thru

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

ObjectFormat
Payment, transfer, refund, product, invoice, webhook endpoint, webhook event, API keyUUID
Checkout sessioncs_ followed by 32 lowercase hex characters
Invoice publicId (the hosted page)18 hex characters
Invoice numberINV-0001, counting up per workspace
Product slugThe 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" }
StatusTypical causes
400A 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)
401No 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)
403A console user whose role lacks the permission. Keys are not affected.
404The object does not exist, or belongs to another workspace
409A 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
410A product that can no longer be sold
429More than 120 requests per minute from one IP to one checkout session's public endpoints. The body includes retryAfter in seconds.
5xxA server error. Retry with backoff; retrying a create with the same idempotency key is safe.

Lists and pagination

EndpointReturns
GET /v1/paymentsThe 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/invoicesThe newest 200
GET /v1/refundsThe newest 100. No pagination and no updatedAfter — a refund older than that cannot be listed again.
GET /v1/webhooks/eventsThe newest 100 events. No pagination.
GET /v1/productsAll products
GET /v1/checkout/sessionsPages 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/sweepsThe 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

EndpointKeyBehaviour on a repeat
POST /v1/paymentsidempotencyKey, up to 120 charactersThe same chain and network return the original payment, without comparing the amount. A different chain or network returns 409.
POST /v1/checkout/sessionsidempotencyKey, up to 180 charactersReturns 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

WhatLimit
Payment expiry30 minutes after creation, fixed
Late transfers after expiryCredited 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 linkexpiresInSeconds, 300 to 604800, default 1800
Underpaid checkout sessionStays processing until 7 days after the payment's expiresAt, then becomes failed
Signed returnReject if more than 900 seconds old
Webhook response10 seconds