Payments API (direct payments)
A payment asks for one amount of one token on one chain, and comes with a fresh address to receive it. Checkout sessions, payment links and invoices all create payments underneath, so everything here applies to them too.
Create a payment directly when you decide the amount yourself and show the payment screen yourself: you show the address and amount, or embed @thru-payment/pay-sdk. There is no hosted page for a direct payment. For a per-purchase amount on thru's hosted page, use a custom-amount product and a checkout session with amount instead (checkout-sessions, credit-topups); the payment it creates is the same object described here.
Create a payment
POST /v1/payments, with x-api-key.
curl -X POST https://api.thru.la/v1/payments \
-H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
-d '{"chain":"arc","network":"testnet","token":"USDC","amount":"1","currency":"USD","idempotencyKey":"order_1042","metadata":{"orderId":"1042"}}'
| Field | Required | Rules |
|---|---|---|
chain | yes | A chain name such as bnb, sui, arc or robinhood. Read supported-chains first: some accepted values never confirm, and aptos, bitcoin, litecoin, dogecoin, xrp, ton, near, cosmos, polkadot and cardano are rejected with 400. |
network | no | mainnet or testnet. If you leave it out, the server's default applies (mainnet on api.thru.la). Always send it. |
token | yes | A symbol such as USDC, or the token's contract address (a coin type on Sui). Up to 512 characters. It is stored as the registered symbol. A token that is not registered for the chain is accepted, and the payment never confirms. |
amount | yes | A decimal string in token units: "25" is 25 USDC. thru does not enforce a minimum and does not reject zero or negative values, so validate it yourself. |
currency | yes | Up to 24 characters, stored uppercase. A display label only; thru converts nothing. |
idempotencyKey | no | Up to 120 characters, unique within your workspace. See Idempotency below. |
metadata | no | A JSON object, returned on reads and inside most payment.* events (not payment.expired). |
These are all the fields. Any other field (reference, description, customer, expiresAt…) returns 400. The expiry is fixed at 30 minutes and cannot be set per request.
The response is the payment:
{
"id": "00000000-0000-4000-8000-0000000000a1",
"merchantId": "00000000-0000-4000-8000-000000000001",
"chain": "arc",
"network": "testnet",
"token": "USDC",
"amount": "1",
"currency": "USD",
"expectedAmount": "1",
"receivedAmount": "0",
"feeBps": 0,
"feeAmount": "0",
"paymentAddress": "0x3f5c…a91e",
"status": "waiting_for_payment",
"idempotencyKey": "order_1042",
"metadata": {
"orderId": "1042"
},
"productId": null,
"expiresAt": "2026-09-19T08:30:00.000Z",
"createdAt": "2026-09-19T08:00:00.000Z",
"updatedAt": "2026-09-19T08:00:00.000Z",
"confirmedAt": null
}
| Field | Meaning |
|---|---|
id | The payment id (a UUID). Store it. |
paymentAddress | A fresh address for this payment only. |
expectedAmount | The amount you asked for, in token units. |
receivedAmount | The total of all transfers thru has matched to this payment. Starts at "0". |
status | See Statuses below. |
feeBps, feeAmount | The platform fee rate and amount, recorded when money arrives. Set by thru for the whole platform; currently 0. |
productId | Set only on payments created from a product (a payment link or a checkout session). Always null for POST /v1/payments. |
checkoutSession | { id, reference, metadata } of the checkout session the payment is bound to, or null. On GET /v1/payments/:id and on every row of GET /v1/payments. Not in the create response. |
expiresAt, confirmedAt, createdAt, updatedAt | ISO 8601 timestamps. confirmedAt is set when the payment first becomes confirmed. |
Read a payment
| Endpoint | Returns |
|---|---|
GET /v1/payments/:id | The payment plus blockchainTransactions (every transfer matched to it) and checkoutSession ({ id, reference, metadata } when the payment is bound to a checkout session, else null). Use this before you fulfil. |
GET /v1/payments?status=&network= | Your newest 100 payments, each with its latest 3 transfers and its checkoutSession. Add updatedAfter, cursor or limit to walk the whole history instead — see Reconciliation below. |
GET /v1/public/payments/:id | No key needed. A subset safe for a browser: id, chain, network, token, currency, expectedAmount, receivedAmount, paymentAddress, status, expiresAt, createdAt, confirmedAt, and transactions (the latest 5, each { id, txHash, amount, confirmations, status, createdAt }). |
GET /v1/payments/:id after a confirmed payment:
{
"id": "00000000-0000-4000-8000-0000000000a1",
"merchantId": "00000000-0000-4000-8000-000000000001",
"chain": "arc",
"network": "testnet",
"token": "USDC",
"amount": "1",
"currency": "USD",
"expectedAmount": "1",
"receivedAmount": "1",
"feeBps": 0,
"feeAmount": "0",
"paymentAddress": "0x3f5c…a91e",
"status": "confirmed",
"idempotencyKey": "order_1042",
"metadata": {
"orderId": "1042"
},
"productId": null,
"expiresAt": "2026-09-19T08:30:00.000Z",
"createdAt": "2026-09-19T08:00:00.000Z",
"updatedAt": "2026-09-19T08:03:12.000Z",
"confirmedAt": "2026-09-19T08:03:12.000Z",
"blockchainTransactions": [
{
"id": "00000000-0000-4000-8000-0000000000b1",
"paymentId": "00000000-0000-4000-8000-0000000000a1",
"merchantId": "00000000-0000-4000-8000-000000000001",
"chain": "arc",
"network": "testnet",
"txHash": "0x8b2e…41d7",
"logIndex": 0,
"fromAddress": "0x92c4…07fa",
"toAddress": "0x3f5c…a91e",
"tokenAddress": null,
"amount": "1",
"blockNumber": "18204417",
"confirmations": 1,
"status": "confirmed",
"createdAt": "2026-09-19T08:03:12.000Z",
"updatedAt": "2026-09-19T08:03:12.000Z"
}
]
}
Statuses
| Status | Meaning | Webhook |
|---|---|---|
waiting_for_payment | Nothing has arrived yet. | none |
detected, confirming | A transfer was seen but does not have enough confirmations yet. On BNB Chain, Sui, Robinhood Chain and Arc, thru reports a transfer only once it is final, so you normally skip these. | none |
confirmed | The confirmed total equals expectedAmount. | payment.confirmed |
underpaid | The confirmed total is less than expectedAmount. The address still accepts the rest. | payment.underpaid |
overpaid | The confirmed total is more than expectedAmount. | payment.overpaid |
expired | Nothing arrived before expiresAt. | payment.expired |
refunded | The payment was fully refunded. | payment.refunded |
- The status is recomputed from the total received each time a transfer is reported. A
payment.confirmed,payment.underpaidorpayment.overpaidevent fires each time the payment enters that status. - Expiry is a display state, not a closed door. A transfer that arrives after
expiresAtis still credited while the payment is being watched: the payment moves toconfirmed,underpaidoroverpaidand the matching event fires. Only payments with nothing received becomeexpired. "After expiry" below gives the exact window and what happens past it. - A payment never reaches
created,settledorfailed. The enum lists them and nothing writes them: the statuses in the table above are every one a payment can hold. Do not write a branch for the other three, and in particular do not treatsettledas paid — asettledpayment would mean thru had a bug, so alert on it rather than crediting it. Funds reaching your own wallet is a separate event stream (settlement.*) and never changes a payment's status. - Credit from the status, not from
receivedAmount. Credit only when the status isconfirmed,overpaidorunderpaid, and creditreceivedAmountminus what you have already credited for that payment.receivedAmountis cumulative and already counts a transfer that is not yet final while the status isdetectedorconfirming, soreceivedAmount > 0is not by itself a signal that money has arrived.
Required confirmations
| Chain | Confirmations |
|---|---|
bnb | 15 |
base | 20 |
robinhood | 20 |
arc | 1 |
sui | 1 |
solana | 32 |
tron | 19 |
The depth applies to every transfer, not just the latest one. A payment reaches confirmed, underpaid or overpaid only once the shallowest transfer making up receivedAmount has reached the number above — so in any of those three statuses, the whole of receivedAmount is final. While any transfer is still shallow the payment reads detected or confirming, even if the amount is already complete, and those two are exactly the statuses the allow-list tells you not to credit.
After expiry: the watch window
An expired payment's address keeps being watched, so a payer who was slow is credited automatically and you do nothing. That window is a real mechanism with real edges, and they are worth knowing before you promise a payer anything:
- Seven days after
expiresAt. A transfer that reaches the required confirmations inside that window is credited exactly like an on-time one: the status becomesconfirmed,underpaidoroverpaid, the matchingpayment.*event fires, and a checkout session bound to the payment completes (withlate: true, because you may already have told the buyer no). - The list is per chain and network, and it is shared. thru watches the 20,000 most recently expired payments on each chain and network, counting every merchant's, and separately the 2,000 newest payments that have not expired yet. The two budgets are separate on purpose: abandoned checkouts can never push a payment that is still live off the list.
- Eviction is by expiry time, newest kept. If a chain and network ever held more than 20,000 payments that expired within the last seven days, the oldest expiries would drop off first and their window would end early. thru alerts internally when a chain reaches that number; at today's volumes it does not happen, and the seven days is the binding limit.
- Past the window nothing is lost, it just stops being automatic. The address belongs to thru either way. See "Recovering a transfer" below.
Three things fall outside the automatic path entirely, however recent they are: a transfer to a payment that is already confirmed or overpaid, a transfer in a different token than the payment asked for, and a transfer on a chain thru does not run a listener for (supported-chains). The first two are not lost — read on.
Recovering a transfer that was not credited
Send thru support the payment id and the transaction hash. There is nothing to replay: the listener cannot be wound back over an old block, and asking for that is not the path. Support runs a recovery for that one transaction from thru's ops console — thru reads the transaction from the chain, checks the recipient address, the token and the confirmations against the payment, and credits it through the same code path the listener uses. Nobody, inside thru or outside it, can type an amount; the amount always comes from the chain, which is why this is safe to ask for.
Which payments it can be run on. Recovery is accepted while the payment is waiting_for_payment, detected, confirming, underpaid or expired — and also when it is already confirmed or overpaid, because a further transfer to an address that has already been paid is precisely the case this exists for. It is refused with 409 on a refunded payment, and on the three statuses no payment ever really holds (created, settled, failed).
- You see an ordinary credit. The status is recomputed to
confirmed,underpaidoroverpaid, you get the matchingpayment.*webhook, the transfer appears inblockchainTransactionsonGET /v1/payments/:id, and a bound checkout session projects tocompleted. Your handler needs no special case — provided it credits the difference betweenreceivedAmountand what it has already credited, which is the same rule that makes webhook retries safe. - On an already-paid payment the money is forwarded by a SECOND payout. A payment whose funds were already swept is re-opened by the new credit, and the next payout run forwards it automatically: you get a second
settlement.completedfor the samepaymentId, carryingsequence: 2. It is not instant — it waits for the next run of the payout scan — but it is not a ticket either. The cases that still need a person are the ones no run can ever forward, andGET /v1/sweepsnames them withpayoutState: "manual_required". - It is idempotent. Running it again on the same transaction reports that it was already credited and writes nothing, so a support retry cannot double-credit you.
- It needs a final transaction. A transfer that does not yet have its chain's required confirmations is refused rather than half-credited, so the recovery is simply run again once the chain has caught up.
- It cannot move money. A transfer sent to the wrong address, or in a token the payment never asked for, is not something a recovery can credit; that is a manual conversation with thru support and the outcome is not guaranteed.
- Tell the payer that their funds are at an address thru controls, that crediting them needs the transaction hash, and that they must not send a second transfer.
- A recovered credit can arrive weeks later. If your reconciliation job only looks at recent orders, make sure the webhook path itself does not refuse an order for being old.
Reconciliation: walking what changed
A webhook can be lost — your endpoint was down, your queue dropped it, a deploy ate it. The repair for that is not a full table scan: GET /v1/payments takes three parameters that turn it into a resumable walk of everything that has changed.
| Parameter | Meaning |
|---|---|
updatedAfter | ISO 8601. Exclusive, and millisecond precision — the column is timestamp(3). A malformed value is a 400. |
limit | 1–100, default 25. Out-of-range values are clamped, not rejected. |
cursor | The nextCursor from the previous page, passed back unchanged. A malformed value is a 400. |
Pass any of them and the response becomes { data, hasMore, nextCursor } instead of a bare array — the same envelope and the same cursor format GET /v1/checkout/sessions and GET /v1/sweeps use. Pass none and you get the legacy newest-100 array, unchanged. Rows come back oldest-changed first.
GET /v1/payments?updatedAfter=2026-09-20T10:00:00.000Z&limit=100
→ { "data": [ … ], "hasMore": true, "nextCursor": "2026-09-20T10:04:12.881Z|3f2a…" }
GET /v1/payments?updatedAfter=2026-09-20T10:00:00.000Z&limit=100&cursor=2026-09-20T10%3A04%3A12.881Z%7C3f2a…
What the walk guarantees, and what it does not
- Every matching row comes back at least once. A row that changes while you are walking moves ahead of your cursor and is returned again rather than being skipped. Repeats are safe if you credit
receivedAmountminus what you have already credited — the same rule that makes webhook retries safe. - Follow
nextCursoruntil it isnull; it isnullon the last page, so it is not what you store between runs. Never resume mid-walk from a baremax(updatedAt):updatedAfteris exclusive and millisecond-precision, so any other row that changed in that same millisecond is dropped permanently. The cursor carries the row id as a tie-break; a timestamp does not. Between scheduled runs you have two safe options: (a) build the next run'scursoryourself from the last row you consumed, as"<updatedAt>|<id>"— the exact format we return, and both fields are on every row; or (b) if and only if you drained the walk tohasMore: false, takemax(updatedAt)of what you consumed as the nextupdatedAfter— at that point every row sharing that millisecond was necessarily on the final page. - Each page withholds anything updated in the last 60 seconds. A row's
updatedAtis stamped when the write runs, but the row only becomes readable when that write commits. Returning rows inside that gap would let the cursor step past a row you never saw, permanently. The cost is that this endpoint lags reality by a minute; the benefit is that it cannot skip. Use webhooks for anything that must be immediate. - The 60 seconds is a bound, not a hope. It holds because no thru write transaction lives that long: the ones that touch payments and payouts are aborted at 5 seconds, and the database connection itself refuses a statement past 30 seconds or a transaction idling past 15. It is not a guarantee against a database stalled indefinitely. Re-walk from a few minutes earlier once a day and the question stops mattering.
- Do not filter the walk by
status. Walk unfiltered and filter on your side. The filter is re-applied on every page, so a row that changes status mid-walk — aconfirmedpayment that is refunded, anunderpaidone that completes — stops matching, is never returned, and the cursor moves past it. That is a permanent skip, and it is the one way to lose a row that the 60-second lag does not cover. (networknever changes on a payment, so filtering by it is safe.) An unrecognisedstatusvalue is ignored rather than rejected, which silently widens the result set — another reason not to send one.
GET /v1/sweeps takes the same three parameters, with the same envelope, cursor format and 60-second lag — that is the payout half of the same reconciliation. It has no status/network filters, so the hazard above does not arise there.
GET /v1/checkout/sessions looks similar but is not the same walk: it orders by createdAt descending and has no safety lag. Do not reuse a cursor between the three endpoints.
The receiving address is single-use
Each payment gets its own address. thru watches it while the payment is waiting, underpaid, or expired for less than seven days. Once a payment is confirmed or overpaid, further transfers to its address are not detected. They are not lost — support can credit one from its transaction hash, as above, and the money then reaches you through a second payout (sequence: 2) rather than a manual transfer — but the credit itself is hand-driven and needs a ticket. Tell payers to send exactly one transfer, and create a new payment for every purchase; never display an old address again.
If you draw your own QR code, show the amount and token as text beside it. The @thru-payment/pay-sdk QR code encodes only the address, because no single payment-URI format works on every chain.
Idempotency
- Sending a request again with the same
idempotencyKeyand the samechainandnetworkreturns the original payment, unchanged. The amount is not compared, so a key must never be reused for a different amount. - The same key with a different
chainornetworkreturns409. If a payer switches chain, create a new payment with a new key. - Use your own order or top-up id as the key, and resend the identical request after a timeout.
Common mistakes
- Offering a chain or token that is not live (
supported-chains). The payment is created and never confirms, and no error is shown. - Leaving out
network, then fulfilling without checking it. - Treating
amountas dollars whentokenis not a dollar stablecoin."25"with"token": "SUI"asks for 25 SUI. - Granting from the public endpoint, from
metadataalone, or from a webhook without an authenticated read. - Waiting for
settled, or treating it as paid if you ever see it. Paid isconfirmedoroverpaid. - Crediting from
receivedAmountwithout checking the status, which credits a transfer that is stilldetected. - Closing an order the moment its payment is
expired. Show "expired", but leave the crediting path open. - Assuming a second transfer to an address that is already paid will settle like the first. It can be credited, but the payout is made by hand.
- Reusing an idempotency key for a different amount.
- Showing the same address for a second purchase.