# Webhooks

send21 calls your server when a draft changes. Register an endpoint with `POST /api/v1/webhooks` (scope `webhooks:manage`) and the events you want. The response contains the signing secret once. Send a test event any time with `POST /api/v1/webhooks/{id}/test`.

## Delivery

Each delivery is a `POST` with `Content-Type: application/json` and the body `{ "event": "<type>", "data": { ... } }`. Any 2xx response counts as delivered. Anything else, or no answer within 15 seconds, is retried after 1, 2, 4, 8 minutes and so on, up to 8 attempts. Deliveries can arrive out of order.

| Header | Meaning |
| --- | --- |
| `X-Send21-Event` | The event type |
| `X-Send21-Delivery` | Unique delivery id; retries reuse it, so use it to ignore duplicates |
| `X-Send21-Signature` | `sha256=` plus the lowercase hex HMAC-SHA256 of the raw body, keyed with your secret |

## Verify the signature

Compute the HMAC over the raw request bytes, before parsing JSON, and compare in constant time. Answer 401 when the header is missing or wrong.

```js
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'));
const received = Buffer.from(String(req.headers['x-send21-signature'] ?? ''));
const ok = received.length === expected.length && crypto.timingSafeEqual(received, expected);
if (!ok) return res.status(401).end();
```

## The same check in Python

```python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
valid = hmac.compare_digest(expected, request.headers.get("X-Send21-Signature", ""))
```

## Events

| Event | When |
| --- | --- |
| `draft.created` | A draft was created: by you, an API key, a recurring template, or a payer picking a currency on a pay link |
| `draft.seen` | A payment was seen but is not confirmed yet. Also sent with `acceptedByOwner: true` when you accept a payment with a different amount |
| `draft.confirmed` | The payment reached its required confirmations. For Lightning: the provider confirmed it with a valid preimage |
| `draft.amount_mismatch` | A transfer within 10% of the billed amount arrived. It does not pay the draft until you accept it |
| `draft.expired` | The draft expired without payment |
| `draft.cancelled` | The draft was cancelled |
| `test` | Sent by the test endpoint |

## Payload

`draft.seen`, `draft.confirmed`, `draft.amount_mismatch` and `draft.expired` share this shape. Amounts ending in `Sats` are base units of the sent asset: 8 decimals for BTC, 6 for USDC, USDT and EURC, 9 for SOL and ETH (ETH in gwei). `txId` is the transaction id, or the payment hash for Lightning. `receivedAmountSats` and `receivedAmount` appear when the amount differs from the billed amount, including on the `draft.confirmed` of an accepted short or over payment. `paymentRequestId` and `orderId` are set for pay-link payments.

```json
{
  "event": "draft.confirmed",
  "data": {
    "draftId": "3f6c2a9e-5d1b-4c8e-9a7f-2b4d6e8f0a1c",
    "status": "Confirmed",
    "txId": "5KqZ9bQ...",
    "confirmations": 1,
    "sentCurrency": "USDC",
    "network": "Solana",
    "amountSats": 50000000,
    "sentAmount": "50.000000",
    "fiatCurrency": "EUR",
    "fiatAmount": 46.00,
    "conversionRate": 0.92,
    "rateSource": "coinbase",
    "platformFee": { "accrued": true, "waived": false, "percent": 0.39, "feeUsd": 0.20,
                     "feeBaseUnits": 195000, "feeAmount": "0.195000", "currency": "USDC" },
    "paymentRequestId": "8d1e4b7a-0c2f-4e6d-b3a9-7f5c1e2d4a6b",
    "orderId": "ORDER-1042",
    "occurredAt": "2026-10-06T08:22:22Z"
  }
}
```

## Endpoint rules

- Endpoints must use HTTPS and resolve to a public address; private and internal addresses are refused.
- Store the secret in a secret manager. It is shown only once.
- Reconcile with `orderId` and the exact amount, not with the time of arrival.

---
Source: https://send21.io/docs/webhooks
