---
title: "Webhooks"
description: "Register endpoints and filters, which event family to listen to (and how not to count a sale twice), payload shapes, signature verification code, dedupe, retries, test sends and replay"
source: https://docs.thru.la/webhooks.md
html: https://docs.thru.la/webhooks
index: https://docs.thru.la/llms.txt
---
# 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"}'
```

| 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.*` 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

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

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

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

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

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

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

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

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

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

---

Related: [payments](https://docs.thru.la/payments.md) · [checkout-sessions](https://docs.thru.la/checkout-sessions.md) · [credit-topups](https://docs.thru.la/credit-topups.md) · [refunds-and-settlement](https://docs.thru.la/refunds-and-settlement.md)
