Receiving Webhooks

Verify Signatures, Be Idempotent, Reply Fast

Receiving Webhooks

Webhooks are someone else's API calling you. Treat them with the same paranoia as user input.

4 min read Level 2/5 #express#webhooks#security
What you'll learn
  • Build a webhook endpoint
  • Verify HMAC signatures
  • Handle duplicates idempotently

A webhook is an HTTP request someone else makes to your server when something happens on their side — Stripe charge succeeded, GitHub PR opened, Twilio message delivered.

Three rules:

  1. Verify the signature — anyone could call your endpoint
  2. Be idempotent — providers retry, duplicates happen
  3. Reply fast — slow webhooks get marked as failed

The Signature Trap

A webhook endpoint that just does app.use(express.json()) first throws away the bytes you need to verify the signature. Use express.raw on webhook routes:

import express from "express";
import crypto from "node:crypto";

app.post("/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    const body = req.body;   // Buffer, not parsed

    const expected = computeStripeSignature(body, process.env.STRIPE_WEBHOOK_SECRET);
    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(400).end();
    }

    const event = JSON.parse(body.toString("utf8"));
    // ... handle event
    res.status(200).end();
  }
);

In real code, use the provider’s SDK (stripe.webhooks.constructEvent) — it handles the signature algorithm, version, replay attacks, etc.

timingSafeEqual

Never compare signatures with ===. The string compare returns faster on a mismatch in the early bytes, which lets attackers brute- force the signature byte-by-byte. timingSafeEqual runs in constant time.

Idempotency

Providers retry on timeouts. The same event arrives multiple times. Track event IDs:

app.post("/webhooks/stripe", express.raw(...), async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], SECRET);

  // dedupe by event id
  const seen = await db.webhookEvents.findById(event.id);
  if (seen) return res.status(200).end();   // already processed

  await db.webhookEvents.create({ id: event.id, type: event.type });

  // process — may be retried, but the `seen` check makes that safe
  await processEvent(event);

  res.status(200).end();
});

Reply Fast — Push Heavy Work To A Queue

Webhooks have aggressive timeouts (5-30s, depending on provider). If you do anything slow, the provider marks it failed and retries.

The right pattern: verify, enqueue, reply 200:

app.post("/webhooks/stripe", express.raw(...), async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], SECRET);

  // queue actual processing
  await webhookQueue.add(event.type, { eventId: event.id });

  res.status(200).end();
});

A worker handles the slow part. The provider gets a fast 200.

Sending Webhooks

The flip side — when you provide webhooks to others:

  • Sign every payload (HMAC)
  • Include a timestamp (prevent replay)
  • Retry on failure (exponential backoff, ~24h)
  • Provide a re-delivery API

That’s its own chapter — for now, focus on receiving them safely.

End of Chapter

Data and beyond-REST done. Next chapter: production — tests, logs, monitoring, deploys.

Testing →