Webhooks — Overview
Webhooks are the authoritative signal. Browser callbacks
(onSuccess) and dashboard views are convenience; webhooks are
ground truth.
Delivery guarantees
- At least once. A single event may be delivered up to 6 times
if your server doesn’t return 2xx within the timeout. Make your
handler idempotent — dedup on
X-Delivery(the payload has noevent_idfield; the stable delivery UUID is the idempotency key). - One event per HTTP request. No batching.
- Per-endpoint isolation. If you have multiple registered endpoints, each gets its own delivery + retry track. One slow merchant URL can’t starve the others — each host has its own circuit breaker.
- Signed. Every payload carries an
X-Signatureheader (and during the 24-hour window after a rotation, also anX-Signature-Prev). Verify before doing anything with the body. See Signature verification.
Subscribable event types
| Event | Fires when… |
|---|---|
payment.settled | The on-chain transfer cleared the chain’s confirmation count. Use this to mark orders paid. |
payment.failed | A fiat payment was explicitly rejected by the provider (currently: Stripe webhook signalling failure). Not fired for crypto timeouts — those surface as checkout.expired instead, and short crypto payments surface as payment.underpaid. |
payment.underpaid | Funds arrived but short of the order total (typical: stablecoin transfer fee taken from the amount). |
payment.overpaid | Funds arrived in excess of the order total. The surplus is recorded but not auto-refunded. |
order.created | A new order was opened — either by your B2B API call or by a checkout-session conversion. |
order.canceled | An order moved to cancelled. The payload’s data.reason distinguishes manual cancel from payment_timeout (stale unpaid order swept by the worker). |
order.resolved | A PARTIAL_PAID order was resolved to PAID — the merchant accepted the shortfall. |
order.reopened | A previously auto-canceled order (canceled_reason=payment_timeout) was reopened by the merchant. |
checkout.created | A buyer opened the checkout for an order. |
checkout.completed | The buyer-side flow finished (does not imply on-chain settlement — use payment.settled for that). |
checkout.expired | The buyer abandoned and the session TTL ran out. |
payment.refund.requested | A refund record was created — either from a merchant-initiated API call or from a customer-submitted refund-request form. |
payment.refund.approved | A pending refund passed your approval workflow. |
payment.refund.rejected | A pending refund was denied. |
payment.refund.executed | The refund’s on-chain transfer cleared and the record moved to terminal executed. |
refund_request.created | A refund-request token was minted. data.source is b2b / dashboard / renewal. Optional to subscribe — useful for audit pipelines that track which token is currently active per order. |
refund_request.renewal_requested | A buyer clicked “Request new link” after their token expired. Strongly recommended to subscribe — this is the merchant’s cue that the renewal widget has a new item to act on. |
refund_request.renewed | A renewal was approved and a new token replaced the old one. data.old_token / data.new_token form the audit chain. |
refund_request.canceled | A merchant flipped a token to CANCELED from the dashboard (e.g. declined a renewal request, killed a live link). Idempotent — only the first transition emits. data.reason is the optional merchant note. |
The dashboard fetches this list from GET /v1/webhooks/event-types so the
endpoint create / edit form always matches what the platform actually
emits. Subscribing to an event we don’t ship will be rejected at create
time with a clear error.
Test events aren’t subscribable. The dashboard’s per-endpoint
Send Test button POSTs a webhook.test.ping envelope synchronously
to that one endpoint (bypassing the retry pipeline), and the legacy
merchant-level “Send test event” path fans out a webhook.test envelope
to every active endpoint regardless of its filter. Neither appears in
the catalog above — you receive them by virtue of having a registered
endpoint, not by subscribing.
Subscribe to only the events you handle. Each endpoint has its own
event filter; the wildcard "*" means “every event, including ones
added in the future”. Subscribing to fewer events keeps your handler
cleaner and reduces the surface area we have to retry on errors.
Payload + headers
The HTTP body is the event-specific data object directly. No outer
Stripe-style envelope — fields like the event type, delivery ID, and
emission timestamp live in headers instead. For payment.settled
the body looks like:
{
"receipt_id": "rcp_…",
"order_id": "ord_…",
"payment_intent_id": "pin_…",
"checkout_session_id": "cst_…",
"merchant_id": "mer_…",
"customer_id": "cus_…",
"total": "49.00",
"currency": "USD",
"payment_method": "crypto",
"token": "USDC",
"network": "polygon",
"tx_hash": "0x…",
"deposit_address": "0x…",
"treasury_address": "0x…",
"amount_received": "49.00",
"confirmations": 5,
"metadata": { /* per-event */ }
}Other events carry their own field set — see the publisher structs
in payment-service/internal/domain/events.go for the canonical
shape until per-event docs land. Field names are stable (lower
snake_case); on-chain tx hash is always tx_hash (not
transaction_hash).
Headers on the inbound request
Content-Type: application/json
X-Event: payment.settled
X-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp: 1729536000
X-Signature: sha256=9a8b7c…
X-Signature-Prev: sha256=fa31b2… (only during a rotation grace window)| Header | What it is |
|---|---|
X-Event | The event type (e.g. payment.settled). Route on this at the proxy layer if you want to skip JSON parsing. |
X-Delivery | UUID identifying the delivery row. Stable across all retries of the same (event, endpoint) pair — use it as your idempotency key. |
Idempotency-Key | Mirrors X-Delivery (same value). Set on every delivery — picks up convention from Stripe / GitHub. |
X-Timestamp | Unix-seconds when the attempt was sent. Signed into the payload so a captured (body, X-Signature) pair can’t be replayed indefinitely — reject deliveries whose timestamp is outside your tolerance window. |
X-Signature | sha256=<hex> of HMAC-SHA256(secret, X-Timestamp + "." + raw_body). See Signature verification. |
X-Signature-Prev | Same algorithm with the previous secret. Present only in the 24-hour window after you rotate — lets verifiers running either key keep accepting deliveries during the cutover. After the window closes the header stops being sent. |
Retry schedule
If your endpoint doesn’t return 2xx within the timeout, we retry
on this schedule (timestamps relative to first attempt):
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | 0s | 0s |
| 2 | +1 min | 1m |
| 3 | +5 min | 6m |
| 4 | +15 min | 21m |
| 5 | +1 hour | 1h 21m |
| 6 | +6 hours | 7h 21m |
After attempt 6 fails, the delivery is moved to dead letter and
the merchant account email is notified. Dead-lettered events can be
replayed from the dashboard’s Developers → Webhooks → Delivery
history panel, or directly via
POST /v1/webhooks/deliveries/:id/replay. Each replay creates a new
delivery row with its own X-Delivery — the audit chain ties back
to the original via parent_delivery_id so retries-of-replays don’t
shadow the source event.
Register an endpoint
From the merchant dashboard :
- Developers → Webhooks → + Add endpoint
- Paste your URL —
https://…only (plain HTTP is rejected; the create form also blockslocalhost, private IP ranges, and URLs carrying userinfo) - Choose events to subscribe (or
*for all) - Pick environment — test or live (each gets its own secret; they never cross over)
- Save → the dashboard displays the signing secret (
whsec_…) once. Store it server-side; you’ll need it for the next two features.
You can register up to 10 endpoints per environment per merchant (e.g., one for production fulfillment, one for staging mirroring, one for a Slack notifier). Each maintains its own retry state, secret, and per-host circuit breaker.
Lifecycle actions on each endpoint
The ⋮ menu on each endpoint card surfaces:
- Edit — change the URL, description, or subscription list.
The new URL is re-validated with the same
https:///SSRF rules as create. - Send Test — synchronously POSTs a
webhook.test.pingenvelope signed with your current secret. The dashboard shows the HTTP status, latency, and a 512-byte snippet of your response. Bypasses the RMQ pipeline so the answer is immediate. - Rotate Secret — generates a new secret. The previous one stays
valid for 24 hours (deliveries carry both
X-SignatureandX-Signature-Prevduring the window so verifiers running either key keep accepting events while you redeploy). - Reveal Secret — re-displays the existing secret. Gated by fresh 2FA verification and recorded in the audit log; use it only when you lost your copy and Rotate isn’t acceptable.
- Enable / Disable — flip
is_activewithout losing delivery history. Disabled endpoints stay in the dashboard but receive no new deliveries. - Delete — permanent. Use Disable if you might re-enable later.
Tips for handlers
- Return 2xx fast. Acknowledge with
200 OKbefore doing heavy work — kick fulfilment into a background queue. The per-attempt timeout is 10 seconds; holding the response longer than that triggers a retry. The timeout is platform-side and not merchant-configurable — contact support if your handler genuinely needs more time. - Dedup on
X-Delivery(orIdempotency-Key— same value). Even if you return 2xx, an upstream proxy could drop the connection and trigger a retry; the delivery ID is stable across every retry of the same delivery row, so it’s the right key. - Tolerate unknown event types. New events may appear; return 200 and no-op rather than 4xx, or you’ll fill the retry queue.
- Log
X-Deliverynext to your business logic. When something goes wrong, that’s the join key between our side and yours.
What’s next
- Signature verification — the exact algorithm + replay-protection patterns.
- Concepts → Sessions — what state a session is in when each event fires.