Skip to Content
WebhooksOverview

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 no event_id field; 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-Signature header (and during the 24-hour window after a rotation, also an X-Signature-Prev). Verify before doing anything with the body. See Signature verification.

Subscribable event types

EventFires when…
payment.settledThe on-chain transfer cleared the chain’s confirmation count. Use this to mark orders paid.
payment.failedA 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.underpaidFunds arrived but short of the order total (typical: stablecoin transfer fee taken from the amount).
payment.overpaidFunds arrived in excess of the order total. The surplus is recorded but not auto-refunded.
order.createdA new order was opened — either by your B2B API call or by a checkout-session conversion.
order.canceledAn order moved to cancelled. The payload’s data.reason distinguishes manual cancel from payment_timeout (stale unpaid order swept by the worker).
order.resolvedA PARTIAL_PAID order was resolved to PAID — the merchant accepted the shortfall.
order.reopenedA previously auto-canceled order (canceled_reason=payment_timeout) was reopened by the merchant.
checkout.createdA buyer opened the checkout for an order.
checkout.completedThe buyer-side flow finished (does not imply on-chain settlement — use payment.settled for that).
checkout.expiredThe buyer abandoned and the session TTL ran out.
payment.refund.requestedA refund record was created — either from a merchant-initiated API call or from a customer-submitted refund-request form.
payment.refund.approvedA pending refund passed your approval workflow.
payment.refund.rejectedA pending refund was denied.
payment.refund.executedThe refund’s on-chain transfer cleared and the record moved to terminal executed.
refund_request.createdA 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_requestedA 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.renewedA renewal was approved and a new token replaced the old one. data.old_token / data.new_token form the audit chain.
refund_request.canceledA 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)
HeaderWhat it is
X-EventThe event type (e.g. payment.settled). Route on this at the proxy layer if you want to skip JSON parsing.
X-DeliveryUUID identifying the delivery row. Stable across all retries of the same (event, endpoint) pair — use it as your idempotency key.
Idempotency-KeyMirrors X-Delivery (same value). Set on every delivery — picks up convention from Stripe / GitHub.
X-TimestampUnix-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-Signaturesha256=<hex> of HMAC-SHA256(secret, X-Timestamp + "." + raw_body). See Signature verification.
X-Signature-PrevSame 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):

AttemptDelayCumulative
10s0s
2+1 min1m
3+5 min6m
4+15 min21m
5+1 hour1h 21m
6+6 hours7h 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 :

  1. Developers → Webhooks+ Add endpoint
  2. Paste your URL — https://… only (plain HTTP is rejected; the create form also blocks localhost, private IP ranges, and URLs carrying userinfo)
  3. Choose events to subscribe (or * for all)
  4. Pick environment — test or live (each gets its own secret; they never cross over)
  5. 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.ping envelope 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-Signature and X-Signature-Prev during 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_active 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

  1. Return 2xx fast. Acknowledge with 200 OK before 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.
  2. Dedup on X-Delivery (or Idempotency-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.
  3. Tolerate unknown event types. New events may appear; return 200 and no-op rather than 4xx, or you’ll fill the retry queue.
  4. Log X-Delivery next to your business logic. When something goes wrong, that’s the join key between our side and yours.

What’s next