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 and retry track. One slow endpoint doesn’t delay the others.
- 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 rejected by the payment provider. 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 (an unpaid order timed out). |
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. |
Planned (Coming soon)
Coming soon. These events belong to recurring invoices and subscriptions, which are not yet available. They are not in the subscribable table above and cannot be subscribed to today. See Recurring invoices.
| Planned event | Fires when… |
|---|---|
subscription.created | A subscription is created. |
invoice.created | An invoice for a billing cycle is created. |
invoice.paid | An invoice is paid. |
subscription.past_due | An invoice is unpaid past its due date. |
subscription.canceled | A subscription is canceled. |
The dashboard’s endpoint form lists the same events. Subscribing to an event that doesn’t exist is rejected when you save the endpoint.
Test events aren’t subscribable. The dashboard’s per-endpoint
Send Test button sends a webhook.test.ping event to that one
endpoint immediately, without retries. It isn’t in the catalog above:
you receive it because you have a registered endpoint, not because you
subscribed.
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
simpler and means fewer retries when your endpoint has 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 fields. Field names are stable (lower
snake_case); the on-chain transaction hash is always tx_hash.
tx_hash is the transaction identifier in the network’s own format (0x… on EVM chains; the native hash or signature on TRON, Solana and TON). deposit_address can be absent for TRON, Solana and TON, because buyers pay your treasury wallet directly there, and confirmations follows Chains & assets.
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. |
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 marked Failed and
your account email is notified. You can replay failed events from the
dashboard’s Developers → Webhooks → Delivery
history panel. Each replay is a new delivery with its own X-Delivery.
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 has its own retry state and secret.
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. Test pings aren’t retried, so you get an immediate answer. - 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 — switch the endpoint on or off without 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 — move fulfillment to a background job. 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 those deliveries will keep retrying.
- 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.