thru

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

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"}'
FieldRules
urlRequired. Where thru POSTs.
descriptionOptional, up to 120 characters.
eventTypesOptional 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.* or payment.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

EndpointDoes
GET /v1/webhooksLists endpoints, with secrets
PATCH /v1/webhooks/:idChanges url, enabled, description or eventTypes
DELETE /v1/webhooks/:idDeletes the endpoint and its delivery log
POST /v1/webhooks/:id/rotate-secretIssues 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/testSends a signed test delivery (see Testing)

Which events to listen to

If you use…Subscribe toGrant 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 productcheckout.*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 invoicespayment.*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

EventFires when
payment.confirmedA payment enters confirmed: the confirmed total equals the expected amount
payment.underpaidA payment enters underpaid
payment.overpaidA payment enters overpaid
payment.amount_increasedMore 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.expiredA payment with nothing received passes expiresAt, or is abandoned because the buyer changed chain on its checkout session before paying
payment.refundedA payment is fully refunded (a partial refund sends nothing)
checkout.session.completedA session's payment is confirmed or overpaid. Sent once per session.
checkout.session.expiredA session ended unpaid
checkout.session.cancelledThe buyer cancelled, or you closed the session with /expire
checkout.session.failedA completed session's payment was fully refunded
settlement.completedFunds were forwarded to your settlement address
settlement.failedForwarding failed. The funds stay on the payment address; data.error says why.
settlement.address.created, settlement.address.change_staged, settlement.address.change_cancelledA settlement address was added, a change was scheduled, or a scheduled change was cancelled
payment.flow.step, payment.flow.approved, payment.flow.rejectedPayment rules you configured in the console ran on a payment
facilitator.payment.settled, facilitator.payment.failedAgent 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:

HeaderValue
content-typeapplication/json
x-thru-eventThe event type
x-thru-signaturesha256= 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.

json
{
  "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:

json
{
  "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:

json
{
  "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.

json
{
  "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:

FieldRead it as
runIdThe identity of one payout. Dedupe on this.
sequence1-based, per payment. Order on this. > 1 means this payment has paid out before.
amountThis run's transfer, never the lifetime total — so summing amount across events is correct.
sweptTotalLifetime forwarded for this payment, including this run. Reconcile your wallet against this.
previousTxHashThe previous run's hash; null on the first.
triggerWhy this run started: confirmation, reopen, release or retry.
sweepIdThe payment's payout record. One per payment — not a payout id.
txHashA 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:

json
{
  "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:

json
{
  "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:

EventWhat was issued
receipt.issuedThe primary receipt, for the first money documented on this payment. At most one, ever.
receipt.supplementedMoney 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.issuedA 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:

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

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:

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:

ts
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):

Secretexample_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-signaturesha256=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

  1. Verify the signature. If it fails, answer 401.
  2. If data.test is true, answer 200 and stop.
  3. Record the event id (for example, INSERT … ON CONFLICT DO NOTHING) and skip ids you have already processed.
  4. Read the object with your key (GET /v1/payments/:id or GET /v1/checkout/sessions/:id) and act on what it says, checking network. When you credit an amount, credit the payment's receivedAmount as a high-water mark, never the event's.
  5. Answer 2xx only after your changes are committed. Answer 5xx if anything failed, so thru tries again.
  6. 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 2xx answer within 10 seconds. Any other answer, including 4xx, 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 undeliverable and is not retried automatically. Replay it once you have an endpoint.

Testing, logs and replay

EndpointDoes
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/deliveriesEvery attempt for one event: which endpoint, the HTTP status, the duration
POST /v1/webhooks/events/:eventId/replaySends the event again now, and resets its retry count

The console shows the same log under Developers → Events.