thru

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"}'
FieldRules
productId or productSlugExactly one source is required; otherwise 400. The product must be active. (invoiceId is the third possible source; see Limitations.)
amountRequired 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.
referenceOptional, 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.
metadataOptional JSON object, under 8192 bytes, returned verbatim.
idempotencyKeyOptional, 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, returnUrlOptional, 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.
chainOptional. 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.
localeOptional, up to 16 characters.
expiresInSecondsOptional 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:

FieldMeaning
amount, currencyWhat you locked, in USD. null on a fixed-price product or an invoice. Nothing can change it after create.
expectedAmountWhat 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.
receivedAmountWhat 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"
    

}

swift

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

ParameterValue
thru_sessionThe session id
thru_statuscompleted, pending, cancelled, expired or failed
thru_tsUnix time in seconds when thru signed it
thru_sigLowercase 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_statusURL used
completedsuccessUrlreturnUrl → product successUrl
pendingpendingUrlreturnUrlsuccessUrl → product successUrl
cancelledcancelUrlreturnUrl → product cancelUrl
expiredexpiredUrlcancelUrlreturnUrl → product cancelUrl
failedfailedUrlcancelUrlreturnUrl → 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:

php-template
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:

ProductGrant fromBecause
Fixed pricecheckout.session.completedOne event per sale, carrying your reference. Underpayment does not matter until it resolves.
Custom amount (credit, top-ups)payment.confirmed, payment.underpaid, payment.overpaidYou 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.

StatusMeaningEvent
openCreated, and the buyer has not been shown an address yetnone
processingThe buyer has been shown an address, or the payment is underpaid and can still be topped upnone
completedThe 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
expiredNever started before expiresAt, or the payment expired unpaidcheckout.session.expired, within a few minutes
cancelledThe 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
failedThe payment was fully refunded, or it stayed underpaid for 7 days after the payment's expirycheckout.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)

bash
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.