Webhooks
thru sends your server an HTTPS POST when something happens to your money. Each delivery is signed with your endpoint's secret.
Register an endpoint
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"}'
| Field | Rules |
|---|---|
url | Required. Where thru POSTs. |
description | Optional, up to 120 characters. |
eventTypes | Optional list of filters. Empty or omitted means every event. |
The response is the endpoint, including its secret (64 hex characters). GET /v1/webhooks also returns each endpoint's secret, so it is not shown only once, but still keep it on your server only.
Filters
Each eventTypes entry is one of:
- an exact type, such as
payment.confirmed; - a family wildcard:
checkout.*,payment.*,settlement.*,facilitator.*orpayment.flow.*; *for everything.
A filter that names only the status types (payment.confirmed, payment.underpaid, payment.overpaid) does NOT receive payment.amount_increased. payment.* does. If you credit from receivedAmount you want it, because it is the only event for money that arrives without moving the status.
An unknown entry returns 400. Note that checkout.session.* is not an accepted filter; use checkout.* or list the four checkout.session.* types. The payment.* wildcard also matches the payment-rule events (payment.flow.step, payment.flow.approved, payment.flow.rejected); payment.flow.* subscribes to just those three. (flow.* was offered until 2026-09-21 and matched nothing — it is now refused with a 400 rather than silently delivering no events.) GET /v1/webhooks/event-types returns the catalogue and the wildcards.
Managing endpoints
| Endpoint | Does |
|---|---|
GET /v1/webhooks | Lists endpoints, with secrets |
PATCH /v1/webhooks/:id | Changes url, enabled, description or eventTypes |
DELETE /v1/webhooks/:id | Deletes the endpoint and its delivery log |
POST /v1/webhooks/:id/rotate-secret | Issues a new secret and returns the endpoint. The old secret stops working at once. For a rotation without downtime, register a second endpoint with the new secret, switch your handler over, then delete the first. |
POST /v1/webhooks/:id/test | Sends a signed test delivery (see Testing) |
Which events to listen to
| If you use… | Subscribe to | Grant on |
|---|---|---|
Direct payments (POST /v1/payments) | payment.* | payment.confirmed, payment.overpaid (and payment.underpaid if you accept partial payments) |
| Hosted checkout sessions for a fixed-price product | checkout.* | checkout.session.completed |
| Hosted checkout sessions for a custom-amount product (credit, top-ups) | payment.* | payment.confirmed, payment.underpaid, payment.overpaid, crediting the payment's receivedAmount. data.checkoutSession says which session, and so which of your users (credit-topups). |
| Payment links or invoices | payment.* | payment.confirmed, payment.overpaid |
| Settlement monitoring (funds reaching your wallet) | settlement.* | Never. Use it for treasury reconciliation. |
payment.* and checkout.session.* describe the same money. A checkout session that completes produces both payment.confirmed and checkout.session.completed. Grant from one family. If you subscribe to both, make your grant idempotent on one key (the sessionId, which data.checkoutSession.id on a payment event and data.sessionId on a session event both carry) or you will grant the same sale twice.
Event catalogue
| Event | Fires when |
|---|---|
payment.confirmed | A payment enters confirmed: the confirmed total equals the expected amount |
payment.underpaid | A payment enters underpaid |
payment.overpaid | A payment enters overpaid |
payment.amount_increased | More money arrived on a payment whose status did NOT move: an underpaid top-up that is still short, a further transfer onto an already confirmed or overpaid payment, or a recovery credit. Same payload as the events above. Before it existed, that money arrived with no event at all. |
payment.expired | A payment with nothing received passes expiresAt, or is abandoned because the buyer changed chain on its checkout session before paying |
payment.refunded | A payment is fully refunded (a partial refund sends nothing) |
checkout.session.completed | A session's payment is confirmed or overpaid. Sent once per session. |
checkout.session.expired | A session ended unpaid |
checkout.session.cancelled | The buyer cancelled, or you closed the session with /expire |
checkout.session.failed | A completed session's payment was fully refunded |
settlement.completed | Funds were forwarded to your settlement address |
settlement.failed | Forwarding failed. The funds stay on the payment address; data.error says why. |
settlement.address.created, settlement.address.change_staged, settlement.address.change_cancelled | A settlement address was added, a change was scheduled, or a scheduled change was cancelled |
payment.flow.step, payment.flow.approved, payment.flow.rejected | Payment rules you configured in the console ran on a payment |
facilitator.payment.settled, facilitator.payment.failed | Agent payments, which are not enabled on api.thru.la yet |
New event types may be added. Answer 2xx to types you do not handle.
The delivery
Headers:
| Header | Value |
|---|---|
content-type | application/json |
x-thru-event | The event type |
x-thru-signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw body |
The body is always { "id", "type", "createdAt", "data" }. id is a UUID that identifies the event, and it stays the same on every retry. data depends on the type.
payment.confirmed, payment.underpaid, payment.overpaid: data.payment is the full payment (including metadata and idempotencyKey), and data.blockchainTransaction is the transfer that caused the change.
{
"id": "00000000-0000-4000-8000-0000000000d1",
"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-0000000000a1",
"merchantId": "00000000-0000-4000-8000-000000000001",
"chain": "arc",
"network": "testnet",
"token": "USDC",
"amount": "1",
"currency": "USD",
"expectedAmount": "1",
"receivedAmount": "1",
"feeBps": 0,
"feeAmount": "0",
"paymentAddress": "0x3f5c…a91e",
"status": "confirmed",
"idempotencyKey": "order_1042",
"metadata": {
"orderId": "1042"
},
"productId": null,
"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-0000000000b1",
"paymentId": "00000000-0000-4000-8000-0000000000a1",
"merchantId": "00000000-0000-4000-8000-000000000001",
"chain": "arc",
"network": "testnet",
"txHash": "0x8b2e…41d7",
"logIndex": 0,
"fromAddress": "0x92c4…07fa",
"toAddress": "0x3f5c…a91e",
"tokenAddress": null,
"amount": "1",
"blockNumber": "18204417",
"confirmations": 1,
"status": "confirmed",
"createdAt": "2026-09-19T08:03:12.000Z",
"updatedAt": "2026-09-19T08:03:12.000Z"
}
}
}
payment.refunded: the same shape, with data.blockchainTransaction set to null.
For a payment bound to a checkout session, all four of those bodies also carry data.checkoutSession, the three fields you correlate on:
{
"id": "cs_2f9d0c4b7a1e4d3c8b5f6a7e9d0c1b2a",
"reference": "user_1842",
"metadata": {
"orderId": "topup_8813"
}
}
The key is absent, not null, on a payment that was never part of a session (a direct payment, a payment link, an invoice), so a handler written before it existed sees exactly what it always did. GET /v1/payments/:id returns the same three fields as checkoutSession (there null when unbound). See checkout-sessions for what "bound" means when a buyer changes chain.
payment.expired: a different, flat shape, with no payment object and no metadata. Read the id from data.paymentId. It gains the same checkoutSession key when the payment was bound to a session:
{
"id": "00000000-0000-4000-8000-0000000000d2",
"type": "payment.expired",
"createdAt": "2026-09-19T08:31:40.000Z",
"data": {
"paymentId": "00000000-0000-4000-8000-0000000000a1",
"chain": "arc",
"network": "testnet",
"token": "USDC",
"expectedAmount": "1",
"expiresAt": "2026-09-19T08:30:00.000Z"
}
}
checkout.session.*: the checkout session. See checkout-sessions for a full example.
settlement.completed, settlement.failed: a payout of your money to your settlement address.
{
"id": "00000000-0000-4000-8000-0000000000d4",
"type": "settlement.completed",
"createdAt": "2026-09-19T08:12:20.000Z",
"data": {
"paymentId": "00000000-0000-4000-8000-0000000000a1",
"merchantId": "00000000-0000-4000-8000-000000000001",
"chain": "arc",
"network": "mainnet",
"token": "USDC",
"livemode": true,
"toAddress": "0x9c1b…4d7f",
"amount": "25",
"txHash": "0x6b2d…c40a",
"gasFundingTxHash": null,
"error": null,
"sweepId": "00000000-0000-4000-8000-0000000000f1",
"runId": "00000000-0000-4000-8000-0000000000f2",
"sequence": 1,
"trigger": "confirmation",
"sweptTotal": "25",
"previousTxHash": null
}
}
A payment can pay out more than once: money credited after a payout re-opens it and the next run forwards the rest. So the event also carries the run fields, and reading them wrong is the one way to lose a payout in your books:
| Field | Read it as |
|---|---|
runId | The identity of one payout. Dedupe on this. |
sequence | 1-based, per payment. Order on this. > 1 means this payment has paid out before. |
amount | This run's transfer, never the lifetime total — so summing amount across events is correct. |
sweptTotal | Lifetime forwarded for this payment, including this run. Reconcile your wallet against this. |
previousTxHash | The previous run's hash; null on the first. |
trigger | Why this run started: confirmation, reopen, release or retry. |
sweepId | The payment's payout record. One per payment — not a payout id. |
txHash | A receipt, not an identifier: it is null on every settlement.failed and on a run that found nothing to send. |
If your handler keys on paymentId and overwrites, a second payout erases the first. Key on runId.
The second payout of the same payment:
{
"id": "00000000-0000-4000-8000-0000000000d5",
"type": "settlement.completed",
"createdAt": "2026-09-21T11:03:05.000Z",
"data": {
"paymentId": "00000000-0000-4000-8000-0000000000a1",
"merchantId": "00000000-0000-4000-8000-000000000001",
"chain": "arc",
"network": "mainnet",
"token": "USDC",
"livemode": true,
"toAddress": "0x9c1b…4d7f",
"amount": "5",
"txHash": "0x8e71…22b9",
"gasFundingTxHash": null,
"error": null,
"sweepId": "00000000-0000-4000-8000-0000000000f1",
"runId": "00000000-0000-4000-8000-0000000000f3",
"sequence": 2,
"trigger": "reopen",
"sweptTotal": "30",
"previousTxHash": "0x6b2d…c40a"
}
}
On settlement.failed, sweepId/runId/sequence can be null — the failure happened before the payout record existed. On settlement.completed they are always present.
receipt.issued, receipt.supplemented, receipt.credit_note.issued: an accounting document was issued for this payment. The usual { payment, blockchainTransaction, checkoutSession? } plus one field these three events alone carry — data.receipt, the document itself:
{
"type": "receipt.supplemented",
"data": {
"payment": { "id": "pay_...", "status": "confirmed", "receivedAmount": "49" },
"receipt": {
"id": "6f1c...", "kind": "supplement", "number": "RCP-2026-0007",
"publicId": "9f3a1c6b2d804e7f", "url": "https://thru.la/r/9f3a1c6b2d804e7f",
"paymentId": "pay_...", "invoiceNumber": null,
"chain": "arc", "network": "mainnet", "token": "USDC",
"amount": "12", "receivedAmountAtIssue": "49", "refundedToDate": null,
"paymentStatusAtIssue": "confirmed",
"contentHash": "3b1f...", "issuedAt": "2026-09-21T10:00:00.000Z"
}
}
}
url is on the event for a blunt reason: thru emails the document only where it holds a customer address, which in practice is the invoice path. Everywhere else getting this link to your customer is your job, and this is where it reaches you.
Two figures, and they are not the same one. amount is THIS document's own — for the supplement above, the 12 that just arrived, not the 49 received in total. receivedAmountAtIssue is the running total. Credit the difference from what you have already credited, exactly as you do on payment.amount_increased; do not add the chain's amounts up.
A payment's documents form a CHAIN, and the reason is that a receipt must both follow the amount actually received and stay true once a customer is holding it. Those cannot both hold if one document is edited, so nothing issued is ever rewritten:
| Event | What was issued |
|---|---|
receipt.issued | The primary receipt, for the first money documented on this payment. At most one, ever. |
receipt.supplemented | Money that arrived after a document already covered the earlier money — an underpaid payment topping up, or a late transfer recovered by support. It carries only the delta, and the running total at issue. |
receipt.credit_note.issued | A refund. It points back at the receipt and never alters it. |
To show a customer what they paid, use the newest document's running total rather than adding the amounts up yourself. GET /v1/payments/:id/receipts returns the whole chain with received, refunded and net already worked out.
Verify the signature
Compute HMAC-SHA256 over the raw request body bytes, keyed with the UTF-8 bytes of the endpoint secret string (do not hex-decode the secret). Compare "sha256=" + hex with the header in constant time. Do this before you parse the JSON: a parsed and re-serialised body will not match.
Node (Express). Register the raw-body parser for this route before any global express.json(), or the raw bytes are gone:
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyThruSignature(rawBody, header, secret) {
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'));
const received = Buffer.from(header || '');
return expected.length === received.length && timingSafeEqual(expected, received);
}
const app = express();
app.post('/webhooks/thru', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyThruSignature(req.body, req.get('x-thru-signature'), process.env.THRU_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// ... handle, commit, then:
res.status(200).end();
});
Python:
import hashlib, hmac
def verify_thru_signature(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), (header or "").encode())
Go:
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)
}
TypeScript on Node 20+, Workers, Deno or Bun, with @thru-payment/server:
import { constructThruEvent } from '@thru-payment/server';
const raw = await request.text();
const event = await constructThruEvent(raw, request.headers, env.THRU_WEBHOOK_SECRET);
// throws ThruSignatureError (reason: missing_signature | bad_format | bad_signature | bad_payload)
Test vector (example values only):
| Secret | example_webhook_secret_do_not_use |
| Raw body | {"id":"00000000-0000-4000-8000-000000000000","type":"checkout.session.completed","createdAt":"2026-09-19T00:00:00.000Z","data":{}} |
x-thru-signature | sha256=06d0f6dc8eb6639e75520a140b7ce1fc39f7535915d1c64edf013c23a976a08e |
The signature covers only the body; there is no timestamp. Protection against a replayed delivery comes from deduplicating on the event id, and from making your handler idempotent.
Handling deliveries safely
- Verify the signature. If it fails, answer
401. - If
data.testistrue, answer200and stop. - Record the event
id(for example,INSERT … ON CONFLICT DO NOTHING) and skip ids you have already processed. - Read the object with your key (
GET /v1/payments/:idorGET /v1/checkout/sessions/:id) and act on what it says, checkingnetwork. When you credit an amount, credit the payment'sreceivedAmountas a high-water mark, never the event's. - Answer
2xxonly after your changes are committed. Answer5xxif anything failed, so thru tries again. - Answer within 10 seconds; do slow work (emails, provisioning) after you respond.
Retries
- Deliveries are sent by a background job that runs about every 30 seconds, so expect a short delay after the underlying change.
- A delivery succeeds only on a
2xxanswer within 10 seconds. Any other answer, including4xx, and any timeout or connection error, is retried. - After a failure, the next attempt waits about 1 minute, then about 2, 4, 8 minutes and so on, capped at 1 hour. After 8 attempts in total the event is marked
failed. - When one endpoint fails, the retry goes to every endpoint subscribed to the event, including those that already answered
2xx. Your handler must therefore tolerate duplicates. - If no enabled endpoint's filter matches an event, it is marked
undeliverableand is not retried automatically. Replay it once you have an endpoint.
Testing, logs and replay
| Endpoint | Does |
|---|---|
POST /v1/webhooks/:id/test { "eventType": "…" } | Sends a real, signed delivery to that endpoint. The body is always shaped like a checkout-session event with data.test: true, whatever eventType you name, so it tests your URL and signature check, not your parsing of payment.* bodies. |
GET /v1/webhooks/events?eventType=&status= | Your newest 100 events. status is a comma-separated list of pending, retrying, delivered, failed, undeliverable. |
GET /v1/webhooks/events/:eventId/deliveries | Every attempt for one event: which endpoint, the HTTP status, the duration |
POST /v1/webhooks/events/:eventId/replay | Sends the event again now, and resets its retry count |
The console shows the same log under Developers → Events.