send21-io Webhooks API
**send21 calls your server when a draft changes.** Register an endpoint with `POST /api/v1/webhooks` (scope `webhooks:manage`) and pick the events you want. The response contains the signing secret once; store it. Send a signed test event any time with `POST /api/v1/webhooks/{id}/test`. ### Delivery A `POST` to your URL with `Content-Type: application/json` and this body: ```json { "event": "draft.confirmed", "data": { ... } } ``` | Header | Meaning | |---|---| | `X-Send21-Event` | The event type, same as `event` in the body. | | `X-Send21-Delivery` | Unique delivery id. Retries reuse it, so use it to ignore duplicates. | | `X-Send21-Signature-V2` | `t=,v1=..">`, keyed with your secret. Verify this one: it also signs the time and the delivery id, so a copied delivery cannot be replayed later. Refuse deliveries older than 5 minutes (300 seconds). | | `X-Send21-Timestamp` | When the delivery was sent, in Unix seconds (the same value as `t=`). | | `X-Send21-Signature` | Original signature, kept for existing integrations: `sha256=` followed by the lowercase hex HMAC-SHA256 of the raw body only. It has no timestamp, so a copied delivery passes it again. | 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. ### Verifying the signature Check `X-Send21-Signature-V2`: compute the HMAC over `..`, using the raw request bytes before parsing the JSON, compare in constant time, and refuse deliveries more than 300 seconds old. Answer 401 when the header is missing or wrong. The same check as on send21.io/docs/webhooks: ```js // X-Send21-Signature-V2: t=,v1=.."> const sig = String(req.headers['x-send21-signature-v2'] ?? ''); const t = /(?:^|,)t=(\d+)/.exec(sig)?.[1]; const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(sig)?.[1]; const deliveryId = String(req.headers['x-send21-delivery'] ?? ''); const fresh = t !== undefined && Math.abs(Date.now() / 1000 - Number(t)) `. `receivedAmountSats` and `receivedAmount` are set only when the amount differs from the billed amount, including on the `draft.confirmed` of an accepted short or over payment, so you can record what arrived. `paymentRequestId` and `orderId` are set for pay-link payments. ```json { "event": "draft.amount_mismatch", "data": { "draftId": "3f6c2a9e-5d1b-4c8e-9a7f-2b4d6e8f0a1c", "status": "AwaitingPayment", "txId": "5KqZ9bQ...", "confirmations": 1, "receivedAmountSats": 49250000, "receivedAmount": "49.250000", "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" } } ``` ### Payload of draft.created and draft.cancelled `draft.created` has `draftId`, `amountSats`, `network` and `occurredAt`; pay-link drafts add `paymentRequestId`, `orderId` and `currency`. `draft.cancelled` has `draftId`, `status`, `network`, `amountSats`, `paymentRequestId` and `occurredAt`.