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.

HeaderMeaning
X-Send21-EventThe event type
X-Send21-DeliveryUnique delivery id; retries reuse it, so use it to ignore duplicates
X-Send21-Signaturesha256= 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

EventWhen
draft.createdA draft was created: by you, an API key, a recurring template, or a payer picking a currency on a pay link
draft.seenA payment was seen but is not confirmed yet. Also sent with acceptedByOwner: true when you accept a payment with a different amount
draft.confirmedThe payment reached its required confirmations. For Lightning: the provider confirmed it with a valid preimage
draft.amount_mismatchA transfer within 10% of the billed amount arrived. It does not pay the draft until you accept it
draft.expiredThe draft expired without payment
draft.cancelledThe draft was cancelled
testSent 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 orderId and the exact amount, not with the time of arrival.

Related

This page is also available as Markdown: /docs/webhooks.md. Questions: [email protected].