How to buildOctober 11, 202612 min read
APIs and integrationsStep 4 of 8

Webhooks: receiving, signatures, retries and idempotency

In short

A webhook is an HTTP request in which an external service tells your server about an event on its own: a payment succeeded, an order changed. A reliable receiver verifies the HMAC signature over the raw body and a timestamp, stores the event by its id, answers 2xx right away and processes the event in the background. A repeated delivery with the same id must not run the action twice, and an older event must not overwrite a newer state.

How-toLevel: IntermediateWebhooksPractice: ~1–2 h

After this you will be able to

  • write an Express 5 receiver that gets the raw body via express.raw and answers 200 before processing starts
  • verify an HMAC-SHA256 signature with timingSafeEqual and reject forgeries, tampered bodies and requests with an expired timestamp
  • store an event by webhook-id so that a repeated delivery does not run the action twice and a late event does not overwrite a newer state
  • cover the receiver with tests using your own sender: valid and invalid signature, expired timestamp, duplicate

Verified with

Node.js 24 LTS (24.18)Express 5.3.0node:crypto (HMAC-SHA256, timingSafeEqual)node:testPostgreSQL 17 (17.11, postgres:17-alpine)

Checked against docs: October 11, 2026

Contents
  1. 01Webhook or polling: what changes for the server
  2. 02The receiver: raw body and a fast 2xx
  3. 03HMAC signatures and replay protection
  4. 04Sender retries and idempotency by event id
  5. 05Background processing and event order
  6. 06Testing: a test sender and automated tests
  7. 07Local debugging, monitoring and common mistakes

Webhook or polling: what changes for the server

There are two ways to learn about a change in an external system. Polling: your server keeps asking the API “is the order paid?”. A webhook: the external system itself sends a POST to a pre-registered URL on your server when the event happens. This is how Stripe reports that a bank confirmed a payment, a customer disputed a charge or a subscription renewed, and how GitHub reports pushes, pull requests and other repository events.

Polling

  • You take the initiative: requests on a schedule
  • Latency equals the polling interval
  • Frequent requests run into API rate limits
  • No public URL needed

Webhook

  • The provider takes the initiative: a request when the event happens
  • Almost no latency and no wasted requests
  • Requires a public HTTPS URL and authenticity checks
  • An event may arrive twice, out of order or not at all

The last line is the price of webhooks. The receiver URL is public, so anyone can send it a request that looks genuine. Delivery goes over the network, so one event can arrive twice, after a later one or never. The rest of this guide closes these four risks: forgery, duplicates, broken order and loss.

Provider signs and sends a POSTCheck signature and timestampStore in the log by idAnswer 200Process in the background
The path of an event through the receiver in this guide: the response goes out before the business logic.

The receiver: raw body and a fast 2xx

This guide is for backend developers who know Express and background jobs. The code is tested on Node.js 24.18 and Express 5.3.0; the only external package is express, cryptography comes from the built-in node:crypto module and tests use the built-in node:test.

The provider computes the signature over the exact bytes of the request body. If you parse the body with express.json() and then rebuild it with JSON.stringify(req.body), whitespace, key order or character escaping may differ — and the signature will not match. Stripe’s documentation explicitly requires the raw body: any change to it breaks verification. So the webhook route gets express.raw({ type: "application/json" }), and req.body becomes a Buffer. In Express 5, if the Content-Type does not match, req.body stays undefined; the receiver answers 415 in that case.

The second requirement is speed. Stripe requires a 2xx before any complex logic, GitHub expects a 2xx within 10 seconds and otherwise marks the delivery as failed, and the Standard Webhooks specification recommends a 15–30 second timeout for senders. So the route handler only does the quick steps:

  1. Verify the signature and timestamp

    Before parsing JSON: an unsigned body cannot be trusted even as a structure.

  2. Parse JSON

    Only after verification, from the same Buffer.

  3. Store the event by id

    insertIfNew returns false if the id was seen before.

  4. Enqueue

    Only a new event; processing does not block the response.

  5. Answer 200

    For a duplicate too: otherwise the sender keeps retrying.

bash
mkdir webhook-receiver
cd webhook-receiver
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm pkg delete main scripts.test
npm pkg set scripts.start="node src/server.js" scripts.test="node --test"
npm install express
mkdir src scripts test
Project: ES modules, the only dependency is express
src/app.js
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "./signature.js";

export function createApp({ secrets, store, queue, logger = console, now = () => Date.now() }) {
  const app = express();
  app.disable("x-powered-by");

  // Raw body only on this route: the signature covers the exact bytes
  app.post(
    "/webhooks/payments",
    express.raw({ type: "application/json", limit: "256kb" }),
    async (req, res) => {
      if (!Buffer.isBuffer(req.body)) {
        return res.status(415).json({ error: "Expected application/json" });
      }
      let meta;
      try {
        meta = verifyWebhook({ secrets, headers: req.headers, rawBody: req.body, now: now() });
      } catch (error) {
        if (!(error instanceof WebhookVerificationError)) throw error;
        logger.warn(`webhook rejected: ${error.message}`);
        return res.status(400).json({ error: "Invalid webhook signature" });
      }

      let event;
      try {
        event = JSON.parse(req.body.toString("utf8"));
      } catch {
        return res.status(400).json({ error: "Body is not valid JSON" });
      }

      // Save first, then answer; a repeated delivery is not queued again
      const isNew = await store.insertIfNew({ id: meta.id, type: event.type, payload: event });
      if (isNew) queue.enqueue(meta.id);
      logger.info(`webhook ${meta.id} ${event.type}: ${isNew ? "queued" : "duplicate"}`);
      res.status(200).json({ received: true, duplicate: !isNew });
    },
  );

  // The rest of the API parses JSON as usual
  app.use(express.json());
  return app;
}
The webhook route: raw body, verification, storage, queue and response — no business logic
src/queue.js
// The simplest in-process background queue: the HTTP response does not wait
// for processing. Tasks are lost if the process dies, which is why the event
// is saved to the store first. In production use BullMQ or similar.
export function createBackgroundQueue(handler, logger = console) {
  const running = new Set();
  return {
    enqueue(id) {
      const task = new Promise((resolve) => setImmediate(resolve))
        .then(() => handler(id))
        .catch((error) => logger.error(`event ${id} failed: ${error.message}`))
        .finally(() => running.delete(task));
      running.add(task);
    },
    async idle() {
      while (running.size > 0) await Promise.all(running);
    },
  };
}
A teaching in-process queue: the response does not wait for processing; use BullMQ or similar in production

HMAC signatures and replay protection

HMAC (hash-based message authentication code) is a message authenticity code based on a shared secret. Only the provider and you know the secret. The provider computes HMAC-SHA256 over the signed string and puts the result in a header; you compute the same and compare. Without the secret the signature cannot be forged, and changing even one byte of the body produces a different signature. Providers use different formats:

Stripe

Headers
Stripe-Signature: t=…,v1=…
What is signed
timestamp t, a dot, the raw body
Encoding and key
hex; the key is the whole whsec_… string; only the v1 scheme counts

GitHub

Headers
X-Hub-Signature-256: sha256=…
What is signed
the raw body only
Encoding and key
hex; no timestamp in the signature, the delivery id is in X-GitHub-Delivery

Standard Webhooks

Headers
webhook-id, webhook-timestamp, webhook-signature: v1,…
What is signed
id.timestamp.body
Encoding and key
base64; the key is the base64-decoded part after whsec_; several signatures separated by spaces

Our receiver accepts the Standard Webhooks format — an open specification you can also use as a basis for your own outgoing webhooks. We checked the sign function against the reference standardwebhooks 1.1.1 library, and the Stripe row of the table against the test header generator from the stripe 23.0.0 package. The code in this guide does not need these SDKs. Note the difference in keys: Stripe uses the whsec_… string as is, while Standard Webhooks decodes the base64 after the prefix — mixing them up yields an “invalid signature” on every request.

src/signature.js
import { createHmac, timingSafeEqual } from "node:crypto";

export const TOLERANCE_SECONDS = 5 * 60;

export class WebhookVerificationError extends Error {}

// Secret in the Standard Webhooks format: "whsec_" + base64 of random bytes
function keyFrom(secret) {
  return Buffer.from(secret.replace(/^whsec_/, ""), "base64");
}

// Signed content: "<id>.<timestamp>.<raw body>", HMAC-SHA256, base64
export function sign(secret, id, timestamp, rawBody) {
  return createHmac("sha256", keyFrom(secret))
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest("base64");
}

export function verifyWebhook({ secrets, headers, rawBody, now = Date.now() }) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) {
    throw new WebhookVerificationError("Missing webhook headers");
  }
  if (!/^\d{1,12}$/.test(timestamp)) {
    throw new WebhookVerificationError("Invalid timestamp");
  }
  // Replay protection: an intercepted request is useless after the tolerance window
  const age = Math.floor(now / 1000) - Number(timestamp);
  if (Math.abs(age) > TOLERANCE_SECONDS) {
    throw new WebhookVerificationError(`Timestamp outside tolerance (${age}s)`);
  }

  // The header may hold several signatures separated by spaces (secret rotation)
  const received = signatureHeader
    .split(" ")
    .filter((part) => part.startsWith("v1,"))
    .map((part) => Buffer.from(part.slice(3), "base64"));

  for (const secret of secrets) {
    const expected = Buffer.from(sign(secret, id, timestamp, rawBody), "base64");
    for (const candidate of received) {
      // timingSafeEqual throws on different lengths, so compare lengths first
      if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
        return { id, timestamp: Number(timestamp) };
      }
    }
  }
  throw new WebhookVerificationError("No matching signature");
}

// GitHub format: X-Hub-Signature-256: sha256=<hex HMAC of the raw body>
export function verifyGithubSignature(secret, rawBody, header = "") {
  const expected = Buffer.from(
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"),
  );
  const received = Buffer.from(header);
  return received.length === expected.length && timingSafeEqual(received, expected);
}
Signature verification: timestamp, several signatures, constant-time comparison; plus the GitHub format
  • Compare with timingSafeEqual, not ===: the Node.js documentation describes it as a constant-time comparison suitable for HMAC digests. It throws when buffer lengths differ, so the length is checked first — an HMAC-SHA256 is always 32 bytes and its length is not a secret.
  • The timestamp is part of the signed string, so it cannot be changed without breaking the signature. Stripe’s libraries allow a 5-minute difference by default and warn that a tolerance of 0 disables the recency check. We reject both too old and “future” timestamps; keep the server clock in sync with NTP.
  • Within the tolerance window an intercepted request can be replayed — the event log by id from the next section closes that. GitHub has no timestamp in the signature, and its documentation suggests protecting against replays with the unique X-GitHub-Delivery header.
  • Secret rotation: the secrets list accepts the current and the previous one. Stripe lets you keep the old secret active for up to 24 hours and signs each event with every active secret during that time.
  • Answer an invalid signature with 400 and no details; log the reason only on your side.

Sender retries and idempotency by event id

The sender treats a delivery as successful only on a 2xx response. An error, a timeout or a redirect is a reason to retry, and provider policies differ:

Stripe

Automatic retries
In live mode — for up to three days with exponential backoff; in a sandbox — three attempts over a few hours
Also important
A 3xx counts as a failure; every attempt gets a new timestamp and signature; manual resend from the Dashboard works for up to 15 days, via the CLI for up to 30 days

GitHub

Automatic retries
None: failed deliveries are not redelivered automatically
Also important
A 2xx within 10 seconds; redeliver manually or with a script via the REST API; X-GitHub-Delivery stays the same

Standard Webhooks

Automatic retries
A schedule spanning several days with exponential backoff and random jitter
Also important
410 Gone means “disable this endpoint”; on 429, 502, 504 the sender throttles

The main consequence: one event can arrive twice. For example, you processed it but the response got lost or came too late — the sender delivers it again. Stripe states that an endpoint may occasionally receive the same event more than once and advises logging processed event ids and skipping the logged ones. That is receiver idempotency: a repeat does not change the result. Answer a repeat with 200 and a duplicate flag, not an error — otherwise the sender keeps trying.

src/event-store.js
// In-memory "inbox" for the demo. In production: a table with a unique
// event id (see the SQL below); the logic stays the same.
export class EventStore {
  #events = new Map();

  // false means the id is already known: a repeated delivery
  async insertIfNew({ id, type, payload }) {
    if (this.#events.has(id)) return false;
    this.#events.set(id, {
      id,
      type,
      payload,
      status: "received",
      attempts: 0,
      receivedAt: new Date().toISOString(),
    });
    return true;
  }

  async get(id) {
    return this.#events.get(id) ?? null;
  }

  async markProcessed(id, result) {
    Object.assign(this.#events.get(id), { status: "processed", result });
  }

  async markFailed(id, error) {
    const event = this.#events.get(id);
    Object.assign(event, { status: "failed", attempts: event.attempts + 1, error: error.message });
  }

  async listUnprocessed() {
    return [...this.#events.values()].filter((event) => event.status !== "processed");
  }
}
An in-memory log of incoming events — a teaching stand-in for a table with a unique id

In production the log lives in the database. The primary key guarantees uniqueness, and INSERT … ON CONFLICT DO NOTHING RETURNING answers “is this event new?” in one statement: per the PostgreSQL documentation only rows actually inserted are returned. “SELECT first, then INSERT” does not work: two parallel deliveries on different server instances pass the check at the same time.

inbox.sql
CREATE TABLE webhook_events (
  provider     text        NOT NULL,
  id           text        NOT NULL,  -- webhook-id / event id from the provider
  type         text        NOT NULL,
  payload      jsonb       NOT NULL,
  status       text        NOT NULL DEFAULT 'received'
               CHECK (status IN ('received', 'processed', 'failed')),
  attempts     int         NOT NULL DEFAULT 0,
  received_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  PRIMARY KEY (provider, id)
);

CREATE TABLE orders (
  id               text PRIMARY KEY,
  status           text NOT NULL,
  provider_version int  NOT NULL DEFAULT 0
);
INSERT INTO orders (id, status) VALUES ('ord_1042', 'new');

-- Receiver: one row returned = new event, zero rows = repeated delivery
INSERT INTO webhook_events (provider, id, type, payload)
VALUES ('payments', 'msg_1', 'order.status_changed', '{"data": {"id": "ord_1042"}}')
ON CONFLICT (provider, id) DO NOTHING
RETURNING id;

-- Processor: an older version does not overwrite a newer one
UPDATE orders SET status = 'refunded', provider_version = 2
WHERE id = 'ord_1042' AND provider_version < 2;
UPDATE orders SET status = 'paid', provider_version = 1
WHERE id = 'ord_1042' AND provider_version < 1;
The log table, an insert protected against duplicates and an order update that only accepts a newer version
terminal
$ docker run -d --rm --name kb-pg-webhooks -e POSTGRES_PASSWORD=test postgres:17-alpine
$ docker exec -i kb-pg-webhooks psql -U postgres < inbox.sql
CREATE TABLE
CREATE TABLE
INSERT 0 1
  id
-------
 msg_1
(1 row)

INSERT 0 1
UPDATE 1
UPDATE 0

$ docker exec kb-pg-webhooks psql -U postgres -c "INSERT INTO webhook_events (provider, id, type, payload) VALUES ('payments', 'msg_1', 'order.status_changed', '{\"data\": {\"id\": \"ord_1042\"}}') ON CONFLICT (provider, id) DO NOTHING RETURNING id;" -c "SELECT * FROM orders;"
 id
----
(0 rows)

INSERT 0 0
    id    |  status  | provider_version
----------+----------+------------------
 ord_1042 | refunded |                2
(1 row)
Actual output on PostgreSQL 17.11: the first insert returned the id, the repeat returned 0 rows; the older order version did not overwrite the newer one

One more detail from Stripe’s documentation: sometimes the provider generates two separate events for one change. Their ids differ, so critical actions also check the “object id + event type” pair or the object version — that is the next section.

Background processing and event order

The receiver has already stored the event with the received status. A background handler takes the id, runs the business action and sets processed or failed. Storing before enqueuing is the key point: the teaching in-memory queue disappears if the process crashes, but the log row remains and reprocessPending picks it up on schedule. This is the inbox pattern — a mirror of the transactional outbox from the event-driven architecture guide. In production the in-memory queue is replaced by BullMQ or similar (see the background jobs guide) or by a worker that selects unprocessed rows from the table.

src/processor.js
export function createProcessor({ store, orders, logger = console }) {
  async function processEvent(id) {
    const event = await store.get(id);
    if (!event || event.status === "processed") return;
    try {
      if (event.type === "order.status_changed") {
        const { id: orderId, status, version } = event.payload.data;
        const applied = await orders.applyIfNewer(orderId, status, version);
        await store.markProcessed(id, applied ? "applied" : "stale");
        logger.info(`event ${id}: order ${orderId} -> ${status} v${version} (${applied ? "applied" : "stale, skipped"})`);
      } else {
        await store.markProcessed(id, "ignored"); // a type we do not handle
      }
    } catch (error) {
      await store.markFailed(id, error);
      throw error;
    }
  }

  // Run on a schedule: picks up events that failed or were lost from the queue
  async function reprocessPending() {
    for (const event of await store.listUnprocessed()) await processEvent(event.id);
  }

  return { processEvent, reprocessPending };
}
The processor: applies an event, records the result and can rerun unprocessed events

Now order. Stripe does not guarantee delivering events in the order they were generated and warns that created is stored in seconds and can be equal for different events, so it must not be used either to order events or to detect duplicates. There are two working approaches:

  • Object version. If the provider sends a monotonically increasing version or change number, apply an event only when it is newer than the stored state. For the fictional payment provider in the example this is data.version; in SQL it is the provider_version < $2 condition in the UPDATE.
  • Event as a signal. Do not trust the body data; re-read the current object state through the provider API. Stripe suggests this for missing objects, and with thin events it is the main path: the SDK fetches the current object with a separate request.
src/orders.js
// Order state on our side. version is the object version from the provider:
// it grows with every change, so an old event never overwrites a newer state.
export class OrderStore {
  #orders = new Map();

  async applyIfNewer(id, status, version) {
    const current = this.#orders.get(id);
    if (current && current.version >= version) return false;
    this.#orders.set(id, { status, version });
    return true;
  }

  async get(id) {
    return this.#orders.get(id) ?? null;
  }
}
Order state: an event with an older version does not overwrite a newer one
src/server.js
import { createApp } from "./app.js";
import { EventStore } from "./event-store.js";
import { OrderStore } from "./orders.js";
import { createProcessor } from "./processor.js";
import { createBackgroundQueue } from "./queue.js";

// Current secret plus, during rotation, the previous one: "whsec_new,whsec_old"
const secrets = (process.env.WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
if (secrets.length === 0) {
  console.error("Set WEBHOOK_SECRETS: the signing secret from the provider dashboard");
  process.exit(1);
}

const store = new EventStore();
const orders = new OrderStore();
const processor = createProcessor({ store, orders });
const queue = createBackgroundQueue(processor.processEvent);
const app = createApp({ secrets, store, queue });

// Safety net: once a minute, retry events that failed in the background
setInterval(() => processor.reprocessPending().catch(console.error), 60_000).unref();

const port = Number(process.env.PORT ?? 4242);
app.listen(port, "127.0.0.1", (error) => {
  if (error) {
    console.error(`Cannot start server: ${error.code}`);
    process.exit(1);
  }
  console.log(`Webhook receiver: http://127.0.0.1:${port}/webhooks/payments`);
});
Entry point: secrets from the environment, the queue, reprocessing once a minute

Testing: a test sender and automated tests

To avoid depending on a real provider, write a sender that signs the body in exactly the same way. Both the tests and manual checks use it. The fetch timeout uses AbortSignal.timeout, as the specification recommends for senders.

src/sender.js
import { randomUUID } from "node:crypto";
import { sign } from "./signature.js";

// Test sender: signs the body exactly the way the provider does
export async function sendWebhook({
  url,
  secret,
  event,
  id = `msg_${randomUUID()}`,
  timestamp = Math.floor(Date.now() / 1000),
  body = JSON.stringify(event),
  signature = `v1,${sign(secret, id, timestamp, body)}`,
}) {
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "webhook-id": id,
      "webhook-timestamp": String(timestamp),
      "webhook-signature": signature,
    },
    body,
    signal: AbortSignal.timeout(15_000),
  });
  return { status: res.status, body: await res.json() };
}
The test sender: id, timestamp and signature by default, but any field can be overridden
scripts/send-test-event.js
import { sendWebhook } from "../src/sender.js";

// node scripts/send-test-event.js <orderId> <status> <version> [eventId]
const [orderId = "ord_1042", status = "paid", version = "1", id] = process.argv.slice(2);
const result = await sendWebhook({
  url: process.env.WEBHOOK_URL ?? "http://127.0.0.1:4242/webhooks/payments",
  secret: process.env.WEBHOOK_SECRET,
  id,
  event: { type: "order.status_changed", data: { id: orderId, status, version: Number(version) } },
});
console.log(result.status, JSON.stringify(result.body));
Sending an event manually from the terminal
terminal
# terminal 1
$ export WEBHOOK_SECRET="whsec_$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))")"
$ WEBHOOK_SECRETS="$WEBHOOK_SECRET" npm start

> webhook-receiver@1.0.0 start
> node src/server.js

Webhook receiver: http://127.0.0.1:4242/webhooks/payments
webhook msg_002 order.status_changed: queued
event msg_002: order ord_1042 -> refunded v2 (applied)
webhook msg_001 order.status_changed: queued
event msg_001: order ord_1042 -> paid v1 (stale, skipped)
webhook msg_001 order.status_changed: duplicate
webhook rejected: No matching signature

# terminal 2 (the same WEBHOOK_SECRET)
$ node scripts/send-test-event.js ord_1042 refunded 2 msg_002
200 {"received":true,"duplicate":false}
$ node scripts/send-test-event.js ord_1042 paid 1 msg_001
200 {"received":true,"duplicate":false}
$ node scripts/send-test-event.js ord_1042 paid 1 msg_001
200 {"received":true,"duplicate":true}
$ curl -s -X POST http://127.0.0.1:4242/webhooks/payments -H 'content-type: application/json' \
    -H 'webhook-id: msg_fake' -H "webhook-timestamp: $(date +%s)" \
    -H 'webhook-signature: v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' \
    -d '{"type":"order.status_changed","data":{"id":"ord_1042","status":"paid","version":99}}' -w '  HTTP %{http_code}\n'
{"error":"Invalid webhook signature"}  HTTP 400
Actual run: event v2 applied, the late v1 skipped as stale, the duplicate not enqueued, a forged signature gets 400

The automated tests start a real HTTP server on a free port and send requests with the same sendWebhook. Secrets in the tests are random — no real keys are needed.

test/webhooks.test.js
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { afterEach, beforeEach, describe, it } from "node:test";
import { createApp } from "../src/app.js";
import { EventStore } from "../src/event-store.js";
import { OrderStore } from "../src/orders.js";
import { createProcessor } from "../src/processor.js";
import { createBackgroundQueue } from "../src/queue.js";
import { sendWebhook } from "../src/sender.js";
import { sign, verifyGithubSignature } from "../src/signature.js";

const newSecret = () => `whsec_${randomBytes(32).toString("base64")}`;
const SECRET = newSecret();
const silent = { info() {}, warn() {}, error() {} };
const paid = (version = 1) => ({
  type: "order.status_changed",
  data: { id: "ord_1042", status: "paid", version },
});

let server, url, store, orders, queue, processor;

async function start({ secrets = [SECRET], slowMs = 0 } = {}) {
  store = new EventStore();
  orders = new OrderStore();
  processor = createProcessor({ store, orders, logger: silent });
  const handle = async (id) => {
    if (slowMs) await new Promise((resolve) => setTimeout(resolve, slowMs));
    await processor.processEvent(id);
  };
  queue = createBackgroundQueue(handle, silent);
  const app = createApp({ secrets, store, queue, logger: silent });
  await new Promise((resolve) => (server = app.listen(0, "127.0.0.1", resolve)));
  url = `http://127.0.0.1:${server.address().port}/webhooks/payments`;
}

beforeEach(() => start());
afterEach(() => new Promise((resolve) => server.close(resolve)));

describe("signature", () => {
  it("accepts a valid signature and processes the event in the background", async () => {
    const res = await sendWebhook({ url, secret: SECRET, event: paid() });
    assert.equal(res.status, 200);
    assert.deepEqual(res.body, { received: true, duplicate: false });
    await queue.idle();
    assert.deepEqual(await orders.get("ord_1042"), { status: "paid", version: 1 });
  });

  it("rejects a signature made with another secret", async () => {
    const res = await sendWebhook({ url, secret: newSecret(), event: paid() });
    assert.equal(res.status, 400);
    assert.equal((await store.listUnprocessed()).length, 0);
  });

  it("rejects a body changed after signing", async () => {
    const id = "msg_tampered";
    const timestamp = Math.floor(Date.now() / 1000);
    const original = await sendWebhook({ url, secret: SECRET, id: "msg_original", event: paid() });
    assert.equal(original.status, 200);
    const signature = `v1,${sign(SECRET, id, timestamp, JSON.stringify(paid()))}`;
    const body = JSON.stringify({ ...paid(), data: { id: "ord_1042", status: "refunded", version: 9 } });
    const res = await sendWebhook({ url, secret: SECRET, id, timestamp, body, signature });
    assert.equal(res.status, 400);
  });

  it("rejects an expired timestamp even with a valid signature (replay)", async () => {
    const tenMinutesAgo = Math.floor(Date.now() / 1000) - 600;
    const res = await sendWebhook({ url, secret: SECRET, event: paid(), timestamp: tenMinutesAgo });
    assert.equal(res.status, 400);
  });

  it("accepts the old secret during rotation", async () => {
    await new Promise((resolve) => server.close(resolve));
    const oldSecret = newSecret();
    await start({ secrets: [newSecret(), oldSecret] });
    const res = await sendWebhook({ url, secret: oldSecret, event: paid() });
    assert.equal(res.status, 200);
  });

  it("checks the GitHub format with the test values from the docs", () => {
    const header = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
    assert.equal(verifyGithubSignature("It's a Secret to Everybody", "Hello, World!", header), true);
    assert.equal(verifyGithubSignature("wrong secret", "Hello, World!", header), false);
    assert.equal(verifyGithubSignature("It's a Secret to Everybody", "Hello, World!", "sha256=00"), false);
  });
});

describe("delivery", () => {
  it("does not process a repeated delivery twice", async () => {
    let calls = 0;
    const applyIfNewer = orders.applyIfNewer.bind(orders);
    orders.applyIfNewer = (...args) => { calls++; return applyIfNewer(...args); };

    const first = await sendWebhook({ url, secret: SECRET, id: "msg_dup", event: paid() });
    // The sender retries: same id, new timestamp and signature
    const retry = await sendWebhook({ url, secret: SECRET, id: "msg_dup", event: paid() });
    await queue.idle();
    assert.equal(first.body.duplicate, false);
    assert.equal(retry.status, 200);
    assert.equal(retry.body.duplicate, true);
    assert.equal(calls, 1);
  });

  it("answers before the slow processing finishes", async () => {
    await new Promise((resolve) => server.close(resolve));
    await start({ slowMs: 300 });
    const started = performance.now();
    const res = await sendWebhook({ url, secret: SECRET, event: paid() });
    const elapsed = performance.now() - started;
    assert.equal(res.status, 200);
    assert.ok(elapsed < 300, `answered in ${elapsed.toFixed(0)} ms`);
    assert.equal(await orders.get("ord_1042"), null); // not processed yet
    await queue.idle();
    assert.equal((await orders.get("ord_1042")).status, "paid");
  });

  it("ignores an older event that arrives after a newer one", async () => {
    const refunded = { type: "order.status_changed", data: { id: "ord_1042", status: "refunded", version: 2 } };
    await sendWebhook({ url, secret: SECRET, id: "msg_v2", event: refunded });
    await queue.idle();
    await sendWebhook({ url, secret: SECRET, id: "msg_v1", event: paid(1) });
    await queue.idle();
    assert.deepEqual(await orders.get("ord_1042"), { status: "refunded", version: 2 });
    assert.equal((await store.get("msg_v1")).result, "stale");
  });

  it("keeps a failed event and processes it on the next pass", async () => {
    const applyIfNewer = orders.applyIfNewer.bind(orders);
    orders.applyIfNewer = async () => { throw new Error("database is down"); };
    await sendWebhook({ url, secret: SECRET, id: "msg_fail", event: paid() });
    await queue.idle();
    assert.equal((await store.get("msg_fail")).status, "failed");

    orders.applyIfNewer = applyIfNewer; // the database is back
    await processor.reprocessPending();
    assert.equal((await store.get("msg_fail")).status, "processed");
    assert.equal((await orders.get("ord_1042")).status, "paid");
  });

  it("rejects a body without the JSON content type", async () => {
    const res = await fetch(url, { method: "POST", headers: { "content-type": "text/plain" }, body: "hi" });
    assert.equal(res.status, 415);
  });
});
11 tests: signature, tampered body, expired timestamp, rotation, GitHub format, duplicate, response speed, order, reprocessing
terminal
> webhook-receiver@1.0.0 test
> node --test

▶ signature
  ✔ accepts a valid signature and processes the event in the background (39.7763ms)
  ✔ rejects a signature made with another secret (6.3364ms)
  ✔ rejects a body changed after signing (7.3984ms)
  ✔ rejects an expired timestamp even with a valid signature (replay) (3.4191ms)
  ✔ accepts the old secret during rotation (3.7364ms)
  ✔ checks the GitHub format with the test values from the docs (1.0054ms)
✔ signature (62.3897ms)
▶ delivery
  ✔ does not process a repeated delivery twice (4.4402ms)
  ✔ answers before the slow processing finishes (309.4118ms)
  ✔ ignores an older event that arrives after a newer one (9.2903ms)
  ✔ keeps a failed event and processes it on the next pass (2.8013ms)
  ✔ rejects a body without the JSON content type (3.1035ms)
✔ delivery (329.5465ms)
ℹ tests 11
ℹ suites 2
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 550.4084
Actual npm test output on Node.js 24.18
  • The expired timestamp test checks replay protection specifically: the signature is valid, but a ten-minute-old request is rejected. If you remove the time check from verifyWebhook, exactly this test fails — we checked.
  • The speed test: processing artificially takes 300 ms, the response arrives earlier, and at that moment the order is not changed yet.
  • The GitHub format test uses the reference values from GitHub’s documentation: the secret It's a Secret to Everybody, the body Hello, World! and the expected signature.

Local debugging, monitoring and common mistakes

A provider cannot reach localhost. Options: a tunnel that gives you a temporary public HTTPS URL (Stripe’s documentation mentions ngrok); the provider CLI — stripe listen --forward-to localhost:4242/webhook forwards events to a local server and prints a signing secret for the session, and stripe trigger payment_intent.succeeded creates a test event; finally, your own sender from the previous section, which needs no network at all. Do not push real customer data or production secrets through a tunnel.

Webhooks get lost: GitHub does not redeliver failed deliveries automatically, and its documentation suggests a scheduled script that finds failed deliveries via the REST API and requests redelivery. For payments, in our experience, reconciliation is mandatory: periodically request the status of “stuck” orders whose event never arrived. What to monitor:

  • the number of rejected signatures — a spike means a rotated secret or a forgery attempt;
  • the share of duplicates — a sharp rise means you answer too slowly and the sender retries;
  • the number of failed events and the age of the oldest unprocessed one;
  • receiver response time and the time from receipt to processing;
  • the provider’s delivery log: Stripe’s Event deliveries tab shows Delivered, Pending and Failed with the response code.
  • express.json() registered before the webhook route — signatures don’t match or you get 415
  • The signature is compared with === or in different encodings (hex vs base64)
  • The Stripe secret is base64-decoded and the Standard Webhooks one is not (or vice versa)
  • Business logic runs before the response: timeouts become retries, retries become duplicates
  • An unknown event type returns 4xx or 500 — the sender retries it for days; the right answer is 200 and skip
  • Duplicates are detected by the created timestamp instead of the event id
  • A timestamp tolerance of 0 or a whole day; a server clock without NTP
  • The secret lives in code or the repository instead of environment variables and is never rotated
How this was verified5 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.