A webhook endpoint is a public URL that changes things in your system. It grants access, ships orders, marks invoices paid. Anyone who discovers the path can send it a plausible-looking event, and if you are not verifying signatures they get every side effect your handler performs.

Verification itself is about fifteen lines. The reason it is worth an article is that the two most common mistakes both produce a handler that appears to work — it verifies real events correctly for weeks — and is not actually secure, or fails on a payload you have not seen yet.

The three rules

  1. Hash the raw bytes. The signature was computed over exactly what was sent. Read the body as text or a buffer before anything parses it.
  2. Compare in constant time. Use crypto.timingSafeEqual, and check lengths first because it throws on a length mismatch.
  3. Bound the replay window. Reject anything older than a few minutes, using the same unit the header uses.

The signature header

Paydude signs each delivery with an HMAC-SHA256 over the timestamp and the raw body, and sends both in one header:

payments-signature: t=1614049713663,v1=8981f5902896f479fa9079…

t is the timestamp and v1 is the hex digest. The signed payload is the timestamp, a literal dot, and the raw body: t + "." + rawBody. Signing the timestamp alongside the body is what makes the replay check meaningful — otherwise an attacker could keep the signature and change the timestamp.

A working Next.js route handler

import crypto from "node:crypto";

export async function POST(request) {
  // Raw body FIRST. Calling .json() consumes the stream, and the bytes are
  // gone — along with any chance of reproducing the digest.
  const raw = await request.text();
  const header = request.headers.get("payments-signature") ?? "";

  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const timestamp = Number(parts.t);
  const received = parts.v1 ?? "";

  // Milliseconds, with five minutes of tolerance in both directions —
  // deliveries can arrive slightly ahead of your clock as well as behind it.
  if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) {
    return new Response("stale", { status: 400 });
  }

  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SIGNING_SECRET)
    .update(`${parts.t}.${raw}`)
    .digest("hex");

  // timingSafeEqual throws if the lengths differ, so check that first.
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response("bad signature", { status: 400 });
  }

  const event = JSON.parse(raw);   // parse only once the signature holds

  // Acknowledge now, work later. Anything slow here counts as a failed
  // delivery and will be retried.
  queueForProcessing(event);
  return new Response("ok", { status: 200 });
}
app/api/webhooks/route.js

The same thing in Express

Express is where the raw-body rule is easiest to break, because a global JSON parser will have consumed the body long before your route sees it.

// Mount express.raw on the webhook path specifically. A global
// app.use(express.json()) defeats this — req.body arrives already parsed
// and the original bytes are unrecoverable.
app.post("/webhooks",
  express.raw({ type: "application/json" }),   // req.body is a Buffer
  (req, res) => {
    const raw = req.body.toString("utf8");
    // …identical verification to the handler above…
  });

After the signature verifies

Verification is the security half. The delivery semantics are the correctness half, and they shape your handler as much as the signature does.

  • Return 200 before doing the work. Anything other than a prompt 2xx is recorded as a failed delivery. Write the event somewhere durable, respond, and process it outside the request.
  • Failed deliveries retry up to 8 times over roughly 60 hours. That is a long tail: a bug you ship on Friday is still being redelivered on Sunday.
  • Therefore your handler must be idempotent. Key on the event id and make a second delivery of the same event a no-op.
  • Do not assume ordering. Retries interleave, so a payment.succeeded can arrive after the payment.refunded that followed it. Reconcile against the object state rather than treating each event as a transition from the last one.

Failure modes, and what they look like

SymptomUsual cause
Fails on some payloads, passes on mostHashing a re-serialised body instead of the raw bytes
Fails on every deliveryWrong signing secret, or a seconds-based replay window against a millisecond timestamp
Never rejects anything, including forged requestsReplay window so large it never triggers, or the digest comparison result ignored
Throws inside timingSafeEqualLength mismatch not checked before comparing
Passes locally, fails in productionA body-parsing middleware present in one environment only
Duplicate side effectsHandler is not idempotent and a delivery was retried
Why verification fails when the request was legitimate

The first row is the one worth re-reading. It is the only failure here that looks like flakiness rather than a bug, and it is the reason to fix the raw-body handling before you have a reason to.

GOOD QUESTIONS

Frequently asked

Why verify webhook signatures at all?+

Your webhook endpoint is a public URL that causes side effects — granting access, shipping goods, marking invoices paid. Without verification anyone who learns the URL can POST a fake payment.succeeded event and get those side effects for free. The signature is what proves the request came from your processor and not from someone who guessed the path.

Why can I not use JSON.stringify on the parsed body?+

The signature was computed over the exact bytes that were sent. A parse-then-stringify round trip is not guaranteed to reproduce those bytes: key order, unicode escaping and number formatting can all differ. It will appear to work, because most payloads survive the round trip unchanged, and then fail intermittently on the ones that do not. Always hash the raw body.

Why does the comparison need to be constant-time?+

A plain === returns as soon as it finds a differing byte, so the time it takes leaks how many leading bytes were correct. That is enough to recover a valid signature one byte at a time given enough attempts. crypto.timingSafeEqual always takes the same time for equal-length inputs, which removes the signal.

What timestamp tolerance should I use?+

Five minutes is a reasonable default, and it is what most processors assume. The window exists to stop replay: a captured request stays valid forever without one. Too tight and you reject legitimate deliveries during clock skew or a slow retry; too loose and a captured request stays useful for hours.

Should I verify signatures in the sandbox too?+

Yes. Verification is the part you most want to have exercised before production, and a handler that only verifies conditionally has an untested branch in the path that matters most. Use the sandbox signing secret and keep the code identical.