---
title: "Payments API (direct payments)"
description: "POST /v1/payments field by field, the response, reads, the status lifecycle, confirmations, the seven-day late-transfer watch window, how a missed transfer is recovered and when its payout is manual, the single-use address, idempotency and common mistakes"
source: https://docs.thru.la/payments.md
html: https://docs.thru.la/payments
index: https://docs.thru.la/llms.txt
---
# 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`.

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

```json
{
  "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:

```json
{
  "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.underpaid` or `payment.overpaid` event fires each time the payment **enters** that status.
- **Expiry is a display state, not a closed door.** A transfer that arrives after `expiresAt` is still credited while the payment is being watched: the payment moves to `confirmed`, `underpaid` or `overpaid` and the matching event fires. Only payments with nothing received become `expired`. "After expiry" below gives the exact window and what happens past it.
- **A payment never reaches `created`, `settled` or `failed`.** 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 treat `settled` as paid** — a `settled` payment 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 is `confirmed`, `overpaid` or `underpaid`, and credit `receivedAmount` **minus what you have already credited** for that payment. `receivedAmount` is cumulative and already counts a transfer that is not yet final while the status is `detected` or `confirming`, so `receivedAmount > 0` is 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 becomes `confirmed`, `underpaid` or `overpaid`, the matching `payment.*` event fires, and a checkout session bound to the payment completes (with `late: 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`, `underpaid` or `overpaid`, you get the matching `payment.*` webhook, the transfer appears in `blockchainTransactions` on `GET /v1/payments/:id`, and a bound checkout session projects to `completed`. Your handler needs no special case — provided it credits the difference between `receivedAmount` and 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.completed` for the same `paymentId`, carrying `sequence: 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, and `GET /v1/sweeps` names them with `payoutState: "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 `receivedAmount` minus what you have already credited — the same rule that makes webhook retries safe.
- **Follow `nextCursor` until it is `null`; it is `null` on the last page, so it is not what you store between runs.** Never resume mid-walk from a bare `max(updatedAt)`: `updatedAfter` is 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's `cursor` yourself 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 to `hasMore: false`, take `max(updatedAt)` of what you consumed as the next `updatedAfter` — 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 `updatedAt` is 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 — a `confirmed` payment that is refunded, an `underpaid` one 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. (`network` never changes on a payment, so filtering by it is safe.) An unrecognised `status` value 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 `idempotencyKey` and the same `chain` and `network` returns 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 `chain` or `network` returns `409`. 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 `amount` as dollars when `token` is not a dollar stablecoin. `"25"` with `"token": "SUI"` asks for 25 SUI.
- Granting from the public endpoint, from `metadata` alone, or from a webhook without an authenticated read.
- Waiting for `settled`, or treating it as paid if you ever see it. Paid is `confirmed` or `overpaid`.
- Crediting from `receivedAmount` without checking the status, which credits a transfer that is still `detected`.
- 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.

---

Related: [credit-topups](https://docs.thru.la/credit-topups.md) · [webhooks](https://docs.thru.la/webhooks.md) · [supported-chains](https://docs.thru.la/supported-chains.md) · [refunds-and-settlement](https://docs.thru.la/refunds-and-settlement.md)
