Webhooks
Outbound merchant webhooks. Closed event-type union, signed payloads, 5-minute replay window, retry with exponential backoff.
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; every case is covered. The same list, including example payloads, is served at GET /webhook_events.
| Event | Fires when |
|---|---|
payment_intent.succeeded | The payment intent succeeded. Funds are credited. |
payment_intent.failed | The payment intent reached a terminal failure. |
payment_intent.expired | The payment intent expired without payment. |
payout_intent.succeeded | The payout intent settled at the provider. |
payout_intent.failed | The payout intent failed after dispatch. |
payout_intent.cancelled | The payout intent was cancelled or rejected before dispatch. |
Envelope
Every delivery has the same top-level shape:
{
"event": "payment_intent.succeeded",
"payload": {
"payment_intent_id": "dord_01HZXABC123",
"status": "succeeded",
"failure_code": null,
"failure_message": null
}
}Payloads:
payment_intent.succeeded/.failed/.expired—payment_intent_id,status,failure_code,failure_message.payout_intent.*—payout_intent_id,status,failure_code,failure_message.
Add new fields gracefully. Your verifier should not reject unknown keys.
Refund and dispute webhooks are outside the beta Merchant API. They are not emitted on the public webhook contract.
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
Each endpoint can carry two active secrets during rotation, so a deploy never races a secret swap.
Add a second secret
Add a second signing secret in the dashboard. Both are now valid.
Verify against either
Update your verifier to accept a signature matching either secret.
Roll the new secret out
Deploy the new secret through your producer or config.
Remove the old secret
Remove the previous secret in the dashboard once every instance verifies against the new one.
Retry policy
A delivery is successful when your endpoint returns 2xx within a reasonable timeout. Any other outcome triggers retries:
- 6 attempts total including the first.
- Exponential backoff with jitter, starting at 1 second.
- After the last attempt the delivery is parked and surfaced in the dashboard.
Make your handler idempotent. The same (id, event) pair can arrive more than once if your endpoint is slow or flaky.
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.