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.
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
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.
{
"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
orderIdand the exact amount, not with the time of arrival.
Related
This page is also available as Markdown: /docs/webhooks.md. Questions: [email protected].