A payment provider sends payment.succeeded, your route parses the JSON, and the handler marks an order as paid. The endpoint works in development, but it also accepts the same JSON from anyone who discovers the URL. HTTPS protects the request while it travels; it does not prove who created it.

Webhook signatures solve that identity problem. The provider signs the exact bytes it sent with a secret known to both sides. Your server calculates the expected signature from the received bytes and rejects the event before changing state if the values do not match.

preserve the exact request bytes

The most common verification bug is hashing parsed JSON. These bodies describe the same object but have different bytes:

{"amount":1200,"currency":"INR"}
{ "currency": "INR", "amount": 1200 }

Whitespace and property order do not matter to JSON.parse, but they change a cryptographic signature. Read the body once as a Buffer, verify it, and only then parse it.

With a plain Node.js request:

async function readRawBody(req: IncomingMessage): Promise<Buffer> {
  const chunks: Buffer[] = [];
  let size = 0;

  for await (const chunk of req) {
    const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
    size += buffer.length;
    if (size > 1_000_000) throw new Error("Webhook body too large");
    chunks.push(buffer);
  }

  return Buffer.concat(chunks);
}

Frameworks expose raw bodies differently. Configure the webhook route before generic JSON middleware, or use the provider’s documented framework adapter. Logging req.body and seeing an object is a warning that parsing may already have happened.

verify the provider’s actual signing format

Do not invent a signature scheme. Providers differ on the header name, hash algorithm, timestamp format, multiple active secrets, and exact signed message. One common shape signs timestamp.rawBody with HMAC-SHA256:

import { createHmac, timingSafeEqual } from "node:crypto";

function validSignature(input: {
  rawBody: Buffer;
  timestamp: string;
  receivedHex: string;
  secret: string;
}): boolean {
  const signed = Buffer.concat([
    Buffer.from(`${input.timestamp}.`, "utf8"),
    input.rawBody,
  ]);
  const expected = createHmac("sha256", input.secret).update(signed).digest();

  let received: Buffer;
  try {
    received = Buffer.from(input.receivedHex, "hex");
  } catch {
    return false;
  }

  return received.length === expected.length && timingSafeEqual(received, expected);
}

This is an illustrative format, not a drop-in replacement for a provider SDK. Follow the provider’s primary documentation because signing formats are contracts.

Use timingSafeEqual only after checking equal lengths; otherwise Node throws. Reject missing, malformed, or duplicate signature fields before reaching business logic.

reject stale signed requests

A valid signature can be copied and replayed. If the signed message includes a timestamp, accept only a limited tolerance, such as five minutes:

const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
  return new Response("stale webhook", { status: 400 });
}

Clock tolerance reduces simple replay risk but does not replace event-level idempotency. A provider may legitimately retry the same event after your server times out, sometimes much later than five minutes with a newly signed request.

separate authenticity from idempotency

Signature verification answers “did the provider send these bytes?” It does not answer “have I processed this event?” Store the provider’s stable event ID behind a unique database constraint:

CREATE TABLE processed_webhooks (
  provider TEXT NOT NULL,
  event_id TEXT NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)
);

In one transaction, claim the event ID and apply the corresponding state transition. If the insert conflicts, return success for the duplicate without repeating the side effect. A Redis key alone can expire or disappear; a durable constraint should protect durable business state.

acknowledge only after durable acceptance

Providers retry non-success responses. That is useful when your server crashes, but dangerous when it returns 200 before preserving the event.

A robust route performs four bounded steps:

  1. Enforce method, content type, and body-size limits.
  2. Verify signature and timestamp.
  3. Persist the event or enqueue it through a transactional outbox.
  4. Return a success response quickly.

Slow enrichment, email, and downstream API calls belong in a worker. If the route times out after changing business state but before acknowledging, the retry must encounter the stored event ID and do nothing harmful.

rotate secrets without dropping deliveries

During rotation, providers may sign with the old or new secret. Store secrets in a managed secret system, identify versions where the provider supports that, and allow a short overlap. Try a small bounded list of active secrets rather than accepting an arbitrary key supplied by the request.

Never print the secret, full signature header, or sensitive body into normal logs. Useful fields are provider, event type, event ID, signature result, received time, processing state, and a request correlation ID.

test failures, not only a copied success payload

Keep a fixture containing exact raw bytes and a known test signature. Then change one byte and expect rejection. Test malformed hex, wrong secret, missing header, stale timestamp, oversized body, duplicate event ID, concurrent duplicate deliveries, database failure, and a handler crash before acknowledgement.

Monitor signature failures separately from processing failures. A sudden signature-failure spike may mean an attack, a secret mismatch, or middleware that changed body handling. A growing processing backlog means the event was authentic but your system cannot keep up.

The safe boundary is strict: preserve bytes, verify before parsing, reject stale requests, store the event identity durably, and make every downstream effect safe to repeat.

One final operational detail matters during incident response: keep a documented way to pause downstream processing without disabling signature verification or discarding deliveries. Persisting verified events while workers are paused gives the team time to repair a consumer and replay from a known position. Returning success without persistence loses data; returning errors indefinitely can create a retry storm. The durable inbox separates receiving evidence from acting on it.