---
title: "API conventions, errors and limits"
description: "Request and response conventions (decimal strings, ids, timestamps), the error body and every status code, list limits and pagination, idempotency, rate limits and time limits"
source: https://docs.thru.la/api-conventions.md
html: https://docs.thru.la/api-conventions
index: https://docs.thru.la/llms.txt
---
# API conventions, errors and limits

## Requests

- Base URL: `https://api.thru.la/v1`. Send JSON with `content-type: application/json`.
- Authenticate with `x-api-key` (see `auth`).
- **Unknown fields are rejected.** A body field the endpoint does not define returns `400` with `property <name> should not exist`. The same applies to unknown query parameters on `GET /v1/products` and `GET /v1/checkout/sessions`. Put your own data in `metadata` where an endpoint offers it.

## Responses

- **Amounts are decimal strings in token units**, for example `"25"` or `"9.5"`. Parse them with a decimal library, never as floating point. Checkout-session payloads also give atomic amounts (`expectedAmountAtomic`, `receivedAmountAtomic`) with `decimals`.
- Large integers, such as a transfer's `blockNumber`, are strings.
- Timestamps are ISO 8601 in UTC.
- `currency` is a display label. thru never converts between currencies.

## Identifiers

| Object | Format |
|---|---|
| Payment, transfer, refund, product, invoice, webhook endpoint, webhook event, API key | UUID |
| Checkout session | `cs_` followed by 32 lowercase hex characters |
| Invoice `publicId` (the hosted page) | 18 hex characters |
| Invoice `number` | `INV-0001`, counting up per workspace |
| Product `slug` | The name in lowercase with dashes (up to 40 characters), a dash, and 6 hex characters. Generated by thru. |

No other id carries a type prefix; do not validate ids by prefix except `cs_`.

## Errors

Errors return JSON in this shape; `message` is a string, or a list of strings for validation errors:

```json
{ "statusCode": 400, "message": ["property reference should not exist"], "error": "Bad Request" }
```

| Status | Typical causes |
|---|---|
| `400` | A missing or invalid field, an unknown field, an unsupported chain on create, an unknown webhook filter, a return URL outside your registered origins, a refund larger than the maximum, an `amount` on a fixed-price product or missing on a custom-amount one, an amount outside a custom-amount product's bounds (that body also carries `minAmount`, `maxAmount` and `currency` as fields) |
| `401` | No key, an invalid or revoked key, a suspended workspace, or a key used where a signed-in person is required (`A dashboard session is required`) |
| `403` | A console user whose role lacks the permission. Keys are not affected. |
| `404` | The object does not exist, or belongs to another workspace |
| `409` | A conflict with the object's state: a payment idempotency key reused on a different chain or network, a checkout-session idempotency key reused with a different product or amount, a chain change on a checkout session that has already received funds, a refund of a payment that is not refundable, a session that is already final, an archived product, an invoice that is not open, editing an invoice that is not a draft |
| `410` | A product that can no longer be sold |
| `429` | More than 120 requests per minute from one IP to one checkout session's public endpoints. The body includes `retryAfter` in seconds. |
| `5xx` | A server error. Retry with backoff; retrying a create with the same idempotency key is safe. |

## Lists and pagination

| Endpoint | Returns |
|---|---|
| `GET /v1/payments` | The newest 100 as a bare array — or, with any of `updatedAfter` / `cursor` / `limit`, a resumable keyset walk as `{ data, hasMore, nextCursor }`. See `payments`. |
| `GET /v1/invoices` | The newest 200 |
| `GET /v1/refunds` | The newest 100. **No pagination and no `updatedAfter`** — a refund older than that cannot be listed again. |
| `GET /v1/webhooks/events` | The newest 100 events. No pagination. |
| `GET /v1/products` | All products |
| `GET /v1/checkout/sessions` | Pages of `limit` (1 to 100, default 25), newest first, as `{ data, hasMore, nextCursor }`. Pass `cursor=<nextCursor>` for the next page. Ordered by `createdAt` **descending** — a different walk from the two above, and the cursors are not interchangeable. |
| `GET /v1/sweeps` | The newest 100 as a bare array, or the same keyset walk as `GET /v1/payments`. |

To reconcile payments, walk `GET /v1/payments?updatedAfter=…` — it returns every row that changed, oldest first, and each row carries its `checkoutSession`. Do not add a `status` filter to a walk: the filter is re-applied per page, so a row whose status changes mid-walk is skipped for good.

## Idempotency

| Endpoint | Key | Behaviour on a repeat |
|---|---|---|
| `POST /v1/payments` | `idempotencyKey`, up to 120 characters | The same chain and network return the original payment, **without comparing the amount**. A different chain or network returns `409`. |
| `POST /v1/checkout/sessions` | `idempotencyKey`, up to 180 characters | Returns the original session when the source (product or invoice) and the `amount` match (`"37"` equals `"37.00"`). A different source or amount returns `409`. Return URLs, metadata and the other fields are not compared. |

Other creates (products, invoices, webhook endpoints, refunds) take no idempotency key. After a timeout, list or read before retrying, and check `GET /v1/refunds` before you retry a refund, so you do not refund twice.

## Rate limits

The public checkout endpoints (`/v1/public/checkout/*`) allow 120 requests per minute per checkout session per IP, and sign-in allows 10 per IP and 20 per account per 15 minutes. **No other endpoint is rate limited today** — there is no per-key quota on the merchant API. Poll no more often than every few seconds anyway; an unannounced limit is the kind of thing that appears when someone abuses the absence of one.

When a limit does trip, the response is `429` with `{ "statusCode": 429, "message": …, "retryAfter": <seconds> }`. **`retryAfter` is a body field; thru does not send a `Retry-After` header.** Treat any `5xx` as retryable with your own exponential backoff — there is no server-provided hint on those.

## Time limits

| What | Limit |
|---|---|
| Payment expiry | 30 minutes after creation, fixed |
| Late transfers after expiry | Credited automatically for 7 days after `expiresAt`; later ones by support, from the transaction hash — on any status but `refunded`, and forwarded by a second payout when the payment had already paid out (`payments`) |
| Checkout session link | `expiresInSeconds`, 300 to 604800, default 1800 |
| Underpaid checkout session | Stays `processing` until 7 days after the payment's `expiresAt`, then becomes `failed` |
| Signed return | Reject if more than 900 seconds old |
| Webhook response | 10 seconds |

---

Related: [auth](https://docs.thru.la/auth.md) · [payments](https://docs.thru.la/payments.md) · [webhooks](https://docs.thru.la/webhooks.md)
