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
- A
topupstable that binds each checkout session to one of your users. - A "create top-up" endpoint that validates the amount, calls
POST /v1/checkout/sessionsand redirects the user to the session'surl. - A return page (optional for correctness; the user's money is credited whether or not they come back).
- A webhook endpoint subscribed to
payment.*. - One
credit()function, the only code that adds credit. - A reconciliation job that catches anything a webhook missed.
One-time setup
-
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.
-
Set a settlement address for every chain you will accept (Treasury → Settlement), so funds are forwarded to your wallet. See
refunds-and-settlement. -
Create the credit product, once per workspace:
bashcurl -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 noprice: the amount is named per session.minAmountis your floor (here 1 USD; thru's own floor is 0.01).maxAmountis optional. thru enforces both on every session and reports them in the400.- List stablecoin rails only. thru accepts any registered token on a chain it watches as a rail, and the session's
amountbecomes that many tokens with no conversion: a $37 top-up on a USDC rail asks for 37 USDC, but the same product with aBNBrail 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 (a400that names the rail); it does not refuse a native coin for you. Take the rails from the live stablecoin pairs insupported-chains. - Save the response's
slug(orid) in your config. In the staging workspace, create the same product with"network": "testnet"and a testnet rail (see Testing).
-
Register your return origins (Console → Developers → Checkout, or
PUT /v1/checkout/settings), and fetch the checkout signing secret withGET /v1/checkout/secretif you will verify the signed return. Seecheckout-sessions. -
Register a webhook endpoint for the payment events:
bashcurl -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
secretasTHRU_WEBHOOK_SECRET. Subscribe topayment.*, notcheckout.*: 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 filterpayment.*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
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
- 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.
- Insert the
topupsrow. - Create the session, using your top-up id as the idempotency key and your user id as the
reference:
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"}'
amountis 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
idintotopups.thru_session_id, then redirect the user to the response'surl. The response already shows the locked amount asamountandexpectedAmount. - On a timeout or a network error, send the identical request again. The same
idempotencyKeywith the same product and amount returns the same session. The same key with a different amount or product returns409, so a retry can never silently change the order. - An amount outside the bounds returns
400with the bounds as fields. Show the userminAmount(the effective minimum, never below the product's) rather than a generic error:
{
"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:
{
"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:
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:
{
"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/:idwith your key is the authority. The event only tells you when to look. The read carriescheckoutSession, 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 becomesunderpaidwithreceivedAmount"1", and you credit 1. If they then send the remaining 36 to the same address,receivedAmountbecomes"37",payment.confirmedfires, and you credit the difference. If they sent more, the payment isoverpaidand 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. expectedAmountis what was asked, equal to the session'samount;tokenis the rail's token;feeBpsandfeeAmountare the platform fee recorded on the payment (currently0; this recipe credits the grossreceivedAmount).- Check the rail.
receivedAmountis intokenunits, and crediting it as dollars is only right on a stablecoin rail. Comparingchainandtokenwith 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,overpaidorunderpaid. Indetectedandconfirming,receivedAmountalready counts a transfer that is not final, so areceivedAmount > 0test credits money that can still disappear. Keep the list literal: a payment never becomessettled,createdorfailed(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 ordinarypayment.confirmed/underpaid/overpaidcarrying the samecheckoutSessionas the live payment (thru resolves it from the payment'smetadata.thru.sessionId). Nothing in the response distinguishes a live binding from an abandoned one — compare the payment's own id with the session'spaymentIdif 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/:idprojects the status of the bound payment only, so a session can readexpiredwhile 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:
{
"amount": "5",
"reason": "Customer request"
}
- Only
confirmedandoverpaidpayments can be refunded. Anunderpaidpayment cannot be refunded through the API. - Leave out
amountto refund everything that remains. The maximum isreceivedAmount−feeAmount− earlier refunds. To return only an overpayment, refundreceivedAmount−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
refundedand firespayment.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
amountis 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 insupported-chains. - Validate the amount yourself.
POST /v1/paymentsenforces 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
idfrom the create response, stored on your row; there is no session and nocheckoutSessionon the events, socredit()looks the row up by payment id.
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).