Conventions
The response envelope, idempotency, rate limits, versioning, amounts, pagination and timestamps: the rules that hold across every endpoint.
The response envelope
Every successful response is wrapped in the same three fields. Your payload is
always under data:
{
"success": true,
"data": { "…the endpoint's documented object…": "…" },
"timestamp": "2026-09-02T10:15:28.114Z"
}There is no code and no request id at this layer. Pagination lives inside
data, not beside it.
Errors are shaped differently: flat, with a statusCode and a code. They are
documented in full on Errors. Branch on the HTTP status first,
then on code. Never on message.
Idempotency
Every route that moves money or creates a resource requires an idempotency key that you choose. There is no server-minted fallback. A request without a key is refused rather than executed.
Sending the key
Put it in the Idempotency-Key header:
Idempotency-Key: acme-deposit-2026-09-02-0001Routes that take a JSON body also accept it as an idempotencyKey body field.
Sending both is allowed only when they are identical. A disagreement is
refused.
| Rule | |
|---|---|
| Length | 1–100 characters |
| Characters | Letters, digits, and . _ : - |
| Uniqueness | Per flow, per principal. The same string on a deposit and on a withdrawal are different keys. |
| Choose it | When you first decide to move money — not on the retry path. Persist it beside your own record. |
Which routes require one
| Requires a key | Does not |
|---|---|
POST /deposits, POST /withdrawals, POST /conversions, POST /payouts | POST /sub-accounts |
POST /accounts, POST /accounts/{id}/iban, POST /accounts/{id}/close | PATCH /sub-clients/{id} |
POST /sub-clients, POST /sub-clients/{id}/attestations, POST /sub-clients/{id}/currencies, POST /sub-clients/{id}/close | POST /webhooks, DELETE /webhooks/{id}, POST /webhooks/{id}/rotate-secret |
POST /transfers | every GET |
Three routes take no body: POST /accounts/{id}/iban,
POST /accounts/{id}/close and POST /sub-clients/{id}/close. They accept the
key only as the header.
What happens on a repeat
| You send | Result |
|---|---|
| The same key, byte-identical parameters, after the first call finished | The stored result, replayed. Where the response carries a replayed field it reads true. Money moved once. |
| The same key, byte-identical parameters, while the first is still running | 409 IDEMPOTENT_REQUEST_IN_PROGRESS. Wait, then resend the same key. |
| The same key, different parameters | 409 IDEMPOTENCY_KEY_REUSED. Nothing is replayed. |
| A different key | A new, independent request. |
The comparison is a fingerprint of the parameters, and it is exact:
"100"and"100.00"are different requests.- The same body with a different
x-on-behalf-ofis a different request. The header is part of the fingerprint. - Naming a
subAccountIdand omitting it are different requests.
How long a key is remembered
| Routes | Retention |
|---|---|
POST /deposits, POST /withdrawals, POST /conversions, POST /payouts, POST /transfers | Permanently. The key is recorded against the movement it created, so a replay years later still resolves to that one movement. |
| Every other route requiring a key | 24 hours from the first call. After that the key is forgotten and reusing it starts a new request. |
On the five money routes a key is spent forever, so plan a key scheme that
never needs to reuse one. On the resource routes, do not rely on a key to
deduplicate something you might resend a week later. Make your own
externalRef the identity instead.
Rate limits
Requests are counted per principal, not per key. Rotating a key or holding several does not raise your ceiling.
| Default | 120 requests per minute |
| Window | A fixed clock minute, not a rolling window |
| On breach | 429 with code: "MACHINE_RATE_LIMITED" and a limitPerMinute field naming your limit |
| Headers | None. There is no X-RateLimit-* header to read your remaining budget from — read limitPerMinute off the 429 body. |
The limit is configured per client. Ask partners@threecomma.group if you need more. Back off to the start of the next minute and resend with the same idempotency key.
If rate limiting itself is unavailable, requests are refused with a 503
rather than let through unmetered. Nothing was attempted. Retry.
Versioning
The version is the /partner/v1 path segment. Within v1, changes are
additive only:
- New endpoints.
- New optional request fields.
- New response fields.
A breaking change gets a new path segment. It is never a silent change to an existing one. Build your client to ignore unknown response fields rather than fail on them, and an additive release will not require a change.
While the API is a testnet pilot this rule has had two recorded exceptions,
both response fields removed from GET /positions; each is listed under
Removed in the changelog.
The machine-readable specification is updated together with every change to the API surface, so it always describes the current contract.
Amounts and scales
Every amount crossing this API is a decimal string: in a request, in a
response, and inside an error's details.
{ "amount": "1000.00", "settledShares": "1024.518330000000000000000000" }Never a JSON number, never base units, never exponential notation. A JSON number cannot represent every decimal amount exactly.
One exception: the share figures on a statement
(shares, openingShares, closingShares, runningShares) are exact
integers in the share token's base units. They are still strings.
| Kind of amount | Scale |
|---|---|
| Fiat | The currency's minor unit: 2 decimal places for most currencies (USD, EUR, GBP), 0 for JPY and KRW, 3 for BHD, KWD, OMR, JOD and TND. A request carrying a fiat amount with more decimal places (a deposit, withdrawal, payout, conversion or settlement) is refused with 400 AMOUNT_PRECISION_EXCEEDED before anything is held or moved, and details.allowedDecimals says how many are allowed. Round the amount yourself; TCB never rounds it for you. |
| Vault shares | The vault's own share scale, which is not the underlying currency's. |
There is no decimals field anywhere in this API. Read the string, parse it
with a decimal library, and do not infer a scale from the digits a value
happens to carry.
Amounts come back normalised rather than echoed: a submitted "1000.00" is
returned as "1000". Compare amounts numerically, never by string equality.
Pagination
Three list endpoints paginate, with an opaque forward cursor:
| Endpoint | Also filters on |
|---|---|
GET /accounts | currency, role, status |
GET /sub-clients | status, externalRef |
GET /transfers | accountId, from, to |
| Parameter | |
|---|---|
limit | 1–100. Defaults to 25. Outside that range is a 400. |
cursor | Omit for the first page. Then pass back the nextCursor you were given. |
The page sits inside data:
{
"success": true,
"data": {
"items": [ { "…": "…" } ],
"nextCursor": "f39b0c74-51ae-4d82-9b06-3c7e2a5d1f80"
},
"timestamp": "2026-09-02T10:15:28.114Z"
}nextCursor is null on the last page. Stop on that, not on an empty items
array. Treat the cursor as opaque: do not parse it, construct one, or persist
it across runs.
GET /sub-accounts and GET /webhooks are not paginated. They return every
row, oldest first.
Timestamps
Every timestamp is an ISO 8601 string in UTC with milliseconds:
2026-09-02T10:15:28.114ZThat applies to the envelope's timestamp, every createdAt / settledAt /
occurredAt on a response, and the from and to filters on
GET /transfers. A field documented as nullable that reads null means not
yet: settledAt is null until the money lands.
Errors
Every code, and whether it is safe to retry.
Go live
What to have in place before you point volume at this.