---
title: "Recipe: sell prepaid credit (top-ups)"
description: "Let users top up a balance with any amount on the thru-hosted page: one custom_amount product, a checkout session per top-up with the amount locked, credit on payment.* from receivedAmount; table, handler, idempotent credit step, reconciliation; plus the direct-payments variant for your own screen"
source: https://docs.thru.la/credit-topups.md
html: https://docs.thru.la/credit-topups
index: https://docs.thru.la/llms.txt
---
# Recipe: sell prepaid credit (top-ups)

Your users buy credit for your service (API usage, minutes, tokens) by paying stablecoins, and they choose the amount, for example any whole number of dollars from 1 up. You credit them with what actually arrives, one dollar of credit per dollar of stablecoin.

The recommended build is **one custom-amount product plus one checkout session per top-up**: your server names the amount, thru hosts the payment page, and you credit on the `payment.*` events from the payment's `receivedAmount`. Nothing about the amount ever passes through the browser. The last section keeps the older variant, a **direct payment** with your own payment screen, for when you do not want the hosted page.

## What you build

1. A `topups` table that binds each checkout session to one of your users.
2. A "create top-up" endpoint that validates the amount, calls `POST /v1/checkout/sessions` and redirects the user to the session's `url`.
3. A return page (optional for correctness; the user's money is credited whether or not they come back).
4. A webhook endpoint subscribed to `payment.*`.
5. One `credit()` function, the only code that adds credit.
6. A reconciliation job that catches anything a webhook missed.

## One-time setup

1. In the console, create a **production workspace** and a separate **staging workspace**, and an API key in each (Developers → API keys). One key works on both networks, so a separate workspace is what keeps staging traffic away from real money.
2. Set a **settlement address** for every chain you will accept (Treasury → Settlement), so funds are forwarded to your wallet. See `refunds-and-settlement`.
3. **Create the credit product**, once per workspace:

   ```bash
   curl -X POST https://api.thru.la/v1/products \
     -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
     -d '{"name":"Account credit","kind":"one_off","pricingMode":"custom_amount","chain":"bnb","network":"mainnet","token":"USDC","minAmount":"1","maxAmount":"500","networks":[{"chain":"bnb","token":"USDC"},{"chain":"sui","token":"USDC"}],"successUrl":"https://example.com/credit/done","cancelUrl":"https://example.com/credit"}'
   ```

   - `pricingMode: "custom_amount"` and no `price`: the amount is named per session.
   - `minAmount` is your floor (here 1 USD; thru's own floor is 0.01). `maxAmount` is optional. thru enforces both on every session and reports them in the `400`.
   - **List stablecoin rails only.** thru accepts any registered token on a chain it watches as a rail, and the session's `amount` becomes that many tokens with no conversion: a $37 top-up on a USDC rail asks for 37 USDC, but the same product with a `BNB` rail would ask for 37 BNB, and this recipe would credit it as 37 dollars. The API refuses only a chain it does not watch or a token it has not registered (a `400` that names the rail); it does not refuse a native coin for you. Take the rails from the live stablecoin pairs in `supported-chains`.
   - Save the response's `slug` (or `id`) in your config. In the staging workspace, create the same product with `"network": "testnet"` and a testnet rail (see Testing).
4. **Register your return origins** (Console → Developers → Checkout, or `PUT /v1/checkout/settings`), and fetch the checkout signing secret with `GET /v1/checkout/secret` if you will verify the signed return. See `checkout-sessions`.
5. **Register a webhook endpoint** for the payment events:

   ```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"}'
   ```

   Store the returned `secret` as `THRU_WEBHOOK_SECRET`. Subscribe to `payment.*`, **not** `checkout.*`: you credit from the amount that arrived, and only the payment events carry it for partial and late money. Subscribing to both families is how a top-up gets credited twice. The filter `payment.*` also matches the payment-rule events (`payment.flow.step`, `payment.flow.approved`, `payment.flow.rejected`), so ignore event types you do not handle.

## The table

```sql
CREATE TABLE topups (
  id               text PRIMARY KEY,               -- your own id; also the session's idempotencyKey
  user_id          text NOT NULL,
  requested_amount numeric NOT NULL,               -- what the user chose, in USD
  network          text NOT NULL,                  -- 'mainnet' in production
  thru_session_id  text UNIQUE,                    -- cs_… from the create response
  thru_payment_id  uuid,                           -- the payment bound to the session, once known
  credited_amount  numeric NOT NULL DEFAULT 0,     -- how much you have credited so far
  status           text NOT NULL DEFAULT 'pending',
  created_at       timestamptz NOT NULL DEFAULT now()
);
```

The binding between a session and a user is this row, written by your server. The session's `reference` and `metadata` are echoed back on every event and help when debugging, but the row is the binding: do not credit an account because a `reference` says so.

## Step 1: create the top-up

1. **Validate the amount in your own terms** (a whole number of dollars, your own maximum). thru then checks it again against the product's bounds, so a value your UI let through is still refused before the user sees a payment page.
2. Insert the `topups` row.
3. Create the session, using your top-up id as the idempotency key and your user id as the `reference`:

```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"}'
```

- `amount` is a decimal string in USD with at most 2 decimal places (`"37"`, `"37.50"`). It is locked into the session: no page parameter and no later request can change it.
- Save the response's `id` into `topups.thru_session_id`, then redirect the user to the response's `url`. The response already shows the locked amount as `amount` and `expectedAmount`.
- **On a timeout or a network error, send the identical request again.** The same `idempotencyKey` with the same product and amount returns the same session. The same key with a different amount or product returns `409`, so a retry can never silently change the order.
- An amount outside the bounds returns `400` with the bounds as fields. Show the user `minAmount` (the effective minimum, never below the product's) rather than a generic error:

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

## Step 2: the user pays on thru

The hosted page shows the amount and the rails you enabled, lets the user pick one, then shows the address and follows the payment. The user can change the rail until money has been sent to the first address; after that the first address stands (the page gets a `409` and keeps showing it). Whatever happens, the amount is the one you set.

When the user comes back to your `successUrl` or `pendingUrl`, verify the signed return and show a status; do not credit from it. The return says the session's state at signing time, and the credit comes from Step 4 through the webhook or the reconciliation job, whether or not the user ever returns. `checkout-sessions` has the return parameters and the verification code.

## Step 3: the webhook endpoint

```
on POST /webhooks/thru:
  raw = the raw request body (bytes), before any JSON parsing
  if not valid signature(raw, header "x-thru-signature", THRU_WEBHOOK_SECRET): return 401
  event = parse(raw)                      # { id, type, createdAt, data }
  if event.data.test == true: return 200  # a test send from the console or API
  switch event.type:
    "payment.confirmed", "payment.underpaid", "payment.overpaid":
        credit(event.data.payment.id)
    "payment.expired":                    # different shape: no data.payment object
        mark the top-up "expired" for display, and keep accepting credit for it
        (look it up by event.data.checkoutSession.id; also sent when the user changed rail)
    "payment.refunded":
        apply your refund policy (look it up by event.data.payment.id)
    anything else:
        ignore
  return 200 only after your database commit; return 500 on any error so thru retries
```

Every `payment.*` event for a payment that belongs to a session carries `data.checkoutSession: { id, reference, metadata }`. The key is absent (not `null`) on a payment that was never in a session. A confirmed top-up looks like this:

```json
{
  "id": "00000000-0000-4000-8000-0000000000d3",
  "type": "payment.confirmed",
  "createdAt": "2026-09-19T08:03:12.000Z",
  "data": {
    "merchantId": "00000000-0000-4000-8000-000000000001",
    "eventType": "payment.confirmed",
    "payment": {
      "id": "00000000-0000-4000-8000-0000000000a3",
      "merchantId": "00000000-0000-4000-8000-000000000001",
      "chain": "bnb",
      "network": "mainnet",
      "token": "USDC",
      "amount": "37",
      "currency": "USDC",
      "expectedAmount": "37",
      "receivedAmount": "37",
      "feeBps": 0,
      "feeAmount": "0",
      "paymentAddress": "0x3f5c…a91e",
      "status": "confirmed",
      "idempotencyKey": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a:bnb",
      "metadata": {
        "thru": {
          "sessionId": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
          "reference": "user_1842"
        }
      },
      "productId": "00000000-0000-4000-8000-0000000000e2",
      "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"
    },
    "blockchainTransaction": {
      "id": "00000000-0000-4000-8000-0000000000b2",
      "paymentId": "00000000-0000-4000-8000-0000000000a3",
      "merchantId": "00000000-0000-4000-8000-000000000001",
      "chain": "bnb",
      "network": "mainnet",
      "txHash": "0x8b2e…41d7",
      "logIndex": 3,
      "fromAddress": "0x92c4…07fa",
      "toAddress": "0x3f5c…a91e",
      "tokenAddress": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
      "amount": "37",
      "blockNumber": "52118604",
      "confirmations": 15,
      "status": "confirmed",
      "createdAt": "2026-09-19T08:03:12.000Z",
      "updatedAt": "2026-09-19T08:03:12.000Z"
    },
    "checkoutSession": {
      "id": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
      "reference": "user_1842",
      "metadata": {
        "orderId": "topup_8813"
      }
    }
  }
}
```

The same handler in Go:

```go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"strings"
)

// verifyThruSignature checks x-thru-signature over the raw body.
// The HMAC key is the secret string's bytes. Do not hex-decode the secret.
func verifyThruSignature(raw []byte, header, secret string) bool {
	const prefix = "sha256="
	if !strings.HasPrefix(header, prefix) {
		return false
	}
	got, err := hex.DecodeString(header[len(prefix):])
	if err != nil || len(got) != sha256.Size {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(raw)
	return hmac.Equal(mac.Sum(nil), got)
}

type thruEvent struct {
	ID   string `json:"id"`
	Type string `json:"type"`
	Data struct {
		Test      bool   `json:"test"`
		PaymentID string `json:"paymentId"` // payment.expired only
		Payment   struct {
			ID string `json:"id"`
		} `json:"payment"` // confirmed, underpaid, overpaid, refunded
		CheckoutSession *struct {
			ID        string `json:"id"`
			Reference string `json:"reference"`
		} `json:"checkoutSession"` // present only for a session-bound payment
	} `json:"data"`
}

func (s *Server) thruWebhook(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	if !verifyThruSignature(raw, r.Header.Get("x-thru-signature"), s.thruWebhookSecret) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	var ev thruEvent
	if err := json.Unmarshal(raw, &ev); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if ev.Data.Test {
		w.WriteHeader(http.StatusOK)
		return
	}
	switch ev.Type {
	case "payment.confirmed", "payment.underpaid", "payment.overpaid":
		err = s.credit(r.Context(), ev.Data.Payment.ID)
	case "payment.expired":
		if ev.Data.CheckoutSession != nil {
			err = s.markTopupExpired(r.Context(), ev.Data.CheckoutSession.ID)
		}
	case "payment.refunded":
		err = s.onTopupRefunded(r.Context(), ev.Data.Payment.ID)
	}
	if err != nil {
		http.Error(w, "retry later", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
}
```

Deliveries can repeat: thru retries failed deliveries, and a retry goes to every matching endpoint again. You can record `event.id` and skip ids you have seen, but the `credit()` step below is safe to run any number of times on its own.

## Step 4: `credit()`, the only code that adds credit

```
credit(paymentId):
  p = GET https://api.thru.la/v1/payments/{paymentId}   with header x-api-key
  if p.checkoutSession is null:
      alert; return            # the anonymous payment link, or a payment you created yourself
  BEGIN TRANSACTION
    t = SELECT * FROM topups WHERE thru_session_id = p.checkoutSession.id FOR UPDATE
    if t is missing:                              COMMIT; return   # not one of your top-ups
    if p.network != t.network:                    COMMIT; alert; return
    if (p.chain, p.token) not in STABLECOIN_RAILS: COMMIT; alert; return   # the rails you listed on the product
    if p.status not in ("confirmed", "overpaid", "underpaid"): COMMIT; return

    # The high-water mark is PER PAYMENT, never per top-up. A top-up can have more than one
    # payment (the buyer changed rail), and every one of them reports the same
    # checkoutSession.id — so a per-top-up watermark makes two payments overwrite each other.
    c = SELECT * FROM topup_credits WHERE payment_id = p.id FOR UPDATE   -- (topup_id, payment_id) unique
    already = c.credited_amount if c else 0
    delta = decimal(p.receivedAmount) - already
    if delta > 0:
      add delta * CREDIT_PER_USD to the balance of t.user_id
      UPSERT topup_credits (topup_id, payment_id, credited_amount) VALUES (t.id, p.id, p.receivedAmount)
      UPDATE topups SET thru_payment_id = p.id, status = p.status WHERE id = t.id
  COMMIT
```

`GET /v1/payments/:id` for the confirmed top-up above:

```json
{
  "id": "00000000-0000-4000-8000-0000000000a3",
  "merchantId": "00000000-0000-4000-8000-000000000001",
  "chain": "bnb",
  "network": "mainnet",
  "token": "USDC",
  "amount": "37",
  "currency": "USDC",
  "expectedAmount": "37",
  "receivedAmount": "37",
  "feeBps": 0,
  "feeAmount": "0",
  "paymentAddress": "0x3f5c…a91e",
  "status": "confirmed",
  "idempotencyKey": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a:bnb",
  "metadata": {
    "thru": {
      "sessionId": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
      "reference": "user_1842"
    }
  },
  "productId": "00000000-0000-4000-8000-0000000000e2",
  "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-0000000000b2",
      "paymentId": "00000000-0000-4000-8000-0000000000a3",
      "merchantId": "00000000-0000-4000-8000-000000000001",
      "chain": "bnb",
      "network": "mainnet",
      "txHash": "0x8b2e…41d7",
      "logIndex": 3,
      "fromAddress": "0x92c4…07fa",
      "toAddress": "0x3f5c…a91e",
      "tokenAddress": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
      "amount": "37",
      "blockNumber": "52118604",
      "confirmations": 15,
      "status": "confirmed",
      "createdAt": "2026-09-19T08:03:12.000Z",
      "updatedAt": "2026-09-19T08:03:12.000Z"
    }
  ],
  "checkoutSession": {
    "id": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
    "reference": "user_1842",
    "metadata": {
      "orderId": "topup_8813"
    }
  }
}
```

- **Read from the API, not from the event.** `GET /v1/payments/:id` with your key is the authority. The event only tells you when to look. The read carries `checkoutSession`, so you do not need a second call to the sessions API.
- **Credit `receivedAmount`**, which is what actually arrived, cumulative over every transfer to the address. If a user asked for 37 and sent 1, the payment becomes `underpaid` with `receivedAmount` `"1"`, and you credit 1. If they then send the remaining 36 to the same address, `receivedAmount` becomes `"37"`, `payment.confirmed` fires, and you credit the difference. If they sent more, the payment is `overpaid` and you credit all of it (or refund the excess, see below). thru hides no small receipt: a transfer of any size is added and reported.
- **`expectedAmount` is what was asked**, equal to the session's `amount`; `token` is the rail's token; `feeBps` and `feeAmount` are the platform fee recorded on the payment (currently `0`; this recipe credits the gross `receivedAmount`).
- **Check the rail.** `receivedAmount` is in `token` units, and crediting it as dollars is only right on a stablecoin rail. Comparing `chain` and `token` with the list you put on the product is the last line of defence against a rail added by mistake.
- **Check the status against the allow-list**, and nothing else. Credit only when it is `confirmed`, `overpaid` or `underpaid`. In `detected` and `confirming`, `receivedAmount` already counts a transfer that is not final, so a `receivedAmount > 0` test credits money that can still disappear. Keep the list literal: a payment never becomes `settled`, `created` or `failed` (`payments`), so if one ever does, refusing to credit it is the right answer and the alert is the point.
- **Check the network.** One key serves testnet and mainnet. Without this check, a payment made with free testnet tokens could add real credit.
- **Use decimal arithmetic.** Amounts are strings such as `"37"` or `"9.5"`. Never parse them into floating point.
- **Credit only the difference, and keep the high-water mark per PAYMENT.** Duplicate and out-of-order events then add nothing. Keying the watermark on the session instead is the one mistake that loses money — see the next point.
- **A session can end up with money on MORE THAN ONE payment, and all of them report the same `checkoutSession`.** Changing rail is only allowed while nothing has been received, so at most one payment is *live* — but the abandoned address stays watched for the usual 7-day grace, and a buyer who already copied it can still send there. That money is credited to the abandoned payment and reported as an ordinary `payment.confirmed`/`underpaid`/`overpaid` **carrying the same `checkoutSession`** as the live payment (thru resolves it from the payment's `metadata.thru.sessionId`). Nothing in the response distinguishes a live binding from an abandoned one — compare the payment's own id with the session's `paymentId` if you need to know. This is exactly why the watermark is per payment: the top-up's credited total is the SUM over its payments, not the maximum.
- **The session never aggregates them.** `GET /v1/checkout/sessions/:id` projects the status of the bound payment only, so a session can read `expired` while real money sits credited on an abandoned payment of the same session. Trust your own per-payment ledger, not the session status, for what you owe the user.

## Step 5: reconciliation

Every few minutes, take your top-ups created in the last 8 days whose `status` is not yet `confirmed`, `overpaid` or `refunded` (an `underpaid` one can still receive the rest), read each session with `GET /v1/checkout/sessions/:id`, and if `paymentId` is set run `credit(paymentId)`. This catches a webhook that failed all its retries, and an event that was never delivered because no endpoint matched it (those are not retried automatically).

Iterate over your own table, or over `GET /v1/checkout/sessions?createdAfter=…&status=processing,completed`, which is paginated. `GET /v1/payments` returns only the newest 100 payments and has no pagination, so it cannot be used to walk history.

## What happens when…

| Situation | What thru does | What you do |
|---|---|---|
| The user pays the exact amount | `payment.confirmed` (and `checkout.session.completed`, which you are not subscribed to) | Credit it. |
| The user pays less | `payment.underpaid`; `receivedAmount` is what arrived. The session stays `processing`. | Credit what arrived. The user can send the rest to the same address, which moves the payment to `confirmed` and fires `payment.confirmed`, or start a new top-up. |
| The user pays more | `payment.overpaid` | Credit what arrived, or refund the excess. |
| The user asks for less than `minAmount` | `POST /v1/checkout/sessions` returns `400` with `minAmount` in the body | Show the minimum. No session and no payment exist. |
| The user changes rail before paying | The first payment becomes `expired` and sends `payment.expired` with `checkoutSession`; a new payment is bound to the same session | Nothing to credit. `credit()` follows the session, not the payment id. |
| The user tries to change rail after paying | The page gets `409` and keeps the first address | Nothing. |
| Nothing arrives in 30 minutes | The payment becomes `expired` and `payment.expired` fires; the session becomes `expired` a few minutes later | Show "expired". Keep the top-up eligible for credit. |
| The user pays after the countdown | Credited automatically for 7 days after `expiresAt`, with the usual `payment.confirmed`/`underpaid`/`overpaid` event. Later than that it is not replayed by any listener: thru support credits that one transaction from its hash and the same event fires (`payments`) | Credit as normal, through the same handler — a recovered credit can land weeks after the top-up was created, so do not reject it for being old. Still, tell users to pay before the countdown ends. |
| The user sends to an address they abandoned by changing rail | Credited to that (expired) payment and reported without `checkoutSession`; `metadata.thru.sessionId` names the session | Alert and reconcile by hand: credit it, or refund it. |
| The user sends again after the payment confirmed | Not detected, not credited. The funds sit on an address thru controls. Support **can** credit that transfer — a paid payment is an accepted case, not a refused one — which moves the payment to `overpaid` and fires `payment.overpaid` | Send thru support the payment id and the transaction hash. The recovery is idempotent and fires the normal event (`payments`). The money reaches you through a **second payout**: the credit re-opens the payment's payout and the next run forwards it, arriving as another `settlement.completed` with `sequence: 2`. |
| The user sends the wrong token or uses the wrong chain | Not detected, and no recovery can credit it either: the token has to be the one the payment asked for | Contact thru support with the transaction hash. Recovery is manual and not guaranteed. |
| You fully refunded the payment and money arrives afterwards | Not detected, and recovery is **refused** with `409` on a `refunded` payment | Contact thru support with the transaction hash. It is a manual case and not guaranteed. |
| You fully refund the payment | Status `refunded`, `payment.refunded` fires (and `checkout.session.failed`) | Debit per your policy. |
| A testnet payment reaches your production handler | `network` is `testnet` | Your network check refuses it. |

## Refunds

`POST /v1/payments/:id/refund` with an optional body:

```json
{
  "amount": "5",
  "reason": "Customer request"
}
```

- Only `confirmed` and `overpaid` payments can be refunded. An `underpaid` payment cannot be refunded through the API.
- Leave out `amount` to refund everything that remains. The maximum is `receivedAmount` − `feeAmount` − earlier refunds. To return only an overpayment, refund `receivedAmount` − `expectedAmount`.
- By default the refund goes to the address that sent the first transfer. If the user paid from an exchange, that address belongs to the exchange, so ask the user for an address and pass it as `toAddress`.
- A full refund moves the payment to `refunded` and fires `payment.refunded`. A partial refund fires no event.

## Testing

In your **staging** workspace, create the credit product with `"network": "testnet"`, `"chain": "arc"`, `"token": "USDC"` (Arc testnet is the only testnet api.thru.la watches today, and USDC is native there, so it is a valid custom-amount rail). Then create sessions on it and pay them with test USDC from Circle's faucet; the `quickstart` shows how to get and send test USDC. On Arc, USDC must be sent as a normal wallet transfer of the native asset; a transfer made from inside a smart contract, including one through USDC's ERC-20 interface at `0x3600…0000`, is not detected. Your staging handler should accept `testnet`, and your production handler must not.

The acceptance run, end to end: create a $37 session and pay it; create one below the minimum and see the `400` with `minAmount`; post an `amount` to the public redeem endpoint and see `400`; resend the same create request and get the same session back; close the page after paying and still receive `payment.confirmed`; underpay, then send the rest, and see `receivedAmount` climb; change rail before paying and see `payment.expired` with `checkoutSession` followed by a credit on the new payment.

`POST /v1/webhooks/:id/test` sends a signed test delivery with `data.test: true`. Its body is always shaped like a checkout-session event, whatever `eventType` you ask for, so it proves your URL, TLS and signature check, but not your `payment.*` parsing. Use a real testnet payment for that.

## Alternative: your own payment screen with direct payments

If you would rather show the payment screen yourself, skip the product and the session and create a **direct payment** per top-up (`POST /v1/payments`; see `payments`). Everything above still applies, with these differences:

- Your server decides the rail, and `amount` is in **token units** of that rail's token, so offer stablecoins only: a payment in BNB, SUI or ETH would be credited as that many coins, not dollars. Take your rail list from the "Live on api.thru.la" column in `supported-chains`.
- **Validate the amount yourself.** `POST /v1/payments` enforces no minimum and does not reject zero or negative amounts.
- The idempotency key is scoped by chain and network, and the amount is **not** compared on a replay: never reuse a key for a different amount.
- The binding is the payment `id` from the create response, stored on your row; there is no session and no `checkoutSession` on the events, so `credit()` looks the row up by payment id.

```bash
curl -X POST https://api.thru.la/v1/payments \
  -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
  -d '{"chain":"bnb","network":"mainnet","token":"USDC","amount":"25","currency":"USD","idempotencyKey":"topup_7f3c9a1e","metadata":{"userId":"user_1842","topupId":"7f3c9a1e"}}'
```

Show the user the **exact amount and token** as text, the **chain** by name with a warning to send only this token on this chain, `paymentAddress` as text and as a QR code, a countdown to `expiresAt`, and this notice: **"This address is for this payment only. Send one transfer. Anything sent after this payment is confirmed will not be credited."** For the live status the browser can poll `GET /v1/public/payments/:id` every 5 seconds without a key; use it for display only. A React frontend can render the whole screen with `<ThruCheckout paymentId={id} />` from `@thru-payment/pay-sdk` (see `sdks`).

---

Related: [checkout-sessions](https://docs.thru.la/checkout-sessions.md) · [products](https://docs.thru.la/products.md) · [webhooks](https://docs.thru.la/webhooks.md) · [payments](https://docs.thru.la/payments.md) · [supported-chains](https://docs.thru.la/supported-chains.md)
