# 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

`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 cached
  response, with `Idempotency-Replay: true`. The window is 24 hours.
- The **same key with a different payload** returns `409`
  (`idempotency_key_conflict`).
- A key outside 8–200 characters returns `400` (`invalid_idempotency_key`).

```bash
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

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-history` and `/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/sor/decisions` uses `data`, while `/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`:

```ts
let cursor: string | undefined
do {
  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

Limits are per credential and configured per deployment. 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.

## 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

Failures return a JSON body with a stable machine-readable `error` code, usually
alongside a `detail`:

```json
{ "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](https://docs.troncharts.xyz/docs/auth/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.