Positions & accrual
Partition your book into vault sub-accounts, then read what each one holds and what it has earned.
Concepts: Shares, position and accrual
TCB holds one vault position for you per currency, in one wallet. A vault sub-account is a named partition of that position on your own books, so you can attribute deposits and withdrawals to a fund, a strategy or a product.
A vault sub-account confers no wallet, no key and no separate custody. Whichever one you name, its shares — and any tcbUSD booked to it between the steps of the tokenised deposit chain — sit in the same TCB wallet assigned to you. What a sub-account holds is a book entry against that wallet, and a sub-account can spend only what is booked to it.
Use sub-accounts to attribute your position to funds, strategies or products of your own. A sub-account that stands for one of your end customers must be linked to a registered sub-client; how that link is expressed in the API is being finalised.
Every route on this page acts as you, never as a sub-client, and refuses x-on-behalf-of with 400 ON_BEHALF_OF_NOT_ALLOWED_HERE.
Register a vault sub-account
POST /sub-accounts · scope account:manage
| Field | Type | Required | Notes |
|---|---|---|---|
label | string, up to 255 chars | yes | A readable name for this partition. |
externalRef | string, up to 128 chars | yes | Your identifier for the fund, strategy or product this partition stands for. Unique across your own accounts and vault sub-accounts together: the two share one namespace, keyed on you as the owner. Your sub-clients' accounts have namespaces of their own. Letters, digits and . _ : -, starting with a letter or digit. |
The tcb: prefix is reserved
No externalRef you send may begin with tcb:. That includes tcb:default, the sub-account a deposit or withdrawal lands in when it names none. Sending one is refused with 400 SUB_ACCOUNT_EXTERNAL_REF_RESERVED. See the tcb: namespace.
curl -X POST https://api.threecommabank.com/api/partner/v1/sub-accounts \
-H "Authorization: Bearer $TCB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "label": "Fund A", "externalRef": "acme:fund-a" }'{
"success": true,
"data": {
"subAccountId": "5d3b8e10-2f47-4c9a-b6e8-0a71d4c2f395",
"label": "Fund A",
"externalRef": "acme:fund-a",
"isDefault": false,
"createdAt": "2026-09-02T10:15:28.000Z"
},
"timestamp": "2026-09-02T10:15:28.391Z"
}subAccountId is the only identifier. It is what you name as subAccountId on a deposit or a withdrawal. The registry row carries no amounts. Read those from the position.
List your vault sub-accounts
GET /sub-accounts · scope position:read
Returns an array of the object above, oldest first, so your default sub-account leads the list. Exactly one row has isDefault: true. That is where a money call naming no sub-account lands.
curl https://api.threecommabank.com/api/partner/v1/sub-accounts \
-H "Authorization: Bearer $TCB_API_KEY"Read your position
GET /positions · scope position:read
| Query | Required | Notes |
|---|---|---|
currency | yes | The vault's currency, ISO 4217. It selects the vault to report on. |
periodFrom | no | First day of earnedPeriod, YYYY-MM-DD (UTC, inclusive). Send it with periodTo. |
periodTo | no | Last day of earnedPeriod, YYYY-MM-DD (UTC, inclusive), at most 366 days after periodFrom. |
curl -G https://api.threecommabank.com/api/partner/v1/positions \
-H "Authorization: Bearer $TCB_API_KEY" \
--data-urlencode "currency=USD"{
"success": true,
"data": {
"currency": "USD",
"vault": {
"address": "0x7a2fD3c8e91B4A5d06E7c1F2b38A9d4C5e6f0B12",
"chain": "ARBITRUM"
},
"totals": {
"settledShares": "1024.518330000000000000000000",
"reservedShares": "0",
"availableShares": "1024.518330000000000000000000",
"walletShares": "1024.518330000000000000000000",
"accrual": {
"currentValue": "1002.48",
"netContributions": "1000",
"deposited": "1000",
"withdrawn": "0",
"earnedLifetime": "2.48",
"earnedUnrealised": "2.48",
"earnedRealised": "0",
"earnedPeriod": null,
"notObserved": []
}
},
"subAccounts": [
{
"subAccountId": "a0c74e19-6b83-4d51-9f2a-7c3e8b1d0462",
"label": "Default",
"externalRef": "tcb:default",
"isDefault": true,
"settledShares": "0",
"reservedShares": "0",
"availableShares": "0",
"accrual": {
"currentValue": "0",
"netContributions": "0",
"deposited": "0",
"withdrawn": "0",
"earnedLifetime": "0",
"earnedUnrealised": "0",
"earnedRealised": "0",
"earnedPeriod": null,
"notObserved": []
}
},
{
"subAccountId": "5d3b8e10-2f47-4c9a-b6e8-0a71d4c2f395",
"label": "Fund A",
"externalRef": "acme:fund-a",
"isDefault": false,
"settledShares": "1024.518330000000000000000000",
"reservedShares": "0",
"availableShares": "1024.518330000000000000000000",
"accrual": {
"currentValue": "1002.48",
"netContributions": "1000",
"deposited": "1000",
"withdrawn": "0",
"earnedLifetime": "2.48",
"earnedUnrealised": "2.48",
"earnedRealised": "0",
"earnedPeriod": null,
"notObserved": []
}
}
],
"sharePrice": {
"value": "1.002480000000000000",
"asOfBlock": "316183526",
"asOfAt": "2026-10-06T00:00:05.000Z",
"snapshotDate": "2026-10-06",
"stale": false
},
"rate": {
"apyBps": 400,
"asOf": "2026-10-06T12:00:00.000Z",
"stale": false,
"effectiveFrom": "2026-09-01T00:00:00.000Z",
"pendingChange": null,
"nextChange": null,
"unavailableReason": null
},
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-09-02T10:16:02.884Z"
}The disclosure object is the one printed at the top of this page. It is abbreviated in the example.
Share figures
Every share figure on these routes is a decimal string at the vault's own share scale. There is no decimals field anywhere on this API. Only a statement prints shares differently, as integers in the share token's base units. See Amounts and scales.
| Field | Where | Meaning |
|---|---|---|
settledShares | totals and each sub-account | What the sub-account holds, as recorded on your ledger. The total is the sum of the rows below it, never a separately stored figure. |
reservedShares | totals and each sub-account | Shares held against your withdrawals still in flight from that sub-account. |
availableShares | totals and each sub-account | settledShares minus reservedShares. A withdrawal naming a sub-account is authorised against that row's figure alone, never against your whole vault balance. |
walletShares | totals only | The share balance of the wallet TCB holds for you, read from the vault. Published beside the ledger total so you can see the holding your entitlement is against. While a deposit or withdrawal is in flight the two legitimately differ, so a difference is not by itself an error. null when the chain read was unavailable. |
subAccounts[] is driven by the registry, oldest first, so a sub-account you just registered appears with a zero position rather than being absent.
What each sub-account has earned
Each sub-account's accrual is built from its own settled deposits and withdrawals on your ledger, valued at the vault's latest observed share price (sharePrice, with the chain block it was read at and when). TCB's own economics are not an input to any of its fields and never appear on this API.
| Field | Meaning |
|---|---|
currentValue | The position at the latest observed share price, plus movements since that observation at their own amounts, in the underlying currency. Shares settled at the observation are valued at its price; shares deposited since are valued at what was deposited for them until the next observation. |
netContributions | Settled deposits minus settled withdrawals. |
deposited / withdrawn | Everything settled in, and everything settled out as credited to you. |
earnedLifetime | Everything the position has earned: currentValue + withdrawn − deposited. It can be negative. |
earnedUnrealised | Earned on the shares still held: currentValue minus their remaining cost. |
earnedRealised | Earned and locked in by withdrawals: proceeds minus the cost of the shares withdrawn. earnedRealised + earnedUnrealised = earnedLifetime, always. |
earnedPeriod | What was earned between periodFrom and periodTo, with the deposits and withdrawals inside the period. null when you named no period. |
notObserved | Why any figure on the object is null. Empty when every figure is present. |
Cost is average cost: a withdrawal takes its share of the position's cost in proportion to the shares it redeems.
A period covers whole UTC days. It is valued at the vault's first share-price observation on periodFrom and on the day after periodTo, so a period ending today stays null until tomorrow's observation.
totals.accrual is the sum of the sub-account rows, never derived separately. A figure is null in the total when it is null on any row.
Null means not observed
A figure is never 0 because something was not observed. It is null, and notObserved says why:
| Reason | Meaning |
|---|---|
NO_PRICED_OBSERVATION | No share price has been observed for the vault, so the position cannot be valued. |
FLOW_NOT_VALUED | A movement on the sub-account carries no amount in the underlying currency (an operator adjustment), so its earnings cannot be split. |
PERIOD_ENDPOINT_NOT_OBSERVED | A day bounding the period has no observed share price. |
EVIDENCE_INCONSISTENT | This sub-account's records disagree with each other. Its earned figures are withheld and TCB has been alerted. Its share figures still stand, and the rest of the report is unaffected. |
HISTORY_TOO_LONG | The sub-account has more than 5,000 ledger entries. Its earned figures are withheld. Its share figures still stand, and the rest of the report is unaffected. |
notObserved only ever explains a null. Whether the share price is current is said separately, on sharePrice.stale.
A sub-account holding no shares is worth exactly 0; that zero is a fact, not a missing observation.
If a read the report depends on fails, the request fails with 503 CHAIN_UNAVAILABLE. You never receive a partial report.
The share price and the rate
sharePrice is the observation every currentValue on the response is valued at; a deposit settled after it counts at its own amount until the next observation. sharePrice.stale is true when that observation is more than two days old: the figures are still returned, as of sharePrice.asOfAt.
rate.apyBps is the annual rate the vault pays now, in basis points, net of fees. It is read from the vault's strategy on-chain and is variable: it follows the vault's rate schedule. rate.effectiveFrom is when the rate in apyBps took effect, and rate.nextChange is the next scheduled change, or null. When a scheduled change's date has passed but the vault does not pay it yet, apyBps is still the previous rate, effectiveFrom is null, and rate.pendingChange carries the scheduled rate and its date. rate.asOf is when the rate was read. If the latest read failed, you get the last successful read, with its time in asOf and stale set to true. If the rate has never been read, apyBps is null and unavailableReason says why. To derive the rate your position has actually realised, use the share price history.
How accrual arrives
Nothing credits you and nothing is posted on a schedule. Your share count stays where your deposits and withdrawals put it. The vault's share price rises, so currentValue goes up while settledShares stands still. The share price is observed once a day. Poll GET /positions as often as your reporting needs. There is no accrual event to subscribe to.
The yield in this pilot is simulated. Read the disclosure at the top of this page before presenting any of these figures.
Read the share price history
GET /positions/share-price-history · scope position:read
| Query | Required | Notes |
|---|---|---|
currency | yes | The vault's currency, ISO 4217. |
from | no | First day, YYYY-MM-DD (UTC, inclusive). Send it with to. Omit both for the 30 days ending yesterday. |
to | no | Last day, YYYY-MM-DD (UTC, inclusive), at most 366 days after from. |
curl -G https://api.threecommabank.com/api/partner/v1/positions/share-price-history \
-H "Authorization: Bearer $TCB_API_KEY" \
--data-urlencode "currency=USD" \
--data-urlencode "from=2026-10-04" \
--data-urlencode "to=2026-10-06"{
"success": true,
"data": {
"currency": "USD",
"vault": { "address": "0x7a2fD3c8e91B4A5d06E7c1F2b38A9d4C5e6f0B12", "chain": "ARBITRUM" },
"from": "2026-10-04",
"to": "2026-10-06",
"days": [
{ "date": "2026-10-04", "sharePrice": "1.002200000000000000", "asOfBlock": "315491973", "asOfAt": "2026-10-04T00:00:04.000Z" },
{ "date": "2026-10-05", "sharePrice": null, "asOfBlock": null, "asOfAt": null },
{ "date": "2026-10-06", "sharePrice": "1.002480000000000000", "asOfBlock": "316183526", "asOfAt": "2026-10-06T00:00:05.000Z" }
],
"disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-10-07T09:00:00.000Z"
}There is one entry per day, oldest first. A day the share price was not observed has nulls. It is never filled from a neighbouring day and never shown as 0.
Read your tcbUSD
GET /positions/tokens · scope position:read
The tcbUSD booked to each of your sub-accounts in one currency. You hold tcbUSD only when you run the tokenised deposit chain step by step: between a mint and a vault subscription, or between a vault redemption and a tcbUSD redemption. The bundled POST /deposits and POST /withdrawals leave none behind on your book. Your vault shares are on GET /positions, not here.
| Query | Required | Notes |
|---|---|---|
currency | yes | The currency of the token, ISO 4217. USD reads tcbUSD. |
curl -G https://api.threecommabank.com/api/partner/v1/positions/tokens \
-H "Authorization: Bearer $TCB_API_KEY" \
--data-urlencode "currency=USD"{
"success": true,
"data": {
"currency": "USD",
"token": {
"symbol": "tcbUSD",
"address": "0x5c9E0a7d4F3b21C68e0A9b7D1f2E34c5A6b7C8d9",
"chain": "ARBITRUM"
},
"custody": {
"mode": "TCB_SUBACCOUNT",
"walletAddress": "0x3B9aC4f27D18E056b1C4a9F30d2E85c7A6b1F094"
},
"subAccounts": [
{
"subAccountId": "0e4a7c21-9b38-4d56-a1f0-3c8e5d2b7a94",
"label": "Default",
"externalRef": "tcb:default",
"isDefault": true,
"held": "400",
"reserved": "0",
"available": "400"
},
{
"subAccountId": "5d3b8e10-2f47-4c9a-b6e8-0a71d4c2f395",
"label": "Growth Fund II",
"externalRef": "acme-fund-002",
"isDefault": false,
"held": "600",
"reserved": "250",
"available": "350"
}
],
"totals": {
"held": "1000",
"reserved": "250",
"available": "750",
"walletTokens": "1000.004"
},
"yieldNote": "tcbUSD earns nothing on its own: only vault shares carry the yield.",
"asOf": "2026-10-12T09:30:00.000Z",
"disclosure": { "version": "2026-10-12", "summary": "…", "items": ["…"] }
},
"timestamp": "2026-10-12T09:30:00.214Z"
}| Field | Meaning |
|---|---|
held | The tcbUSD booked to the sub-account. |
reserved | tcbUSD held against a tcbUSD redemption or a vault subscription still in flight from that sub-account. |
available | held minus reserved. A tcbUSD redemption or a vault subscription naming the sub-account is authorised against this figure alone, never against the whole wallet. |
totals | The sum of each figure over every sub-account. |
totals.walletTokens | The tcbUSD the TCB wallet assigned to you holds on chain. It can be higher than totals.held: a part of a queued redemption that the vault has paid but that is not yet booked, and fractions of a cent left by a fiat withdrawal, sit in the wallet until they are booked. null when the chain could not be read. |
yieldNote | Always present. tcbUSD earns nothing on its own: only vault shares carry the yield, so this position never carries an accrual. |
An unknown currency, or one with no active token, answers 409 TOKEN_CONFIG_NOT_AVAILABLE.
Handle the errors
| Status | Code | What to do |
|---|---|---|
| 400 | SUB_ACCOUNT_EXTERNAL_REF_RESERVED | Your externalRef begins with tcb:. Choose another. |
| 400 | INVALID_AMOUNT | A period or range date is malformed, ends before it starts, or spans more than 366 days. |
| 403 | INSUFFICIENT_SCOPE | The key lacks account:manage or position:read. |
| 404 | VAULT_NOT_AVAILABLE | No active vault for that currency. |
| 409 | TOKEN_CONFIG_NOT_AVAILABLE | No active token for that currency (GET /positions/tokens). |
| 409 | SUB_ACCOUNT_EXTERNAL_REF_TAKEN | That reference is already used by one of your vault sub-accounts or accounts. |
| 503 | CHAIN_UNAVAILABLE | The chain read failed. Retry. |
The full catalogue is in Errors.
The machine-readable specification for these endpoints is openapi.partner.v1.json. Where it and this page disagree, the specification is authoritative.