Conventions
The rules below describe the /api/v1/* surface. Where a rule is narrower than
that — idempotency and the page envelope both are — it says so.
Idempotency
Section titled “Idempotency”Idempotency-Key is honoured on /api/v1/oms/* — the whole OMS router,
which is every write that can move a position. Elsewhere on /api/v1/* the
header is accepted and ignored, so treat a retry on those routes as a second
write, not a replay.
Where it applies, the key is 8–200 characters:
- Replaying the same key with an identical payload returns the original
response, with
Idempotency-Replay: true. The receipt is durable; it does not expire after a fixed window. - The same key with a different payload returns
409(idempotency_key_conflict). - While the first request is still running, or when it failed with a
5xxthat may have had an effect, the same key returns409(command_outcome_unknown) withRetry-Afterand theintentIdsit created. Reconcile those intents rather than retrying under a new key. - A
5xxfromPOST /api/v1/oms/intentsthat created no intent (for example503 risk_unavailable) releases the key: retry the same request with it. Bracket legs sent withparentIntentIdare excluded and stay unknown. - A key outside 8–200 characters returns
400(invalid_idempotency_key).
curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H "idempotency-key: $(uuidgen)" \ -H 'content-type: application/json' \ -d '{ … }'Send one on every order write. A network timeout on placement is otherwise indistinguishable from a rejection, and retrying without a key can double a position.
Pagination
Section titled “Pagination”Most list endpoints are cursor-paginated.
?limit=<n>— default 100, and the cap is per endpoint: 500 on most, including/api/v1/accounts/{id}/events; 1000 on the/api/v1/accounts/{id}history routes/orders,/trades,/fills,/order-historyand/funding; 5000 (default 1000) on/api/v1/accounts/{id}/state/history.?cursor=<opaque>— take it from the previous response.
Every cursor-paginated list response carries nextCursor, and a null
nextCursor means you have reached the end. Treat the cursor as opaque; it is
not an offset and its encoding is not part of the contract.
Not every list route is cursor-paginated. /api/v1/accounts/{id}/state/history
is window-based: it returns a boolean truncated and no cursor at all, so widen
from/to or raise limit rather than paging it. Small collections such as
/api/v1/accounts and /api/v1/accounts/{id}/positions are returned whole.
The array key is not uniform. The account history routes name the array
after the resource and return no hasMore:
| Endpoint | Array key |
|---|---|
/api/v1/accounts/{id}/orders |
orders |
/api/v1/accounts/{id}/trades |
trades |
/api/v1/accounts/{id}/fills |
fills |
/api/v1/accounts/{id}/funding |
events |
Other list endpoints do carry hasMore, but the array key still varies:
/api/v1/reports/* also names the array after the resource (orders, fills,
positionsClosed, funding). Take
the shape from the endpoint’s own reference page rather than assuming one
envelope, and on a cursor-paginated route page until nextCursor is null:
let cursor: string | undefineddo { const url = new URL(`https://api.troncharts.xyz/api/v1/accounts/${accountId}/trades`) url.searchParams.set('limit', '200') if (cursor) url.searchParams.set('cursor', cursor)
const res = await fetch(url, { headers: { authorization: `Bearer ${token}`, 'x-tenant-slug': tenantSlug }, }) const page = (await res.json()) as { trades: unknown[]; nextCursor: string | null }
handle(page.trades) cursor = page.nextCursor ?? undefined} while (cursor)Rate limits
Section titled “Rate limits”Limits are per credential, as a token bucket whose default depends on your tier:
| Tier | Sustained | Burst |
|---|---|---|
readonly |
60 req/s | 120 |
liquidation |
120 req/s | 240 |
fullTrading |
240 req/s | 480 |
A deployment can override these, so read them off the response rather than
hard-coding: every /api/v1/* reply carries X-RateLimit-Limit and
X-RateLimit-Remaining. X-RateLimit-Limit is the burst, not the
sustained rate — a fullTrading credential sees 480 there and refills at
240/s, so treating the header as a per-second budget under-uses your limit by
half.
When you exceed one you get 429 with a Retry-After header — honour it
rather than backing off on a fixed timer. The bootstrap path
(POST /api/auth/bootstrap) is hard-capped at 10 requests per minute per API
key, separately from the bucket above.
Decimals
Section titled “Decimals”Sizes and prices are accepted as decimal strings or numbers, and the server
coerces. Prefer strings: JSON numbers are IEEE-754 doubles, and a size like
0.1 does not survive the round-trip exactly. The platform reads money as
decimals end to end — match it.
Errors
Section titled “Errors”Failures return a JSON body with a stable machine-readable error code, usually
alongside a detail:
{ "error": "venue_rejected", "detail": "insufficient margin" }detail is best-effort, not guaranteed: some failures carry none at all, and on
validation errors it is a structured array of field issues rather than a
sentence. Never require it to be present or to be a string.
| Status | Means |
|---|---|
400 |
Malformed request — a field is missing or the wrong shape. |
401 |
No credential, or an expired / revoked token. |
403 |
Authenticated but not allowed — wrong scope, wrong tier, or another tenant’s resource. |
404 |
The request resolved to no tenant (unknown_tenant_origin). Send X-Tenant-Slug or call from a registered origin — see Tenancy. |
409 |
Conflict — idempotency-key reuse with a different payload, or a state transition that isn’t legal. |
410 |
The tenant is suspended (tenant_suspended). |
422 |
The venue rejected the order. detail carries the venue’s reason. |
429 |
Rate-limited. Read Retry-After. |
A 404 is the one to read carefully: it far more often means “you did not
identify a tenant” than “that endpoint does not exist”. Check the error code
before concluding a route was removed.
Branch on error, never on detail — the code is contract, the prose is not.