thru

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

csharp
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

sql
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…

SituationWhat thru doesWhat you do
The user pays the exact amountpayment.confirmed (and checkout.session.completed, which you are not subscribed to)Credit it.
The user pays lesspayment.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 morepayment.overpaidCredit what arrived, or refund the excess.
The user asks for less than minAmountPOST /v1/checkout/sessions returns 400 with minAmount in the bodyShow the minimum. No session and no payment exist.
The user changes rail before payingThe first payment becomes expired and sends payment.expired with checkoutSession; a new payment is bound to the same sessionNothing to credit. credit() follows the session, not the payment id.
The user tries to change rail after payingThe page gets 409 and keeps the first addressNothing.
Nothing arrives in 30 minutesThe payment becomes expired and payment.expired fires; the session becomes expired a few minutes laterShow "expired". Keep the top-up eligible for credit.
The user pays after the countdownCredited 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 railCredited to that (expired) payment and reported without checkoutSession; metadata.thru.sessionId names the sessionAlert and reconcile by hand: credit it, or refund it.
The user sends again after the payment confirmedNot 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.overpaidSend 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 chainNot detected, and no recovery can credit it either: the token has to be the one the payment asked forContact thru support with the transaction hash. Recovery is manual and not guaranteed.
You fully refunded the payment and money arrives afterwardsNot detected, and recovery is refused with 409 on a refunded paymentContact thru support with the transaction hash. It is a manual case and not guaranteed.
You fully refund the paymentStatus refunded, payment.refunded fires (and checkout.session.failed)Debit per your policy.
A testnet payment reaches your production handlernetwork is testnetYour 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 receivedAmountfeeAmount − earlier refunds. To return only an overpayment, refund receivedAmountexpectedAmount.
  • 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).