How to Implement Webhooks in Node.js: Signature Verification, Retries, and Idempotency

How to Implement Webhooks in Node.js: Signature Verification, Retries, and Idempotency

by | Sep 26, 2026 | Uncategorized | 0 comments

Most tutorials about webhook Node.js integrations stop at the fun part: spin up an Express route, log req.body, celebrate. That is a demo, not a production endpoint. The moment a real provider (Stripe, GitHub, Shopify, Slack, your own internal service) starts hammering your URL, three problems show up at once: can you prove the request is genuine?, what happens when your handler throws?, and what happens when the same event arrives four times?

This guide covers the receiver side end to end, with code you can paste into an Express 5 app running on Node.js 22 or 24 LTS. We will build HMAC signature verification the way Stripe does it, a fast acknowledgement path, an idempotency layer backed by a unique constraint, a retry queue with exponential backoff and jitter, and a dead-letter strategy with a replay endpoint.

What is a webhook, and why the receiver is the hard part

A webhook is an HTTP callback: instead of you polling an API every 30 seconds asking “anything new?”, the provider sends an HTTP POST to a URL you own the instant something happens. It is push instead of pull, which means lower latency, fewer wasted requests, and near real-time data sync.

The catch is that the provider is now a client you do not control. It will:

  • Retry on any non-2xx response, sometimes for hours or days.
  • Send events out of order, because retries and parallel delivery workers do not respect chronology.
  • Send the same event more than once, even when your first response was a 200 that got lost in transit.
  • Time out if you take too long (Stripe expects a response quickly, GitHub cuts off around 10 seconds).

So a correct webhook receiver is not a route handler. It is a small pipeline.

webhook code laptop

Webhook vs REST API vs callback

Aspect Webhook REST API Callback (in-process)
Who initiates The provider (server to server push) You (client pull) The runtime, inside one process
Transport HTTP POST to your public URL HTTP request/response Function reference, no network
Latency Near real time Depends on polling interval Immediate
Auth model HMAC signature or shared secret API key, OAuth token None needed
Delivery guarantee At least once (duplicates expected) Exactly once per call you make Exactly once

The line that matters for your code: webhooks are at-least-once. Everything below exists because of that single sentence.

The architecture: accept fast, process later

The pattern that works in production has four stages:

  1. Verify the signature against the raw request bytes. Reject with 400 if it fails.
  2. Persist the event with a unique constraint on the provider event id. If the insert conflicts, it is a replay: answer 200 and stop.
  3. Acknowledge immediately with 200 or 202. No business logic, no email sending, no third party calls in the request cycle.
  4. Process asynchronously in a worker with retries, backoff and a dead-letter queue.

Total time in the HTTP handler: a signature check plus one database insert plus one queue push. Typically under 20 ms.

webhook code laptop

Step 1: capture the raw body in Express

Signature verification is computed over the exact bytes the provider sent. If express.json() parses the body first, JSON.stringify(req.body) will not reproduce those bytes (key order, whitespace, unicode escaping all differ) and every signature check will fail. This is the number one reason webhook verification “randomly” fails. A comparable breakdown sits on chatbot.com.

Mount express.raw() on the webhook route only, before the global JSON parser:

import express from "express";
import { handleStripeWebhook } from "./webhooks/stripe.js";

const app = express();

// Raw bytes, webhook route only. Must come before express.json().
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json", limit: "512kb" }),
  handleStripeWebhook
);

// Everything else gets normal JSON parsing.
app.use(express.json({ limit: "100kb" }));

app.listen(3000);

If you need the parsed body and the raw bytes globally, use the verify hook instead:

app.use(express.json({
  limit: "512kb",
  verify: (req, res, buf) => {
    req.rawBody = buf; // Buffer, untouched
  }
}));

Note the limit option. An unbounded webhook endpoint is a free memory exhaustion vector for anyone who finds your URL.

Step 2: verify the HMAC signature, Stripe style

Stripe sends a Stripe-Signature header shaped like t=1756370000,v1=abc123...,v1=def456.... The signed payload is timestamp + "." + rawBody, hashed with HMAC SHA-256 using your endpoint secret. Multiple v1 values appear during secret rotation.

Here is a dependency-free implementation using only node:crypto:

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutes

export function verifySignature(rawBody, signatureHeader, secrets) {
  if (!signatureHeader) {
    throw new WebhookError("missing_signature");
  }

  let timestamp = null;
  const provided = [];

  for (const part of signatureHeader.split(",")) {
    const index = part.indexOf("=");
    if (index === -1) continue;
    const key = part.slice(0, index).trim();
    const value = part.slice(index + 1).trim();
    if (key === "t") timestamp = value;
    if (key === "v1") provided.push(value);
  }

  if (!timestamp || provided.length === 0) {
    throw new WebhookError("malformed_signature");
  }

  // Replay window: reject anything too old or from the future.
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (Number.isNaN(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    throw new WebhookError("timestamp_out_of_tolerance");
  }

  const signedPayload = Buffer.concat([
    Buffer.from(timestamp + ".", "utf8"),
    rawBody
  ]);

  const candidates = secrets.map((secret) =>
    crypto.createHmac("sha256", secret).update(signedPayload).digest()
  );

  const matches = provided.some((hex) => {
    let sig;
    try {
      sig = Buffer.from(hex, "hex");
    } catch {
      return false;
    }
    return candidates.some(
      (expected) =>
        sig.length === expected.length && crypto.timingSafeEqual(sig, expected)
    );
  });

  if (!matches) {
    throw new WebhookError("invalid_signature");
  }

  return true;
}

export class WebhookError extends Error {}

Three details people get wrong

  • Always use crypto.timingSafeEqual. Comparing hex strings with === leaks timing information and turns signature forgery into a measurable side channel. timingSafeEqual throws if the buffers have different lengths, hence the explicit length check first.
  • Enforce a timestamp tolerance. Without it, a captured valid request can be replayed forever. Five minutes is the common default.
  • Accept an array of secrets. Passing [process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_PREVIOUS].filter(Boolean) makes secret rotation a zero-downtime config change instead of an outage.

GitHub uses a simpler scheme (X-Hub-Signature-256: sha256=... over the raw body, no timestamp), and the Standard Webhooks spec uses webhook-id, webhook-timestamp and webhook-signature. The structure of your verifier stays identical; only the header parsing and the signed payload string change.

Step 3: answer with the right HTTP status code

Your status code is an instruction to the sender’s retry engine. Get this wrong and you either lose events or get retried into oblivion.

Situation Status Why
Event accepted and queued 202 Honest: received, not yet processed
Duplicate of an event already stored 200 Stops the retry loop immediately
Event type you do not handle 200 Never retry something you will never process
Bad or missing signature 400 Permanent failure, retrying cannot fix it
Unparseable JSON 400 Same reasoning
Database or queue unreachable 503 Temporary, you want the provider retry

Do not return 500 for a validation error. Do not return 200 when your database is down, that silently drops the event forever. Much the same conclusion turns up on dev.to.

webhook code laptop

Step 4: idempotency, or how to survive replays

Idempotency means processing the same event twice produces the same end state as processing it once. You need it at two levels.

4.1 Ingress deduplication with a unique constraint

Let the database be the source of truth. No SELECT then INSERT (that race condition will bite you under concurrent delivery), just one atomic upsert:

CREATE TABLE webhook_events (
  id           BIGSERIAL PRIMARY KEY,
  provider     TEXT        NOT NULL,
  event_id     TEXT        NOT NULL,
  event_type   TEXT        NOT NULL,
  payload      JSONB       NOT NULL,
  status       TEXT        NOT NULL DEFAULT 'received',
  attempts     INT         NOT NULL DEFAULT 0,
  last_error   TEXT,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at TIMESTAMPTZ,
  CONSTRAINT webhook_events_unique UNIQUE (provider, event_id)
);

CREATE INDEX ON webhook_events (status, received_at);

4.2 The complete route handler

import { verifySignature, WebhookError } from "./verify.js";
import { db } from "../db.js";
import { webhookQueue } from "../queues.js";

const SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS
].filter(Boolean);

export async function handleStripeWebhook(req, res) {
  // 1. Verify against raw bytes
  try {
    verifySignature(req.body, req.get("stripe-signature"), SECRETS);
  } catch (err) {
    if (err instanceof WebhookError) {
      return res.status(400).json({ error: err.message });
    }
    throw err;
  }

  // 2. Parse only after the signature is proven valid
  let event;
  try {
    event = JSON.parse(req.body.toString("utf8"));
  } catch {
    return res.status(400).json({ error: "invalid_json" });
  }

  if (!event.id || !event.type) {
    return res.status(400).json({ error: "missing_event_fields" });
  }

  try {
    // 3. Atomic dedupe
    const result = await db.query(
      `INSERT INTO webhook_events (provider, event_id, event_type, payload)
       VALUES ($1, $2, $3, $4)
       ON CONFLICT (provider, event_id) DO NOTHING
       RETURNING id`,
      ["stripe", event.id, event.type, event]
    );

    if (result.rowCount === 0) {
      return res.status(200).json({ received: true, duplicate: true });
    }

    // 4. Hand off to the worker, then acknowledge
    await webhookQueue.add(
      "process-event",
      { rowId: result.rows[0].id },
      {
        jobId: `stripe:${event.id}`, // second safety net against duplicates
        attempts: 8,
        backoff: { type: "custom" },
        removeOnComplete: { count: 1000 },
        removeOnFail: false
      }
    );

    return res.status(202).json({ received: true });
  } catch (err) {
    req.log?.error({ err }, "webhook ingest failed");
    // Storage is down: ask the provider to retry.
    return res.status(503).json({ error: "temporarily_unavailable" });
  }
}

4.3 Business-level idempotency

Ingress dedupe protects you from the same event id twice. It does not protect you from two different events describing the same state change, or from a worker crash between two writes. Write handlers that can run twice safely:

async function onPaymentSucceeded(payload) {
  const intent = payload.data.object;

  // Conditional upsert: writing 'paid' twice changes nothing.
  const { rowCount } = await db.query(
    `INSERT INTO payments (provider_id, order_id, amount, currency, status)
     VALUES ($1, $2, $3, $4, 'paid')
     ON CONFLICT (provider_id) DO UPDATE
       SET status = 'paid', updated_at = now()
     WHERE payments.status IS DISTINCT FROM 'paid'
     RETURNING id`,
    [intent.id, intent.metadata.order_id, intent.amount, intent.currency]
  );

  // Side effects fire only on a real state transition.
  if (rowCount === 1) {
    await sendReceiptEmail(intent.metadata.order_id);
  }
}

Rules of thumb for idempotent handlers:

  • Prefer INSERT ... ON CONFLICT DO UPDATE over blind INSERT.
  • Prefer absolute writes (SET balance = 500) over relative ones (SET balance = balance + 100).
  • Guard every non-transactional side effect (email, SMS, invoice PDF, Slack ping) behind a state transition check or its own dedupe table keyed by event id.
  • Wrap the state change and the status = 'processed' update in the same transaction when the work is database only.

4.4 Out of order delivery

Retries mean subscription.updated can land after subscription.deleted. Defend with a monotonic marker stored on the row:

UPDATE subscriptions
   SET status = $1, last_event_at = to_timestamp($2)
 WHERE provider_id = $3
   AND last_event_at < to_timestamp($2);

Stale events update zero rows and disappear quietly, which is exactly what you want.

Step 5: the retry queue with exponential backoff

The provider retries when you return non-2xx. Your own processing needs its own retry loop, because you already answered 200 and the provider will never come back. BullMQ on Redis is the pragmatic choice for a webhook Node.js stack.

// queues.js
import { Queue } from "bullmq";

export const connection = { host: process.env.REDIS_HOST, port: 6379 };
export const webhookQueue = new Queue("webhooks", { connection });
export const deadLetterQueue = new Queue("webhooks-dlq", { connection });
// worker.js
import { Worker } from "bullmq";
import { connection, deadLetterQueue } from "./queues.js";
import { db } from "./db.js";

const handlers = {
  "payment_intent.succeeded": onPaymentSucceeded,
  "customer.subscription.deleted": onSubscriptionDeleted
};

async function processor(job) {
  const { rows } = await db.query(
    "SELECT * FROM webhook_events WHERE id = $1",
    [job.data.rowId]
  );
  const event = rows[0];
  if (!event || event.status === "processed") return; // already done

  const handler = handlers[event.event_type];
  if (!handler) {
    await markDone(event.id, "ignored");
    return;
  }

  await handler(event.payload);
  await markDone(event.id, "processed");
}

function markDone(id, status) {
  return db.query(
    "UPDATE webhook_events SET status = $1, processed_at = now() WHERE id = $2",
    [status, id]
  );
}

export const worker = new Worker("webhooks", processor, {
  connection,
  concurrency: 10,
  settings: {
    // Exponential backoff with full jitter, capped at 1 hour.
    backoffStrategy: (attemptsMade) => {
      const base = Math.min(2 ** attemptsMade * 1000, 60 * 60 * 1000);
      return Math.floor(base / 2 + Math.random() * (base / 2));
    }
  }
});

Why jitter is not optional

If a downstream API goes down for two minutes, every failed job retries at exactly the same instant when it comes back, and you take it down again. Randomising the delay spreads the load. With the strategy above and attempts: 8:

Attempt Base delay Actual delay (with jitter) Elapsed (approx.)
1 2 s 1 to 2 s 2 s
2 4 s 2 to 4 s 6 s
3 8 s 4 to 8 s 14 s
4 16 s 8 to 16 s 30 s
5 32 s 16 to 32 s 1 min
6 64 s 32 to 64 s 2 min
7 128 s 64 to 128 s 4 min
8 256 s 128 to 256 s 8 min

Also separate retryable from permanent failures. A 429 or a socket timeout from a downstream service deserves a retry. A schema validation error does not, so fail it straight to the dead letter queue with UnrecoverableError from BullMQ.

Step 6: the dead-letter strategy

After the last attempt, the job must not vanish into a log file. Move it to a dead-letter queue, mark the row, and alert.

worker.on("failed", async (job, err) => {
  if (!job) return;

  const maxAttempts = job.opts.attempts ?? 1;
  const isFinal = job.attemptsMade >= maxAttempts;

  await db.query(
    `UPDATE webhook_events
        SET attempts = $1, last_error = $2, status = $3
      WHERE id = $4`,
    [job.attemptsMade, err.message.slice(0, 1000),
     isFinal ? "dead" : "retrying", job.data.rowId]
  );

  if (isFinal) {
    await deadLetterQueue.add("dead-event", {
      rowId: job.data.rowId,
      originalJobId: job.id,
      reason: err.message
    }, { removeOnComplete: false });

    metrics.increment("webhook.dead_letter");
    logger.error({ jobId: job.id, err: err.message }, "webhook dead-lettered");
  }
});

Then expose an internal, authenticated replay endpoint so an operator can push events back through the pipeline after fixing the bug:

app.post("/admin/webhooks/:id/replay", requireAdmin, async (req, res) => {
  const { rows } = await db.query(
    "SELECT id, provider, event_id FROM webhook_events WHERE id = $1",
    [req.params.id]
  );
  if (rows.length === 0) return res.sendStatus(404);

  await db.query(
    "UPDATE webhook_events SET status = 'received', last_error = NULL WHERE id = $1",
    [rows[0].id]
  );

  await webhookQueue.add("process-event", { rowId: rows[0].id }, {
    jobId: `replay:${rows[0].provider}:${rows[0].event_id}:${Date.now()}`,
    attempts: 8,
    backoff: { type: "custom" }
  });

  res.json({ replayed: true });
});

Because your handlers are idempotent, a replay of an event that partially succeeded is safe. That is the payoff for the work in step 4.

Alert on two signals: dead letter count greater than zero, and oldest event with status ‘received’ older than N minutes. The second one catches a stopped worker, which is otherwise completely silent.

webhook code laptop

Sending webhooks from Node.js

If you are on the emitting side, mirror everything above so your consumers can do their job:

import crypto from "node:crypto";

export function buildDelivery(event, secret) {
  const body = JSON.stringify(event);
  const timestamp = Math.floor(Date.now() / 1000);
  const signature = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  return {
    body,
    headers: {
      "content-type": "application/json",
      "user-agent": "BoxSoftware-Webhooks/1.0",
      "webhook-id": event.id,
      "webhook-timestamp": String(timestamp),
      "webhook-signature": `v1=${signature}`
    }
  };
}

export async function deliver(url, event, secret) {
  const { body, headers } = buildDelivery(event, secret);
  const response = await fetch(url, {
    method: "POST",
    headers,
    body,
    signal: AbortSignal.timeout(10_000),
    redirect: "error"
  });

  if (!response.ok) {
    throw new Error(`delivery_failed_${response.status}`);
  }
}

Sender checklist: stable event ids, a short timeout, retries with the same exponential backoff, an endpoint disable rule after repeated failures, per-endpoint secrets with rotation support, and a delivery log your customers can inspect. Never follow redirects and always validate the destination URL to avoid SSRF against your own internal network.

Testing webhooks locally

  • Tunnel: cloudflared tunnel --url http://localhost:3000 or ngrok http 3000 to get a public HTTPS URL.
  • Provider CLI: stripe listen --forward-to localhost:3000/webhooks/stripe then stripe trigger payment_intent.succeeded. The CLI prints a temporary signing secret, use it in your .env.
  • Unit tests: generate signatures in the test itself, no network involved.
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { verifySignature } from "../src/webhooks/verify.js";

const SECRET = "whsec_test";

function sign(payload, timestamp = Math.floor(Date.now() / 1000)) {
  const raw = Buffer.from(JSON.stringify(payload));
  const sig = crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.${raw.toString("utf8")}`)
    .digest("hex");
  return { raw, header: `t=${timestamp},v1=${sig}` };
}

test("accepts a valid signature", () => {
  const { raw, header } = sign({ id: "evt_1", type: "ping" });
  assert.equal(verifySignature(raw, header, [SECRET]), true);
});

test("rejects a tampered body", () => {
  const { header } = sign({ id: "evt_1", type: "ping" });
  const tampered = Buffer.from(JSON.stringify({ id: "evt_1", type: "admin" }));
  assert.throws(() => verifySignature(tampered, header, [SECRET]));
});

test("rejects an expired timestamp", () => {
  const old = Math.floor(Date.now() / 1000) - 3600;
  const { raw, header } = sign({ id: "evt_1", type: "ping" }, old);
  assert.throws(() => verifySignature(raw, header, [SECRET]));
});

Write the tampered-body and expired-timestamp tests before you ship. They are the two cases that silently pass on a broken implementation.

Production checklist

  1. Raw body captured with a size limit, mounted before the JSON parser.
  2. HMAC verified with timingSafeEqual and a timestamp tolerance window.
  3. Multiple secrets accepted so rotation is a config change.
  4. Unique constraint on (provider, event_id), insert with ON CONFLICT DO NOTHING.
  5. Handler returns in milliseconds, all work happens in a worker.
  6. Correct status codes: 400 permanent, 503 transient, 200 for duplicates and unknown types.
  7. Exponential backoff with jitter, capped, with a bounded attempt count.
  8. Dead-letter queue plus an authenticated replay endpoint.
  9. Handlers idempotent and tolerant of out of order events.
  10. Metrics and alerts on dead letters and on ingest-to-process lag.
  11. HTTPS only, optional IP allowlist, endpoint URL that is not guessable.
  12. Never log the raw payload of financial or personal data without redaction.

FAQ

What is a webhook and why is it used?

A webhook is an automated HTTP POST that one system sends to a URL you own when a specific event happens, for example a payment succeeding or a pull request being merged. It is used to replace polling: you get the data within a second of the event instead of discovering it on your next scheduled check, and you stop burning API rate limits on requests that return nothing new.

What is the difference between a webhook and a REST API?

Direction and initiative. With a REST API you make the call and receive a response. With a webhook the provider calls you, unprompted, and your endpoint is the server. They are complementary: most integrations use webhooks for notification and a REST call afterwards to fetch the full, current object.

What is the difference between a webhook and a callback?

A callback is a function reference executed inside your own process. A webhook is a callback over the network between two independent systems, which adds everything a network adds: latency, timeouts, duplicates, reordering and the need for authentication. Related reading: Webhooks server in Node.js.

Are webhooks free to use?

Receiving webhooks costs nothing beyond the hosting for your endpoint, and virtually every SaaS provider includes them in all plans. Sending webhooks at scale is where costs appear, either in engineering time for the delivery infrastructure or in fees if you use a managed service such as Svix or Hookdeck.

Do I really need a queue, or can I just process inline?

You can process inline if the work is a single fast database write and you accept losing the event when it fails. As soon as a handler calls another API, sends an email or takes more than a second, inline processing means timeouts, duplicate deliveries and lost events. The queue is what buys you retries without asking the provider to retry for you.

What if the provider does not send an event id?

Build a deterministic one by hashing the raw body plus the timestamp: crypto.createHash("sha256").update(timestamp + rawBody).digest("hex"). Store that as the dedupe key. It is not perfect for legitimately identical events, but it is far better than no deduplication at all.

Which Node.js version should I run this on?

Node.js 22 LTS or Node.js 24 LTS. Both ship native fetch, AbortSignal.timeout and the built-in test runner used in the examples above, so the whole receiver needs only Express, a database driver and BullMQ.


Building payment, billing or third party integrations and want the plumbing done right the first time? The team at Box Software designs and ships this kind of event-driven Node.js infrastructure for production workloads. Get in touch to talk through your integration.