Pilot environment: testnet, simulated yield, mocked fiat rails — read the disclosure
Using the API

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:

Response
{
  "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-0001

Routes 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
Length1–100 characters
CharactersLetters, digits, and . _ : -
UniquenessPer flow, per principal. The same string on a deposit and on a withdrawal are different keys.
Choose itWhen you first decide to move money — not on the retry path. Persist it beside your own record.

Which routes require one

Requires a keyDoes not
POST /deposits, POST /withdrawals, POST /conversions, POST /payoutsPOST /sub-accounts
POST /accounts, POST /accounts/{id}/iban, POST /accounts/{id}/closePATCH /sub-clients/{id}
POST /sub-clients, POST /sub-clients/{id}/attestations, POST /sub-clients/{id}/currencies, POST /sub-clients/{id}/closePOST /webhooks, DELETE /webhooks/{id}, POST /webhooks/{id}/rotate-secret
POST /transfersevery 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 sendResult
The same key, byte-identical parameters, after the first call finishedThe 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 running409 IDEMPOTENT_REQUEST_IN_PROGRESS. Wait, then resend the same key.
The same key, different parameters409 IDEMPOTENCY_KEY_REUSED. Nothing is replayed.
A different keyA 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-of is a different request. The header is part of the fingerprint.
  • Naming a subAccountId and omitting it are different requests.

How long a key is remembered

RoutesRetention
POST /deposits, POST /withdrawals, POST /conversions, POST /payouts, POST /transfersPermanently. 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 key24 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.

Default120 requests per minute
WindowA fixed clock minute, not a rolling window
On breach429 with code: "MACHINE_RATE_LIMITED" and a limitPerMinute field naming your limit
HeadersNone. 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 amountScale
FiatThe 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 sharesThe 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:

EndpointAlso filters on
GET /accountscurrency, role, status
GET /sub-clientsstatus, externalRef
GET /transfersaccountId, from, to
Parameter
limit1–100. Defaults to 25. Outside that range is a 400.
cursorOmit for the first page. Then pass back the nextCursor you were given.

The page sits inside data:

Response
{
  "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.114Z

That 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.

Pilot disclosure

On this page