Handle a webhook
Verify the signature, dedupe by intent and event, switch on type, ack 2xx fast.
A production-grade webhook handler does four things: verify the signature, dedupe per delivery, route by event, and respond with 2xx quickly. Slow handlers cost you retries.
Endpoint contract
The platform POSTs JSON to your configured URL with these headers:
webhook-signature: t=<unix>,v1=<hex-hmac-sha256>.Content-Type: application/json.
You return 2xx to acknowledge. Anything else triggers retries; see Webhooks.
The body shape is:
{
"event": "payment_intent.succeeded",
"payload": {
"payment_intent_id": "dord_01HZXABC123",
"status": "succeeded",
"failure_code": null,
"failure_message": null
}
}Express example
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// Capture raw body — required for signature verification.
app.use("/webhooks", express.raw({ type: "application/json" }));
const SECRETS = (process.env.WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
const TOLERANCE_SECONDS = 300;
function verifyWebhookSignature({
header,
rawBody,
secret,
toleranceSeconds = TOLERANCE_SECONDS,
}: {
header: string | null | undefined;
rawBody: string;
secret: string;
toleranceSeconds?: number;
}): 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);
}
function verifyAny(rawBody: Buffer, header: string | undefined): boolean {
const body = rawBody.toString("utf8");
for (const secret of SECRETS) {
if (verifyWebhookSignature({ header, rawBody: body, secret })) return true;
}
return false;
}
app.post("/webhooks", async (req, res) => {
const sig = req.header("webhook-signature");
if (!verifyAny(req.body, sig)) {
res.status(400).send("invalid signature");
return;
}
const event = JSON.parse(req.body.toString("utf8")) as {
event: string;
payload: Record<string, unknown>;
};
const subjectId =
typeof event.payload.payment_intent_id === "string"
? event.payload.payment_intent_id
: event.payload.payout_intent_id;
if (typeof subjectId !== "string") {
res.status(400).send("malformed body");
return;
}
// Dedupe — the same (intent id, event) pair can arrive more than once.
const dedupeKey = `${subjectId}:${event.event}`;
if (await alreadyProcessed(dedupeKey)) {
res.status(200).send("ok");
return;
}
// Acknowledge fast. Move slow work off the request.
await enqueueForProcessing(event);
await markProcessed(dedupeKey);
res.status(200).send("ok");
});Routing by type
async function handle(event: WebhookEvent) {
switch (event.event) {
case "payment_intent.succeeded":
await markOrderPaid(event.payload.payment_intent_id);
return;
case "payment_intent.failed":
case "payment_intent.expired":
await markOrderFailed(event.payload.payment_intent_id, event.payload);
return;
case "payout_intent.succeeded":
await markPayoutSettled(event.payload.payout_intent_id);
return;
case "payout_intent.failed":
case "payout_intent.cancelled":
await markPayoutFailed(event.payload.payout_intent_id, event.payload);
return;
default:
// Unknown type — log and ignore. Do not 4xx; that triggers retries.
logger.warn("unknown webhook event", { event: event.event });
}
}Error response: 400 (your endpoint)
If your handler rejects the delivery (bad signature, malformed body), respond with a non-2xx. The platform retries up to six times with exponential backoff. After the last attempt the delivery is parked in the dashboard.
HTTP/1.1 400 Bad Request
Content-Type: text/plain
invalid signatureHardening checklist
Verify before you parse. Treat the body as raw bytes until the signature checks out, and compare
with a constant-time function — === on hex leaks timing.
- Verify before parsing. Treat the body as bytes until the signature checks out.
- Use a constant-time comparator.
===on hex leaks timing. - Reject deliveries with a
tmore than 300 seconds from your clock. Replays are real. - Dedupe on
(intent id, event)and store the seen pairs for at least the platform's retry window. - Configure separate endpoints per environment. Test deliveries should never reach a live handler.
- Support two active secrets during rotation so a deploy never races a secret swap.