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:
{
"version": 1,
"id": "wevt_5276f0877bd1874da00b1ca4",
"event": "payment_intent.succeeded",
"environment": "test",
"merchantTeamId": "team_merchant",
"createdAt": "2026-05-09T12:01:00.000Z",
"transitionSequence": 3,
"data": {
"id": "dord_01HZXABC123",
"status": "succeeded"
}
}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
id: string
data: Record<string, unknown>
}
const subjectId = event.data.id
if (typeof subjectId !== "string") {
res.status(400).send("malformed body")
return
}
// Dedupe by immutable public event id. Manual resend preserves the same event id.
const dedupeKey = event.id
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) {
const subjectId = event.data.id
if (typeof subjectId !== "string") return
switch (event.event) {
case "payment_intent.succeeded":
await markOrderPaid(subjectId)
return
case "payment_intent.failed":
case "payment_intent.expired":
await markOrderFailed(subjectId, event.data)
return
case "payment_intent.refunded":
await markOrderRefunded(subjectId, event.data)
return
case "payout_intent.succeeded":
await markPayoutSettled(subjectId)
return
case "payout_intent.failed":
case "payout_intent.cancelled":
await markPayoutFailed(subjectId, event.data)
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, respond with a non-2xx. The endpoint default is six retries after the first attempt. After exhaustion, use manual resend from 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 the top-level event
idand retain it permanently enough for your business audit. - 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.