API conventions, errors and limits
Requests
- Base URL:
https://api.thru.la/v1. Send JSON withcontent-type: application/json. - Authenticate with
x-api-key(seeauth). - Unknown fields are rejected. A body field the endpoint does not define returns
400withproperty <name> should not exist. The same applies to unknown query parameters onGET /v1/productsandGET /v1/checkout/sessions. Put your own data inmetadatawhere 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) withdecimals. - Large integers, such as a transfer's
blockNumber, are strings. - Timestamps are ISO 8601 in UTC.
currencyis 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:
{ "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 |