Webhooks with Express.js — Complete Guide
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
- Not preserving the raw request body before JSON Parsing, making signature verification impossible
- Using
crypto.hashtiming-vulnerable comparison instead ofcrypto.timingSafeEqual - Blocking the event loop with synchronous processing of webhook payloads
- Not handling Express error middleware for uncaught webhook processing errors
- Using a single route handler for all event types without routing by event type
- Forgetting to validate the Content-Type header before parsing
- Not setting reasonable timeouts on outgoing requests in the provider
Practice Questions
- Why is
express.json()with theverifyoption preferred over separate body parsing? - How does
crypto.timingSafeEqualprevent timing attacks on signature verification? - What Express mechanism would you use to handle webhook processing errors centrally?
- How would you implement event-type-based routing in Express?
- 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
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