Merchant API

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 signature

Hardening 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 t more than 300 seconds from your clock. Replays are real.
  • Dedupe on the top-level event id and 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.

On this page