Webhooks
Outbound Merchant webhooks with a versioned event catalogue, frozen signed bytes, retries, and manual resend.
Webhooks deliver state changes to your endpoint over HTTP POST. They are the production-grade alternative to polling payment and payout intent status. Subscribe in the dashboard; one URL per environment.
Event types
The event field is a closed union. Switch over it and cover every case documented below.
| Family | Events |
|---|---|
| Payment | payment_intent.requires_payment_method, .requires_action, .processing, .succeeded, .failed, .expired, .cancelled |
| Payout | payout_intent.requires_approval, .processing, .succeeded, .failed, .cancelled, .expired, .returned |
Envelope
Every delivery has the same top-level shape:
{
"version": 1,
"id": "wevt_5276f0877bd1874da00b1ca4",
"event": "payment_intent.succeeded",
"environment": "test",
"merchantTeamId": "team_merchant",
"organizationId": "org_merchant",
"createdAt": "2026-05-09T12:01:00.000Z",
"transitionSequence": 3,
"data": {
"id": "dord_01HZXABC123",
"status": "succeeded"
}
}data is the resource projection frozen when the effective transition committed. transitionSequence is the immutable Payment or Payout transition sequence and is null for events without one. Add new fields gracefully; do not reject unknown keys.
Payout event data uses status, not the internal executionStatus. A successful Payout has
status: "succeeded". The payout_intent.returned event reports a return of funds and has
status: "failed", matching GET. Existing Payment event data is unchanged.
Signing
Every delivery carries a signed payload header:
webhook-signature: t=1715250000,v1=4d3a...The canonical signed string is:
${t}.${rawBody}Where t is the unix timestamp from the header and rawBody is the raw request body bytes. Compute HMAC-SHA256 with your endpoint's signing secret and compare the lowercase hex against v1 using a constant-time comparator.
Replay window
Reject deliveries whose t is more than 5 minutes from your server's clock. A captured
payload replayed later must not be accepted.
Secret rotation
Rotating an endpoint secret is an atomic swap. The previous secret stops signing new deliveries as soon as the new secret is issued. Deploy the new secret to your receiver before completing the dashboard rotation.
Prepare the receiver
Deploy support for the new secret reference before rotating the endpoint.
Rotate in the dashboard
Issue the replacement secret. New deliveries use it immediately.
Verify the replacement
Send a Test delivery and verify its signature with the replacement secret.
Retire the old receiver configuration
Remove the previous secret from your receiver after the replacement is verified.
Retry policy
A delivery is successful when your endpoint returns 2xx within 10 seconds. Any other response, timeout, or unknown transport outcome advances to the next attempt:
- The endpoint default is 6 retries after the first attempt, for 7 attempts total. The endpoint setting can change this count.
- Delays are 15 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, then 6 hours for further retries.
- Payload bytes, URL, and secret version remain identical for every attempt. Each physical attempt receives a fresh signature timestamp and signature.
- After exhaustion the delivery is
failedand requires manual resend. Resend creates a linked new delivery; it never mutates the failed attempt history. - Disabling an endpoint terminates a pending delivery instead of retrying it.
Delivery is at-least-once and is not ordered. The same event id can arrive more than once if your
endpoint accepted a request before Init observed an unknown transport result. Deduplicate by id
and use transitionSequence to ignore stale out-of-order Payment or Payout events.
Verification snippet
import { createHmac, timingSafeEqual } from "node:crypto"
type VerifyWebhookSignatureArgs = {
header: string | null | undefined
rawBody: string
secret: string
toleranceSeconds?: number
}
export function verifyWebhookSignature({
header,
rawBody,
secret,
toleranceSeconds = 300,
}: VerifyWebhookSignatureArgs): boolean {
if (!header) return false
const parts: Record<string, string> = {}
for (const segment of header.split(",")) {
const eq = segment.indexOf("=")
if (eq < 0) continue
const key = segment.slice(0, eq).trim()
const value = segment.slice(eq + 1).trim()
if (key) parts[key] = value
}
const t = Number(parts.t)
const v1 = parts.v1
if (!Number.isFinite(t) || !v1) return false
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - t) > toleranceSeconds) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
let a: Buffer
let b: Buffer
try {
a = Buffer.from(expected, "hex")
b = Buffer.from(v1, "hex")
} catch {
return false
}
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}Verify against the raw request body bytes, not a parsed-and-re-stringified object. Re-serializing reorders keys and changes whitespace, so the HMAC will never match.
See Handle a webhook for an end-to-end Express example.