---
title: "Hosted checkout with checkout sessions"
description: "Send a buyer to a thru-hosted page for one of your products, at its fixed price or at an amount your server locks per session; signed return, checkout.session.* and payment.* webhooks, the one-session-one-payment rule, statuses and reconciliation"
source: https://docs.thru.la/checkout-sessions.md
html: https://docs.thru.la/checkout-sessions
index: https://docs.thru.la/llms.txt
---
# Hosted checkout with checkout sessions

A checkout session is one buyer's attempt to buy one of your **products**, tagged with your own `reference` (a user id, an order id). Your server creates the session and sends the buyer to its `url`. thru hosts the payment page, sends the buyer back to you with a signed result, and tells your server by webhook.

What the buyer pays depends on the product's `pricingMode` (`products`):

- **fixed**: the product's price, or its per-chain price for the chain the buyer picks. Use it for plans and packs.
- **custom_amount**: an `amount` your server sends when it creates the session, in USD, within the product's bounds. It is locked into the session: nothing the buyer does on the page, and no later request, can change it. Use it for credit, top-ups and anything priced per purchase (`credit-topups` is the worked recipe).

## Limitations to know first

- **Product sessions only.** The API also accepts `invoiceId` as a source, but the hosted page cannot display an invoice session yet. Do not send buyers to one.
- **The amount comes from the product or from your server, never from the browser.** A fixed-price product charges the product's price for the chosen chain. A custom-amount product charges the session's `amount`.
- **The network comes from the product.** A session has no network field; a product created with `"network": "testnet"` produces testnet sessions.
- **The amount maps 1:1 to token units on whichever rail the buyer picks.** A custom-amount product may list any token thru has registered on a chain whose deposits it detects, and the session's `amount` becomes the payment's `expectedAmount` unchanged: a $37 session paid on a USDC rail expects 37 USDC, and the same session on a BNB rail expects **37 BNB**. thru does no conversion, so give a custom-amount product stablecoin rails only (`products`).

## One-time setup

1. **Create a product** in the console or with `POST /v1/products` (see `products`). Note its `id` or its server-generated `slug`. For credit or top-ups, create it with `"pricingMode": "custom_amount"` and a `minAmount`.
2. **Register your return origins.** thru only ever redirects a buyer to an origin you registered. Use Console → Developers → Checkout, or:

   ```bash
   curl -X PUT https://api.thru.la/v1/checkout/settings \
     -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
     -d '{"returnOrigins":["https://example.com"]}'
   ```

   An origin is scheme, host and port with no path (`https://example.com`, not `https://example.com/billing`). It must be https, except for `localhost` and `127.0.0.1`. You can register up to 20. `PUT` replaces the whole list. `GET /v1/checkout/settings` returns `{ returnOrigins, checkoutSecretSet }`.
3. **Get the checkout signing secret** with `GET /v1/checkout/secret`, which returns `{ "checkoutSecret": "<64 hex characters>" }`. One is created with your first session if you do not have one yet. Store it on your server as, for example, `THRU_CHECKOUT_SECRET`. `POST /v1/checkout/secret/rotate` issues a new one, and returns that are signed with the old secret stop verifying at once.
4. **Register a webhook endpoint.** Pick **one** family to grant from (see "Which family to grant from" below):

   - For a fixed-price product, `checkout.*`: one `checkout.session.completed` per sale.

     ```bash
     curl -X POST https://api.thru.la/v1/webhooks \
       -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
       -d '{"url":"https://example.com/webhooks/thru","eventTypes":["checkout.*"],"description":"Hosted checkout"}'
     ```

   - For a custom-amount product, `payment.*`: you credit from `receivedAmount`, and only the payment events report partial and late money.

     ```bash
     curl -X POST https://api.thru.la/v1/webhooks \
       -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
       -d '{"url":"https://example.com/webhooks/thru","eventTypes":["payment.*"],"description":"Payments"}'
     ```

   The string `checkout.session.*` is **not** an accepted filter and returns `400`. Save the `secret` from the response.

## 1. Create a session (your server)

```bash
curl -X POST https://api.thru.la/v1/checkout/sessions \
  -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
  -d '{"productSlug":"pro-plan-30-days-4f1c2a","reference":"user_1842","metadata":{"plan":"pro"},"idempotencyKey":"upgrade_user_1842_0919","successUrl":"https://example.com/billing/done","pendingUrl":"https://example.com/billing/pending","cancelUrl":"https://example.com/billing"}'
```

| Field | Rules |
|---|---|
| `productId` or `productSlug` | Exactly one source is required; otherwise `400`. The product must be `active`. (`invoiceId` is the third possible source; see Limitations.) |
| `amount` | **Required on a custom-amount product; refused on a fixed-price product or an invoice** (`400 amount is only accepted for a custom_amount product; this product has a fixed price.`). A decimal string in USD with at most 2 decimal places, at least `0.01`, within the product's bounds. See "A custom amount" below. |
| `reference` | Optional, up to 256 characters. Your identifier, returned as you set it (surrounding whitespace trimmed) on retrieve and in every event. It is never shown to the buyer and never put in a URL. |
| `metadata` | Optional JSON object, under 8192 bytes, returned verbatim. |
| `idempotencyKey` | Optional, up to 180 characters. Sending a key again returns the **existing** session when the product and the amount match; a different product or amount returns `409`. Use one key per purchase attempt. See Idempotency below. |
| `successUrl`, `pendingUrl`, `cancelUrl`, `expiredUrl`, `failedUrl`, `returnUrl` | Optional, up to 2048 characters each. Each must be on a registered return origin, or the create fails with `400`. See step 3 for which one is used when. |
| `chain` | Optional. Pins the chain; otherwise the buyer chooses from the product's enabled chains, and may change their choice until money arrives (step 2). Must be the product's chain or one of its `enabledChains`. |
| `locale` | Optional, up to 16 characters. |
| `expiresInSeconds` | Optional integer from 300 to 604800; the default is 1800. It limits how long the link can be *started*. Once the buyer has been shown an address, the payment keeps its own 30-minute clock. |

Response:

```json
{
  "id": "cs_7b41d2e0a9f34c8db6512ee0c73a19f4",
  "object": "checkout.session",
  "url": "https://thru.la/c/cs_7b41d2e0a9f34c8db6512ee0c73a19f4",
  "sessionId": "cs_7b41d2e0a9f34c8db6512ee0c73a19f4",
  "merchantId": "00000000-0000-4000-8000-000000000001",
  "status": "open",
  "reference": "user_1842",
  "metadata": {
    "plan": "pro"
  },
  "referenceOrigin": "server",
  "source": "product",
  "productId": "00000000-0000-4000-8000-0000000000e1",
  "productSlug": null,
  "productName": null,
  "invoiceId": null,
  "invoiceNumber": null,
  "paymentId": null,
  "amount": null,
  "currency": null,
  "chain": null,
  "network": "mainnet",
  "token": null,
  "tokenAddress": null,
  "decimals": null,
  "expectedAmount": null,
  "expectedAmountAtomic": null,
  "receivedAmount": null,
  "receivedAmountAtomic": null,
  "paymentStatus": null,
  "txHash": null,
  "late": false,
  "completedAt": null,
  "createdAt": "2026-09-19T08:00:00.000Z",
  "livemode": true,
  "locale": null,
  "expiresAt": "2026-09-19T08:30:00.000Z",
  "redeemedAt": null
}
```

The create response has `productSlug` and `productName` set to `null`; a retrieve fills them in. The session id (`cs_` + 32 hex characters) is the only credential the hosted page needs, so treat the `url` as belonging to that buyer.

### A custom amount

For a custom-amount product, send the `amount`:

```bash
curl -X POST https://api.thru.la/v1/checkout/sessions \
  -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
  -d '{"productSlug":"account-credit-9c2e4b","amount":"37","reference":"user_1842","metadata":{"orderId":"topup_8813"},"idempotencyKey":"topup_8813","successUrl":"https://example.com/credit/done","pendingUrl":"https://example.com/credit/pending","cancelUrl":"https://example.com/credit"}'
```

```json
{
  "id": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
  "object": "checkout.session",
  "url": "https://thru.la/c/cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
  "sessionId": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
  "merchantId": "00000000-0000-4000-8000-000000000001",
  "status": "open",
  "reference": "user_1842",
  "metadata": {
    "orderId": "topup_8813"
  },
  "referenceOrigin": "server",
  "source": "product",
  "productId": "00000000-0000-4000-8000-0000000000e2",
  "productSlug": null,
  "productName": null,
  "invoiceId": null,
  "invoiceNumber": null,
  "paymentId": null,
  "amount": "37",
  "currency": "USD",
  "chain": null,
  "network": "mainnet",
  "token": null,
  "tokenAddress": null,
  "decimals": null,
  "expectedAmount": "37",
  "expectedAmountAtomic": null,
  "receivedAmount": null,
  "receivedAmountAtomic": null,
  "paymentStatus": null,
  "txHash": null,
  "late": false,
  "completedAt": null,
  "createdAt": "2026-09-19T08:00:00.000Z",
  "livemode": true,
  "locale": null,
  "expiresAt": "2026-09-19T08:30:00.000Z",
  "redeemedAt": null
}
```

Three amounts appear on a session, and they mean different things:

| Field | Meaning |
|---|---|
| `amount`, `currency` | What you locked, in `USD`. `null` on a fixed-price product or an invoice. Nothing can change it after create. |
| `expectedAmount` | What the bound payment asks for, in `token`. Before a payment exists it falls back to `amount`, so the create response already reads `"37"`. Once the buyer has picked a chain it is the payment's, which equals `amount` on a custom-amount product (the same number, in the rail's token: 37 USDC on a stablecoin rail, 37 BNB on a BNB rail) and the chain's price on a fixed-price one. |
| `receivedAmount` | What has actually arrived, cumulative over every transfer to the payment address. `null` until a payment exists. **Credit from this, never from `amount`.** |

**Validation** happens at create, before the buyer sees anything:

- Malformed (`"37.505"`, `"1e2"`, `" 37"`): `400` with `amount must be a decimal string with at most 2 decimal places, e.g. "37" or "37.50".`; below `0.01`: `400 amount must be at least 0.01.`
- A JSON number instead of a string (`37`, not `"37"`) fails earlier, in field validation, and there `message` is an array: `400` with `["amount must be shorter than or equal to 32 characters","amount must be a string"]`.
- Outside the product's bounds: `400`, and the body carries the bounds **as fields** so your backend can show the real minimum without parsing the sentence. The minimum reported is the effective one, the larger of the product's `minAmount` and `0.01`; thru has no other minimum, per chain or otherwise.

  ```json
  {
  "statusCode": 400,
  "error": "Bad Request",
  "message": "amount 0.5 is below the minimum of 1 USD.",
  "minAmount": "1",
  "maxAmount": "500",
  "currency": "USD"
}
  ```

- `amount` on a fixed-price product: `400`. The price is the product's; it is refused, not ignored.

### Idempotency

The same `idempotencyKey` returns the same session **only if the source and the amount match** (`"37"` and `"37.00"` are the same amount). A replay with a different product or a different amount returns `409` instead of quietly handing back the earlier session:

```json
{
  "statusCode": 409,
  "message": "idempotencyKey \"topup_8813\" was already used for a different checkout (product 00000000-0000-4000-8000-0000000000e2, amount 37). Retry with a new key to create a different session.",
  "error": "Conflict"
}
```

Return URLs, `metadata`, `locale` and the other fields are not compared: a retry that differs only there is the same purchase and gets the same session. Two creates with the same key that arrive at the same moment (a retry racing the original, two workers on one order) resolve to the same session, or to a `409` if their amounts differ; neither is ever a `500`. So the safe pattern is one key per order on your side, and the identical request resent after a timeout.

## 2. Redirect the buyer to `url`

The page at `https://thru.la/c/<session id>` shows the product and the amount, lets the buyer pick a chain (unless you pinned one), then shows the address and amount and follows the payment. The buyer can also cancel there.

Under the page, the browser posts `{ chain }` to `POST /v1/public/checkout/sessions/:id/redeem`. That body has **no amount field**: an `amount`, `expectedAmount` or `price` in it returns `400 property amount should not exist`, which is how the amount stays what your server set.

**Changing the chain.** If you did not pin `chain`, the buyer may change their mind after being shown an address, as long as nothing has been sent to it. thru then abandons the first payment, mints a new one on the new chain and binds the session to it. The rule:

- Allowed only while the session is `processing` and the current payment is `waiting_for_payment` with `receivedAmount` `"0"`. Once anything has landed, the redeem returns `409 Funds have already been sent to this checkout, so its payment method cannot be changed. Continue with the existing payment.` and the existing address stands.
- Not available when you pinned `chain`, on an invoice session, or once the session is `cancelled` or `expired` (`409 This checkout is <status>.`). Posting the same chain again, a chain you did not enable, or no chain returns the current quote unchanged.
- The abandoned payment becomes `expired` and a `payment.expired` event is sent for it, carrying `checkoutSession`. See "One session, one payment that ever received money" for what that means for crediting.

## 3. The buyer comes back (signed return)

thru appends four query parameters to the return URL, keeping any query parameters your URL already has:

| Parameter | Value |
|---|---|
| `thru_session` | The session id |
| `thru_status` | `completed`, `pending`, `cancelled`, `expired` or `failed` |
| `thru_ts` | Unix time in seconds when thru signed it |
| `thru_sig` | Lowercase hex HMAC-SHA256 |

Which of your URLs is used for each outcome (first one set wins; "product" means the product's own `successUrl` or `cancelUrl`):

| `thru_status` | URL used |
|---|---|
| `completed` | `successUrl` → `returnUrl` → product `successUrl` |
| `pending` | `pendingUrl` → `returnUrl` → `successUrl` → product `successUrl` |
| `cancelled` | `cancelUrl` → `returnUrl` → product `cancelUrl` |
| `expired` | `expiredUrl` → `cancelUrl` → `returnUrl` → product `cancelUrl` |
| `failed` | `failedUrl` → `cancelUrl` → `returnUrl` → product `cancelUrl` |

`pending` means the buyer has been shown an address but the payment has not finished, so a buyer can leave for your pending page while they wait for confirmations. When the session reaches a final status, the page redirects automatically after 5 seconds.

### Verify the signature

The signed message is:

```
thru.v1|<thru_session>|<thru_status>|<thru_ts>
```

`thru_sig` is the lowercase hex HMAC-SHA256 of that message, keyed with the UTF-8 bytes of your checkout secret string (do not hex-decode it). Reject the return if `|now − thru_ts|` is more than 900 seconds, and compare signatures in constant time.

With the SDK (note the `await`: the function is async, and without it `verified` is always undefined):

```ts
import { verifyThruReturn } from '@thru-payment/server';

// request.url must be ABSOLUTE (Fetch API, Next.js route handlers). With Express, req.url is a
// relative path — pass new URL(req.originalUrl, 'https://' + req.get('host')) instead.
const ret = await verifyThruReturn(request.url, process.env.THRU_CHECKOUT_SECRET!);
if (!ret.verified) {
  // ret.reason: 'missing_parameters' | 'bad_timestamp' | 'expired' | 'bad_signature'
  return showBillingError();
}
// ret.sessionId, ret.status ('completed' | 'pending' | 'cancelled' | 'expired' | 'failed')
```

Test vector for your own implementation (example values only; the secret is the letter `a` repeated 64 times):

```
secret    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
message   thru.v1|cs_7b41d2e0a9f34c8db6512ee0c73a19f4|completed|1789560000
thru_sig  8edb78812f49a7266c242f267332c5c0856fe2752047f177b6006ed55d9bdcc5
```

### Render on the signature, grant on the retrieve

A valid signature proves what thru saw at `thru_ts`. That is enough to **show** "Payment received" immediately. It is not enough to **grant** anything: a page can be replayed from browser history, and a `pending` buyer may go on to underpay. Before granting, read the session with your key:

```bash
curl https://api.thru.la/v1/checkout/sessions/cs_7b41d2e0a9f34c8db6512ee0c73a19f4 \
  -H "x-api-key: $THRU_API_KEY"
```

For a fixed-price product, grant only if `status` is `completed`, `network` (or `livemode`) is what you expect, and `reference` is the user you are granting to. For a custom-amount product, read `paymentId` and credit from the payment's `receivedAmount` through the same code your webhook uses (below). The return page is a convenience for the buyer who comes back; the webhook and the reconciliation job are what credit the buyer who does not.

## 4. The webhook

The webhook is what tells you about buyers who never come back to your site. The body of `checkout.session.completed`:

```json
{
  "id": "00000000-0000-4000-8000-0000000000d1",
  "type": "checkout.session.completed",
  "createdAt": "2026-09-19T08:03:12.000Z",
  "data": {
    "sessionId": "cs_7b41d2e0a9f34c8db6512ee0c73a19f4",
    "merchantId": "00000000-0000-4000-8000-000000000001",
    "status": "completed",
    "reference": "user_1842",
    "metadata": {
      "plan": "pro"
    },
    "referenceOrigin": "server",
    "source": "product",
    "productId": "00000000-0000-4000-8000-0000000000e1",
    "productSlug": "pro-plan-30-days-4f1c2a",
    "productName": "Pro plan, 30 days",
    "invoiceId": null,
    "invoiceNumber": null,
    "paymentId": "00000000-0000-4000-8000-0000000000a2",
    "amount": null,
    "currency": null,
    "chain": "bnb",
    "network": "mainnet",
    "token": "USDC",
    "tokenAddress": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
    "decimals": 18,
    "expectedAmount": "20",
    "expectedAmountAtomic": "20000000000000000000",
    "receivedAmount": "20",
    "receivedAmountAtomic": "20000000000000000000",
    "paymentStatus": "confirmed",
    "txHash": null,
    "late": false,
    "completedAt": "2026-09-19T08:03:12.000Z",
    "createdAt": "2026-09-19T08:00:00.000Z",
    "livemode": true
  }
}
```

All four `checkout.session.*` events carry this same shape; fields that do not apply are `null`. On a custom-amount session `amount` and `currency` are set. A `cancelled` or `expired` event for a session that had reached the address step carries the bound payment's `chain`, `token`, `expectedAmount`, `receivedAmount` and `paymentStatus`. In your handler:

1. Verify `x-thru-signature` over the raw body (see `webhooks`).
2. Ignore bodies with `data.test === true`; those are test sends.
3. Record the envelope `id` and skip it if you have seen it before.
4. On `checkout.session.completed`, check `data.network`, then grant to `data.reference` idempotently, keyed on `data.sessionId`.
5. On `checkout.session.failed`, revoke if you had granted. A full refund produces this event after `completed`.
6. Return `2xx` only after your database write has committed.

`data.late: true` on a completed session means the money arrived after the session had already been reported `expired`, `cancelled` or `failed`. It is still a real payment: grant, and consider telling the buyer.

## Which family to grant from

`payment.*` and `checkout.session.*` describe the same money. Grant from **one** of them, so a sale is never credited twice:

| Product | Grant from | Because |
|---|---|---|
| Fixed price | `checkout.session.completed` | One event per sale, carrying your `reference`. Underpayment does not matter until it resolves. |
| Custom amount (credit, top-ups) | `payment.confirmed`, `payment.underpaid`, `payment.overpaid` | You credit what arrived, and only the payment events report partial money, late money and top-ups to the same address. Since 2026-09-19 every `payment.*` event for a session-bound payment carries `data.checkoutSession: { id, reference, metadata }`, so you need nothing from the session family to know whose money it is. |

If you do listen to both, key your grant on one identifier (the `sessionId`, which `checkoutSession.id` and `metadata.thru.sessionId` also carry) and make it idempotent.

## Crediting a custom amount from `payment.*`

The contract, in one place:

1. Subscribe to `payment.*`. On `payment.confirmed`, `payment.underpaid` and `payment.overpaid`, read `data.payment.id` and `data.checkoutSession` (absent, not `null`, on a payment that never belonged to a session, such as one from the anonymous payment link).
2. Re-read `GET /v1/payments/:id` with your key. It also carries `checkoutSession: { id, reference, metadata } | null`. This read is the authority; the event only tells you when to look.
3. Check `network` and `status` (`confirmed`, `underpaid` or `overpaid`), then credit the **difference** between `receivedAmount` and what you have already credited for that session. `receivedAmount` is cumulative: an underpaid buyer who sends the rest to the same address raises it and fires `payment.confirmed` (or `payment.overpaid`) on the status change; an overpayment reports the whole amount. `expectedAmount` is what was asked, `token` is the rail's token (one token is one dollar only because you listed stablecoin rails; check it before crediting), `feeBps` and `feeAmount` are the platform fee recorded on the payment (currently `0`).
4. There is no threshold below which a receipt is hidden. Any detected transfer, however small, is added to `receivedAmount` and reported as `underpaid` until the total reaches `expectedAmount`. The minimum on the product bounds only what a session may ask for, not what thru will report.

`credit-topups` turns this into code, including the table, the handler and the reconciliation job.

## One session, one payment that ever received money

A session is bound to one payment at a time, and only the bound payment can receive money that counts toward the session. Over its life a session can have had several payments, one per chain change (step 2), but a chain change is only allowed while nothing has been received, so:

- Exactly one payment per session ever receives money **through the hosted page**. `session.paymentId` names it, and `checkoutSession` on its `payment.*` events names the session. Money sent directly to an address the buyer abandoned is the exception — see below.
- The payments abandoned by a chain change are `expired`. Each gets a `payment.expired` event **with** `checkoutSession`, so a `payment.*` listener sees the quote lapse.
- An abandoned address is still watched for the usual late-transfer grace (7 days after the payment's `expiresAt`; `payments` has the mechanism and the recovery past it). If a buyer sends to it anyway, that money is credited to the abandoned payment and reported as a normal `payment.confirmed`/`underpaid`/`overpaid` — and it **carries the same `checkoutSession`** as the live payment, resolved from the payment's `metadata.thru.sessionId`. **Nothing in the payload tells you the binding is historical.** Compare the event's payment id with this session's `paymentId`: equal means the live payment, different means an abandoned one.
- Because of that, the session is **not** a safe key for a credit watermark. Track what you have credited per payment id and sum per session. `GET /v1/checkout/sessions/:id` projects only the bound payment, so it will happily read `expired` while money sits credited on an abandoned payment of the same session.

## Session statuses

The status is recomputed from the bound payment when you retrieve a session. The list endpoint returns the last stored status, so retrieve before you act on one.

| Status | Meaning | Event |
|---|---|---|
| `open` | Created, and the buyer has not been shown an address yet | none |
| `processing` | The buyer has been shown an address, or the payment is underpaid and can still be topped up | none |
| `completed` | The payment is `confirmed` or `overpaid`. Confirmed money outranks a cancellation, even one recorded earlier (the session then completes with `late: true`). | `checkout.session.completed`, once |
| `expired` | Never started before `expiresAt`, or the payment expired unpaid | `checkout.session.expired`, within a few minutes |
| `cancelled` | The buyer cancelled on the hosted page, or you called `/expire`, while the bound payment (if any) was still `waiting_for_payment`, `detected` or `confirming`. A transfer that is detected but not yet confirmed does not block the cancel; once it confirms, the session becomes `completed` with `late: true`. An `underpaid` payment cannot be cancelled over. | `checkout.session.cancelled` |
| `failed` | The payment was fully refunded, or it stayed underpaid for 7 days after the payment's expiry | `checkout.session.failed` for a refund; **no event** for the underpaid timeout |

Things the session events do not tell you (and the reason a custom-amount product grants from `payment.*`):

- **Underpayment.** The session stays `processing` while the buyer can still top up, which is up to 7 days after the payment's `expiresAt` — the same window in which a late transfer is credited automatically (`payments`). Only `payment.underpaid` reports it, with the amount.
- **Overpayment after completion.** If more money arrives after `completed`, no new session event is sent. `payment.overpaid` fires.
- **The transaction hash.** `txHash` is always `null` in session events. Retrieve and list fill it in.

## Close a session you no longer want paid

`POST /v1/checkout/sessions/:id/expire` closes an open session. The resulting status is **`cancelled`** and the event is `checkout.session.cancelled`, not an expiry. It returns `409` if the session is already final. If the buyer had already been shown an address and pays anyway, the session still completes, with `late: true`.

## Reconcile (catch anything a webhook missed)

```
GET /v1/checkout/sessions?createdAfter=<ISO time>&status=completed&limit=100
```

The result is `{ data, hasMore, nextCursor }`, newest first; while `hasMore` is true, pass `cursor=<nextCursor>` to get the next page. Other filters are `reference`, `productId`, and `status` as a comma-separated list. Each item has the same fields as a retrieve, including `amount`, `paymentId` and `receivedAmount`. Run it every few minutes, granting through the same idempotent code as your webhook. For a custom-amount product, also walk `status=processing` sessions, because an underpaid one is `processing` and may have money to credit.

## The same flow with `@thru-payment/server`

```ts
import {
  createThruServerClient,
  verifyThruReturn,
  constructThruEvent,
  isCheckoutSessionEvent,
} from '@thru-payment/server';

const thru = createThruServerClient({ apiKey: process.env.THRU_API_KEY! });

// Checkout button. For a custom-amount product add amount: '37'. The field is typed from 0.3.1;
// an older release only fails to compile on it (the client never validates params at runtime).
const session = await thru.checkout.sessions.create({
  productSlug: 'pro-plan-30-days-4f1c2a',
  reference: user.id,
  idempotencyKey: 'upgrade_' + attempt.id,
  successUrl: 'https://example.com/billing/done',
  pendingUrl: 'https://example.com/billing/pending',
  cancelUrl: 'https://example.com/billing',
});
redirect(session.url);

// Return page: render on the signature, grant on the retrieve
// request.url must be ABSOLUTE (Fetch API, Next.js route handlers). With Express, req.url is a
// relative path — pass new URL(req.originalUrl, 'https://' + req.get('host')) instead.
const ret = await verifyThruReturn(request.url, process.env.THRU_CHECKOUT_SECRET!);
if (ret.verified) {
  const current = await thru.checkout.sessions.retrieve(ret.sessionId);
  if (current.status === 'completed' && current.network === 'mainnet') {
    await grantOnce(current.sessionId, current.reference);
  }
}

// Webhook route: read the RAW body
const raw = await request.text();
const event = await constructThruEvent(raw, request.headers, process.env.THRU_WEBHOOK_SECRET!);
if (isCheckoutSessionEvent(event) && event.type === 'checkout.session.completed') {
  if (event.data.network === 'mainnet') await grantOnce(event.data.sessionId, event.data.reference);
}

// Crediting handler for a custom-amount product (0.3.1+): the authoritative read, with checkoutSession
const payment = await thru.payments.retrieve(paymentId);
if (payment.checkoutSession && payment.network === 'mainnet') {
  await creditUpTo(payment.checkoutSession.id, payment.receivedAmount, payment.status);
}
```

`constructThruEvent` throws `ThruSignatureError` when the signature does not verify; answer that with a `4xx`. The client also has `thru.checkout.sessions.list(params)` and `thru.checkout.sessions.expire(id)`. From **0.3.1**, `@thru-payment/server` also has `thru.payments.retrieve(id)` (the `GET /v1/payments/:id` read a crediting handler needs, returning `checkoutSession` alongside the payment) and `amountBoundsOf(err)`, which returns `{ minAmount, maxAmount, currency }` from a range `400` and `null` for any other error. Install `@thru-payment/server@^0.3.1`, published on npm as `latest`. Until then call `GET /v1/payments/:id` directly.

---

Related: [products](https://docs.thru.la/products.md) · [credit-topups](https://docs.thru.la/credit-topups.md) · [webhooks](https://docs.thru.la/webhooks.md) · [sdks](https://docs.thru.la/sdks.md)
