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
amountyour 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-topupsis the worked recipe).
Limitations to know first
- Product sessions only. The API also accepts
invoiceIdas 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
amountbecomes the payment'sexpectedAmountunchanged: 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
-
Create a product in the console or with
POST /v1/products(seeproducts). Note itsidor its server-generatedslug. For credit or top-ups, create it with"pricingMode": "custom_amount"and aminAmount. -
Register your return origins. thru only ever redirects a buyer to an origin you registered. Use Console → Developers → Checkout, or:
bashcurl -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, nothttps://example.com/billing). It must be https, except forlocalhostand127.0.0.1. You can register up to 20.PUTreplaces the whole list.GET /v1/checkout/settingsreturns{ returnOrigins, checkoutSecretSet }. -
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/rotateissues a new one, and returns that are signed with the old secret stop verifying at once. -
Register a webhook endpoint. Pick one family to grant from (see "Which family to grant from" below):
-
For a fixed-price product,
checkout.*: onecheckout.session.completedper sale.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":["checkout.*"],"description":"Hosted checkout"}' -
For a custom-amount product,
payment.*: you credit fromreceivedAmount, and only the payment events report partial and late money.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"}'
The string
checkout.session.*is not an accepted filter and returns400. Save thesecretfrom the response. -
1. Create a session (your server)
curl -X POST https://api.thru.la/v1/checkout/sessions \
-H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
-d '{"productSlug":"pro-plan-30-days-4f1c2a","reference":"user_1842","metadata":{"plan":"pro"},"idempotencyKey":"upgrade_user_1842_0919","successUrl":"https://example.com/billing/done","pendingUrl":"https://example.com/billing/pending","cancelUrl":"https://example.com/billing"}'
| Field | Rules |
|---|---|
productId or productSlug | Exactly one source is required; otherwise 400. The product must be active. (invoiceId is the third possible source; see Limitations.) |
amount | Required on a custom-amount product; refused on a fixed-price product or an invoice (400 amount is only accepted for a custom_amount product; this product has a fixed price.). A decimal string in USD with at most 2 decimal places, at least 0.01, within the product's bounds. See "A custom amount" below. |
reference | Optional, up to 256 characters. Your identifier, returned as you set it (surrounding whitespace trimmed) on retrieve and in every event. It is never shown to the buyer and never put in a URL. |
metadata | Optional JSON object, under 8192 bytes, returned verbatim. |
idempotencyKey | Optional, up to 180 characters. Sending a key again returns the existing session when the product and the amount match; a different product or amount returns 409. Use one key per purchase attempt. See Idempotency below. |
successUrl, pendingUrl, cancelUrl, expiredUrl, failedUrl, returnUrl | Optional, up to 2048 characters each. Each must be on a registered return origin, or the create fails with 400. See step 3 for which one is used when. |
chain | Optional. Pins the chain; otherwise the buyer chooses from the product's enabled chains, and may change their choice until money arrives (step 2). Must be the product's chain or one of its enabledChains. |
locale | Optional, up to 16 characters. |
expiresInSeconds | Optional integer from 300 to 604800; the default is 1800. It limits how long the link can be started. Once the buyer has been shown an address, the payment keeps its own 30-minute clock. |
Response:
{
"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:
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"}'
{
"id": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
"object": "checkout.session",
"url": "https://thru.la/c/cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
"sessionId": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
"merchantId": "00000000-0000-4000-8000-000000000001",
"status": "open",
"reference": "user_1842",
"metadata": {
"orderId": "topup_8813"
},
"referenceOrigin": "server",
"source": "product",
"productId": "00000000-0000-4000-8000-0000000000e2",
"productSlug": null,
"productName": null,
"invoiceId": null,
"invoiceNumber": null,
"paymentId": null,
"amount": "37",
"currency": "USD",
"chain": null,
"network": "mainnet",
"token": null,
"tokenAddress": null,
"decimals": null,
"expectedAmount": "37",
"expectedAmountAtomic": null,
"receivedAmount": null,
"receivedAmountAtomic": null,
"paymentStatus": null,
"txHash": null,
"late": false,
"completedAt": null,
"createdAt": "2026-09-19T08:00:00.000Z",
"livemode": true,
"locale": null,
"expiresAt": "2026-09-19T08:30:00.000Z",
"redeemedAt": null
}
Three amounts appear on a session, and they mean different things:
| Field | Meaning |
|---|---|
amount, currency | What you locked, in USD. null on a fixed-price product or an invoice. Nothing can change it after create. |
expectedAmount | What the bound payment asks for, in token. Before a payment exists it falls back to amount, so the create response already reads "37". Once the buyer has picked a chain it is the payment's, which equals amount on a custom-amount product (the same number, in the rail's token: 37 USDC on a stablecoin rail, 37 BNB on a BNB rail) and the chain's price on a fixed-price one. |
receivedAmount | What has actually arrived, cumulative over every transfer to the payment address. null until a payment exists. Credit from this, never from amount. |
Validation happens at create, before the buyer sees anything:
-
Malformed (
"37.505","1e2"," 37"):400withamount must be a decimal string with at most 2 decimal places, e.g. "37" or "37.50".; below0.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 theremessageis an array:400with["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'sminAmountand0.01; thru has no other minimum, per chain or otherwise.json{ "statusCode": 400, "error": "Bad Request", "message": "amount 0.5 is below the minimum of 1 USD.", "minAmount": "1", "maxAmount": "500", "currency": "USD"
}
- `amount` on a fixed-price product: `400`. The price is the product's; it is refused, not ignored.
### Idempotency
The same `idempotencyKey` returns the same session **only if the source and the amount match** (`"37"` and `"37.00"` are the same amount). A replay with a different product or a different amount returns `409` instead of quietly handing back the earlier session:
```json
{
"statusCode": 409,
"message": "idempotencyKey \"topup_8813\" was already used for a different checkout (product 00000000-0000-4000-8000-0000000000e2, amount 37). Retry with a new key to create a different session.",
"error": "Conflict"
}
Return URLs, metadata, locale and the other fields are not compared: a retry that differs only there is the same purchase and gets the same session. Two creates with the same key that arrive at the same moment (a retry racing the original, two workers on one order) resolve to the same session, or to a 409 if their amounts differ; neither is ever a 500. So the safe pattern is one key per order on your side, and the identical request resent after a timeout.
2. Redirect the buyer to url
The page at https://thru.la/c/<session id> shows the product and the amount, lets the buyer pick a chain (unless you pinned one), then shows the address and amount and follows the payment. The buyer can also cancel there.
Under the page, the browser posts { chain } to POST /v1/public/checkout/sessions/:id/redeem. That body has no amount field: an amount, expectedAmount or price in it returns 400 property amount should not exist, which is how the amount stays what your server set.
Changing the chain. If you did not pin chain, the buyer may change their mind after being shown an address, as long as nothing has been sent to it. thru then abandons the first payment, mints a new one on the new chain and binds the session to it. The rule:
- Allowed only while the session is
processingand the current payment iswaiting_for_paymentwithreceivedAmount"0". Once anything has landed, the redeem returns409 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 iscancelledorexpired(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
expiredand apayment.expiredevent is sent for it, carryingcheckoutSession. See "One session, one payment that ever received money" for what that means for crediting.
3. The buyer comes back (signed return)
thru appends four query parameters to the return URL, keeping any query parameters your URL already has:
| Parameter | Value |
|---|---|
thru_session | The session id |
thru_status | completed, pending, cancelled, expired or failed |
thru_ts | Unix time in seconds when thru signed it |
thru_sig | Lowercase hex HMAC-SHA256 |
Which of your URLs is used for each outcome (first one set wins; "product" means the product's own successUrl or cancelUrl):
thru_status | URL used |
|---|---|
completed | successUrl → returnUrl → product successUrl |
pending | pendingUrl → returnUrl → successUrl → product successUrl |
cancelled | cancelUrl → returnUrl → product cancelUrl |
expired | expiredUrl → cancelUrl → returnUrl → product cancelUrl |
failed | failedUrl → cancelUrl → returnUrl → product cancelUrl |
pending means the buyer has been shown an address but the payment has not finished, so a buyer can leave for your pending page while they wait for confirmations. When the session reaches a final status, the page redirects automatically after 5 seconds.
Verify the signature
The signed message is:
thru.v1|<thru_session>|<thru_status>|<thru_ts>
thru_sig is the lowercase hex HMAC-SHA256 of that message, keyed with the UTF-8 bytes of your checkout secret string (do not hex-decode it). Reject the return if |now − thru_ts| is more than 900 seconds, and compare signatures in constant time.
With the SDK (note the await: the function is async, and without it verified is always undefined):
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:
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:
{
"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:
- Verify
x-thru-signatureover the raw body (seewebhooks). - Ignore bodies with
data.test === true; those are test sends. - Record the envelope
idand skip it if you have seen it before. - On
checkout.session.completed, checkdata.network, then grant todata.referenceidempotently, keyed ondata.sessionId. - On
checkout.session.failed, revoke if you had granted. A full refund produces this event aftercompleted. - Return
2xxonly after your database write has committed.
data.late: true on a completed session means the money arrived after the session had already been reported expired, cancelled or failed. It is still a real payment: grant, and consider telling the buyer.
Which family to grant from
payment.* and checkout.session.* describe the same money. Grant from one of them, so a sale is never credited twice:
| Product | Grant from | Because |
|---|---|---|
| Fixed price | checkout.session.completed | One event per sale, carrying your reference. Underpayment does not matter until it resolves. |
| Custom amount (credit, top-ups) | payment.confirmed, payment.underpaid, payment.overpaid | You credit what arrived, and only the payment events report partial money, late money and top-ups to the same address. Since 2026-09-19 every payment.* event for a session-bound payment carries data.checkoutSession: { id, reference, metadata }, so you need nothing from the session family to know whose money it is. |
If you do listen to both, key your grant on one identifier (the sessionId, which checkoutSession.id and metadata.thru.sessionId also carry) and make it idempotent.
Crediting a custom amount from payment.*
The contract, in one place:
- Subscribe to
payment.*. Onpayment.confirmed,payment.underpaidandpayment.overpaid, readdata.payment.idanddata.checkoutSession(absent, notnull, on a payment that never belonged to a session, such as one from the anonymous payment link). - Re-read
GET /v1/payments/:idwith your key. It also carriescheckoutSession: { id, reference, metadata } | null. This read is the authority; the event only tells you when to look. - Check
networkandstatus(confirmed,underpaidoroverpaid), then credit the difference betweenreceivedAmountand what you have already credited for that session.receivedAmountis cumulative: an underpaid buyer who sends the rest to the same address raises it and firespayment.confirmed(orpayment.overpaid) on the status change; an overpayment reports the whole amount.expectedAmountis what was asked,tokenis the rail's token (one token is one dollar only because you listed stablecoin rails; check it before crediting),feeBpsandfeeAmountare the platform fee recorded on the payment (currently0). - There is no threshold below which a receipt is hidden. Any detected transfer, however small, is added to
receivedAmountand reported asunderpaiduntil the total reachesexpectedAmount. 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.paymentIdnames it, andcheckoutSessionon itspayment.*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 apayment.expiredevent withcheckoutSession, so apayment.*listener sees the quote lapse. - An abandoned address is still watched for the usual late-transfer grace (7 days after the payment's
expiresAt;paymentshas 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 normalpayment.confirmed/underpaid/overpaid— and it carries the samecheckoutSessionas the live payment, resolved from the payment'smetadata.thru.sessionId. Nothing in the payload tells you the binding is historical. Compare the event's payment id with this session'spaymentId: 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/:idprojects only the bound payment, so it will happily readexpiredwhile money sits credited on an abandoned payment of the same session.
Session statuses
The status is recomputed from the bound payment when you retrieve a session. The list endpoint returns the last stored status, so retrieve before you act on one.
| Status | Meaning | Event |
|---|---|---|
open | Created, and the buyer has not been shown an address yet | none |
processing | The buyer has been shown an address, or the payment is underpaid and can still be topped up | none |
completed | The payment is confirmed or overpaid. Confirmed money outranks a cancellation, even one recorded earlier (the session then completes with late: true). | checkout.session.completed, once |
expired | Never started before expiresAt, or the payment expired unpaid | checkout.session.expired, within a few minutes |
cancelled | The buyer cancelled on the hosted page, or you called /expire, while the bound payment (if any) was still waiting_for_payment, detected or confirming. A transfer that is detected but not yet confirmed does not block the cancel; once it confirms, the session becomes completed with late: true. An underpaid payment cannot be cancelled over. | checkout.session.cancelled |
failed | The payment was fully refunded, or it stayed underpaid for 7 days after the payment's expiry | checkout.session.failed for a refund; no event for the underpaid timeout |
Things the session events do not tell you (and the reason a custom-amount product grants from payment.*):
- Underpayment. The session stays
processingwhile the buyer can still top up, which is up to 7 days after the payment'sexpiresAt— the same window in which a late transfer is credited automatically (payments). Onlypayment.underpaidreports it, with the amount. - Overpayment after completion. If more money arrives after
completed, no new session event is sent.payment.overpaidfires. - The transaction hash.
txHashis alwaysnullin session events. Retrieve and list fill it in.
Close a session you no longer want paid
POST /v1/checkout/sessions/:id/expire closes an open session. The resulting status is cancelled and the event is checkout.session.cancelled, not an expiry. It returns 409 if the session is already final. If the buyer had already been shown an address and pays anyway, the session still completes, with late: true.
Reconcile (catch anything a webhook missed)
GET /v1/checkout/sessions?createdAfter=<ISO time>&status=completed&limit=100
The result is { data, hasMore, nextCursor }, newest first; while hasMore is true, pass cursor=<nextCursor> to get the next page. Other filters are reference, productId, and status as a comma-separated list. Each item has the same fields as a retrieve, including amount, paymentId and receivedAmount. Run it every few minutes, granting through the same idempotent code as your webhook. For a custom-amount product, also walk status=processing sessions, because an underpaid one is processing and may have money to credit.
The same flow with @thru-payment/server
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.