Skip to content

Webhooks with Express.js — Complete Guide

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Webhooks with Express.js. We cover key concepts, practical examples, and best practices to help you master this topic.

Express.js provides a lightweight and flexible foundation for building webhook systems in Node.js. This lesson covers creating both webhook providers and consumers using Express, focusing on middleware patterns, signature validation, and reliable delivery in the Node.js ecosystem.

What You'll Learn

  • Build a webhook consumer endpoint with Express middleware
  • Implement HMAC signature verification in Node.js
  • Create a webhook provider with retry logic
  • Use Express middleware for webhook-specific concerns

Why It Matters

Express is one of the most widely used Node.js frameworks, making it a natural choice for webhook implementations. Understanding webhook patterns in Express helps you integrate with the vast ecosystem of npm packages and build production-ready webhook systems in JavaScript.

Real-World Use

  • GitHub webhook integrations are often built with Express for CI/CD pipelines
  • Slack event subscriptions target Express endpoints for bot applications
  • Payment providers recommend Express sample code for webhook consumers
  • Many open-source webhook proxy tools use Express under the hood

Mermaid Flow

graph LR
    A[Express Server] --> B[Raw Body Middleware]
    B --> C[Signature Verification Middleware]
    C --> D[Event Router]
    D --> E[Handler: Payment Events]
    D --> F[Handler: User Events]
    D --> G[Handler: System Events]
    E --> H[Async Queue / DB]
    F --> H
    G --> H
    H --> I[Return 200 OK]

Teacher's Corner

Emphasize the critical importance of raw body access in Express for signature verification. The default JSON body parser consumes the stream, making signature verification impossible. Show students how to conditionally apply middleware or use the verify option of body-parser. Compare Express's middleware chain to a webhook pipeline.

Code Examples

Example 1: Webhook Consumer with Raw Body Access

const express = require("express");
const crypto = require("crypto");

const app = express();
const WEBHOOK_SECRET = "whsec_your_secret";

app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

function verifySignature(req, res, next) {
  const signature = req.headers["x-signature-256"];
  if (!signature) {
    return res.status(401).json({ error: "missing signature" });
  }
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(req.rawBody)
    .digest("hex");
  try {
    const sig = crypto.timingSafeEqual(
      Buffer.from(signature.replace("sha256=", "")),
      Buffer.from(expected)
    );
  } catch {
    return res.status(401).json({ error: "invalid signature" });
  }
  next();
}

app.post("/webhook", verifySignature, (req, res) => {
  const event = req.body;
  console.log(`Received: ${event.type} [${event.id}]`);
  res.status(200).json({ received: true });
});

app.listen(3000, () => console.log("Webhook consumer on port 3000"));

Expected Output: A valid webhook POST logs the event type and returns 200. An invalid signature returns 401.

Example 2: Webhook Provider with Retry

const express = require("express");
const axios = require("axios");

const app = express();
app.use(express.json());

class WebhookProvider {
  constructor() {
    this.subscribers = [];
  }

  subscribe(url, events) {
    this.subscribers.push({ url, events, retries: 0 });
  }

  async deliver(event) {
    const targets = this.subscribers.filter(s =>
      s.events.includes("*") || s.events.includes(event.type)
    );
    for (const target of targets) {
      await this.sendWithRetry(target, event);
    }
  }

  async sendWithRetry(target, event, attempt = 1) {
    const maxRetries = 5;
    try {
      await axios.post(target.url, event, {
        headers: { "Content-Type": "application/json", "X-Event-ID": event.id },
        timeout: 10000
      });
      console.log(`Delivered ${event.id} to ${target.url}`);
    } catch (err) {
      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000;
        console.log(`Retry ${attempt}/${maxRetries} for ${event.id} in ${delay}ms`);
        setTimeout(() => this.sendWithRetry(target, event, attempt + 1), delay);
      } else {
        console.error(`Failed ${event.id} after ${maxRetries} attempts`);
      }
    }
  }
}

const provider = new WebhookProvider();
provider.subscribe("https://example.com/hooks", ["order.created"]);

app.post("/events", (req, res) => {
  provider.deliver(req.body);
  res.status(202).json({ queued: true });
});

app.listen(3001, () => console.log("Webhook provider on port 3001"));

Expected Output: POST /events with {"id": "evt-1", "type": "order.created"} returns 202 and triggers delivery to the subscriber URL.

Example 3: Express Middleware for Webhook Logging and Rate Limiting

const express = require("express");
const rateLimit = require("express-rate-limit");

const app = express();
app.use(express.json());

const webhookLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  message: { error: "too many webhooks" },
  keyGenerator: (req) => req.ip
});

const auditLogger = (req, res, next) => {
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    method: req.method,
    path: req.path,
    eventId: req.headers["x-event-id"],
    sourceIp: req.ip
  }));
  next();
};

app.post("/webhook", webhookLimiter, auditLogger, (req, res) => {
  res.status(200).json({ ok: true });
});

app.listen(3002, () => console.log("Protected webhook on port 3002"));

Expected Output: Rate-limited requests receive 429. All requests are logged with metadata.

Common Mistakes

  1. Not preserving the raw request body before JSON Parsing, making signature verification impossible
  2. Using crypto.hash timing-vulnerable comparison instead of crypto.timingSafeEqual
  3. Blocking the event loop with synchronous processing of webhook payloads
  4. Not handling Express error middleware for uncaught webhook processing errors
  5. Using a single route handler for all event types without routing by event type
  6. Forgetting to validate the Content-Type header before parsing
  7. Not setting reasonable timeouts on outgoing requests in the provider

Practice Questions

  1. Why is express.json() with the verify option preferred over separate body parsing?
  2. How does crypto.timingSafeEqual prevent timing attacks on signature verification?
  3. What Express mechanism would you use to handle webhook processing errors centrally?
  4. How would you implement event-type-based routing in Express?
  5. Challenge: Build an Express webhook gateway that accepts webhooks, verifies signatures using multiple secrets (keyed by source header), routes events to different handlers by type, logs all events to a file, and returns appropriate status codes.
Answer Key 1. The `verify` option gives access to the raw buffer before JSON parsing, all in one middleware call. Separate parsing loses the raw body. 2. `timingSafeEqual` takes constant time regardless of how many characters match, preventing attackers from inferring the correct signature byte-by-byte. 3. Express error middleware (`app.use((err, req, res, next) => {...})`) catches errors from all route handlers. Log the error and return 500. 4. Use `req.body.type` in a switch statement, or use Express sub-apps mounted at different paths. For complex routing, use a library like `express-routes`. 5. Use a `verify` option in JSON parser to capture raw body, a Map of source-to-secret keyed by `X-Source` header, `crypto.timingSafeEqual` for verification, a router per event type, and `fs.createWriteStream` for logging.

FAQ

Can I use body-parser directly for raw body access?

Yes, but the recommended approach is using express.json({ verify: ... }) which is built into Express 4.16+ and eliminates an extra dependency.

How do I handle large webhook payloads in Express?

Set the limit option on express.json() (default 100kb). For larger payloads, stream the body and process incrementally.

What is the best way to send webhooks from Express?

Use axios or node-fetch for HTTP POST. Queue deliveries with a job queue (Bull, Bee) for better reliability and scalability.

How do I test Express webhook endpoints?

Use SuperTest for integration tests, npx ngrok for local development with real providers, and libraries like nock to mock external HTTP calls.

Should I use TypeScript with Express for webhooks?

TypeScript adds type safety for payload structures and reduces runtime errors. Many production webhook systems use TypeScript with Express.

How do I deploy an Express webhook consumer?

Package the app, deploy to Node.js hosting (Heroku, DigitalOcean, AWS Elastic Beanstalk, Docker). Ensure the server uses a reverse proxy (Nginx) for production.

Mini Project

Build a webhook relay service in Express. Create a server that: (1) accepts webhooks at /incoming/:source with source-specific secret verification, (2) transforms payloads to a normalized format, (3) fans out to registered downstream consumer URLs, (4) implements per-consumer retry with exponential backoff, and (5) exposes /status endpoint showing delivery stats per source and consumer.

What's Next

Learn how to build webhook consumers and providers using Django and Spring Boot.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro