Skip to content

Webhook with Express — Complete Guide

DodaTech Updated 2026-06-28 7 min read

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

Learn to handle webhooks in Express: signature verification middleware, raw body parsing, async processing, structured logging, error handling, and best practices for Express webhook endpoints.

What You Learn

You will learn how to build robust webhook endpoints in Express: capture raw bodies for signature verification, implement reusable middleware for webhook verification, process events asynchronously, and structure your Express webhook code for maintainability.

Why It Matters

Express is one of the most popular Node.js frameworks. Many applications need webhook endpoints alongside their main API routes. Understanding Express-specific patterns for webhooks ensures your endpoints are secure, reliable, and maintainable.

Real-World Use

DodaTech's API server built with Express handles webhooks from 6 providers. A shared middleware pipeline verifies signatures, checks idempotency, and routes events to handlers. The system processes 15K webhooks daily through Express endpoints.

Raw Body Middleware

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

const app = express();

// Middleware to capture raw body for signature verification
app.use('/webhooks', (req, res, next) => {
    const chunks = [];
    req.on('data', chunk => chunks.push(chunk));
    req.on('end', () => {
        req.rawBody = Buffer.concat(chunks).toString('utf8');

        // Parse JSON if content type is application/json
        if (req.headers['content-type']?.includes('application/json')) {
            try {
                req.body = JSON.parse(req.rawBody);
            } catch (e) {
                return res.status(400).send('Invalid JSON');
            }
        }

        next();
    });
});

Expected output: The middleware captures the raw request body as a string before any parsing. This preserves the exact byte sequence needed for HMAC verification. JSON parsing happens on the raw body to ensure consistency.

Signature Verification Middleware

// Reusable signature verification middleware
function verifyWebhookSignature(options) {
    const { secret, signatureHeader = 'x-webhook-signature' } = options;

    return (req, res, next) => {
        const signature = req.headers[signatureHeader];

        if (!signature) {
            console.error('Missing webhook signature');
            return res.status(401).json({ error: 'Missing signature' });
        }

        if (!req.rawBody) {
            console.error('Missing raw body');
            return res.status(400).json({ error: 'Missing raw body' });
        }

        const expectedSig = signature.replace('sha256=', '').trim();
        const computedSig = crypto
            .createHmac('sha256', secret)
            .update(req.rawBody, 'utf8')
            .digest('hex');

        if (!crypto.timingSafeEqual(
            Buffer.from(computedSig),
            Buffer.from(expectedSig)
        )) {
            console.error('Invalid webhook signature');
            return res.status(401).json({ error: 'Invalid signature' });
        }

        console.log(`Webhook signature verified for ${req.path}`);
        next();
    };
}

Expected output: The middleware extracts the signature from headers, computes HMAC-SHA256 of the raw body, and compares using timing-safe comparison. Invalid signatures return 401 before any processing occurs.

Provider-Specific Middleware

// Stripe webhook verification
const stripeWebhookSecret = 'whsec_stripe_secret_123';

function verifyStripeSignature(req, res, next) {
    const signature = req.headers['stripe-signature'];
    // Stripe uses a different signature format
    // Extract timestamp and signatures from the header
    const parts = signature.split(',').reduce((acc, part) => {
        const [key, value] = part.trim().split('=');
        acc[key] = value;
        return acc;
    }, {});

    const timestamp = parts.t;
    const sigs = parts.v1 ? [parts.v1] : [];

    // Construct the signed payload string
    const signedPayload = `${timestamp}.${req.rawBody}`;

    const expectedSig = crypto
        .createHmac('sha256', stripeWebhookSecret)
        .update(signedPayload)
        .digest('hex');

    if (!sigs.includes(expectedSig)) {
        return res.status(401).json({ error: 'Invalid Stripe signature' });
    }

    req.webhookProvider = 'stripe';
    next();
}

// GitHub webhook verification
const githubWebhookSecret = 'gh_secret_456';

function verifyGithubSignature(req, res, next) {
    const signature = req.headers['x-hub-signature-256'];

    if (!signature) {
        return res.status(401).json({ error: 'Missing GitHub signature' });
    }

    const expectedSig = signature.replace('sha256=', '');
    const computedSig = crypto
        .createHmac('sha256', githubWebhookSecret)
        .update(req.rawBody)
        .digest('hex');

    if (!crypto.timingSafeEqual(
        Buffer.from(computedSig),
        Buffer.from(expectedSig)
    )) {
        return res.status(401).json({ error: 'Invalid GitHub signature' });
    }

    req.webhookProvider = 'github';
    next();
}

Expected output: Each provider has its own signature verification middleware. Stripe uses timestamp-prefixed payloads. GitHub uses x-hub-signature-256 header. Provider-specific middleware encapsulates these differences.

Webhook Router

const express = require('express');

// Reusable idempotency middleware
function checkIdempotency(store) {
    return async (req, res, next) => {
        const webhookId = req.body.id ||
            req.headers['x-webhook-id'] ||
            req.headers['stripe-signature']?.split(',')[0];

        if (!webhookId) {
            return next();
        }

        const processed = await store.isProcessed(webhookId);
        if (processed) {
            console.log(`Duplicate webhook: ${webhookId}`);
            return res.status(200).json({ status: 'duplicate' });
        }

        req.webhookId = webhookId;
        next();
    };
}

// Async processing queue
function asyncProcess(handlers) {
    return (req, res) => {
        const eventType = req.body.type ||
            req.body.event ||
            req.headers['x-github-event'];

        // Acknowledge immediately
        res.status(200).json({ received: true });

        // Process asynchronously
        setImmediate(async () => {
            try {
                const handler = handlers[eventType];
                if (handler) {
                    await handler(req.body, req.webhookProvider);
                    console.log(`Processed ${eventType}`);
                } else {
                    console.log(`No handler for ${eventType}`);
                }
            } catch (err) {
                console.error(`Error processing ${eventType}:`, err);
            }
        });
    };
}

// Event handlers
const eventHandlers = {
    'payment_intent.succeeded': async (body) => {
        console.log(`Payment succeeded: ${body.data.object.id}`);
    },
    'push': async (body, provider) => {
        console.log(`Push to ${body.repository.full_name}`);
    },
};

// Route setup
const webhookRouter = express.Router();

// Stripe webhooks
webhookRouter.post(
    '/stripe',
    verifyStripeSignature,
    checkIdempotency(idempotencyStore),
    asyncProcess(eventHandlers)
);

// GitHub webhooks
webhookRouter.post(
    '/github',
    verifyGithubSignature,
    checkIdempotency(idempotencyStore),
    asyncProcess(eventHandlers)
);

app.use('/webhooks', webhookRouter);

Expected output: The router composes middleware for signature verification, idempotency checking, and async processing. Each provider route has its own verification but shares idempotency and processing logic.

Error Handling

// Webhook-specific error handler
function webhookErrorHandler(err, req, res, next) {
    console.error('Webhook error:', {
        path: req.path,
        method: req.method,
        error: err.message,
        stack: err.stack,
        body: req.rawBody?.slice(0, 500),
        headers: {
            'content-type': req.headers['content-type'],
            'user-agent': req.headers['user-agent'],
        },
    });

    // Don't leak internal error details
    res.status(500).json({
        error: 'Internal server error',
        reference: Date.now().toString(36),
    });
}

// Catch-all for unmatched webhook routes
app.use('/webhooks/*', (req, res) => {
    res.status(404).json({ error: 'Unknown webhook endpoint' });
});

app.use(webhookErrorHandler);

Expected output: Error handler logs webhook errors with context including path, method, and partial body. Returns a generic error message with a reference code for debugging. Unknown webhook routes return 404.

Common Mistakes

1. Using express.json() Without Raw Body Capture

Standard express.json() parses the body but does not preserve the raw string. HMAC needs the raw bytes. Use the verify callback to capture the buffer before parsing.

2. Processing Before Acknowledging

Express handlers that await async operations before responding cause provider timeouts. Acknowledge with 200 immediately. Use setImmediate or a task queue for processing.

3. Mixing Provider Verification in One Handler

Multiple providers require different verification logic. Create separate routes or middleware for each provider. This keeps verification logic isolated and testable.

4. Not Handling Large Payloads

Webhook payloads can be several megabytes. Set appropriate body size limits. Use streaming parsers for large payloads. Reject oversized payloads before processing.

5. No Rate Limiting on Webhook Endpoints

Without rate limiting, a misconfigured provider or attacker can flood your endpoint. Use express-rate-limit. Set appropriate limits per provider endpoint.

Practice Questions

1. Why does Express need special middleware for webhooks?

Express's built-in JSON parser does not preserve the raw body string. Webhook signature verification requires the exact raw bytes. The verify callback in express.json() captures the raw buffer.

2. How do you organize webhook code in an Express app?

Use separate routers per provider. Extract middleware (verification, idempotency, logging) into reusable functions. Keep event handlers in separate modules. Use a single async processing utility.

3. What is the advantage of middleware-based webhook handling?

Middleware is composable, testable, and reusable. You can add logging, metrics, rate limiting, and verification as middleware layers. Each layer has a single responsibility.

4. How do you handle Express webhook timeouts?

Set a higher timeout for webhook routes (30 seconds). Acknowledge immediately and process asynchronously. The timeout only needs to cover verification and idempotency check.

Challenge

Build an Express webhook ingestion system: multiple provider endpoints (Stripe, GitHub, SendGrid) with provider-specific verification middleware, shared idempotency with Redis, async processing with Bull queue, Prometheus metrics middleware, rate limiting per endpoint, and a health check endpoint.

FAQ

Does express.json() work for webhooks?

Yes, but you must use the verify option to capture the raw buffer. Without it, you cannot verify HMAC signatures. The verify callback receives the raw buffer before parsing.

How do I handle Stripe webhooks in Express?

Use stripe.webhooks.constructEvent() which handles signature verification. Provide the raw body and the Stripe signature header. It returns the parsed event or throws on invalid signature.

Should I validate the webhook payload schema?

Yes. Use Joi, Zod, or JSON Schema to validate the payload after signature verification. Schema validation catches provider API changes and malformed payloads early.

How do I test Express webhook endpoints?

Use supertest for integration tests. Generate test payloads with known signatures. Mock the external services. Test signature verification, idempotency, and error handling.

Can I use body-parser instead of express.json?

body-parser is the underlying library for express.json(). Use express.json() with the verify callback. Do not use body-parser directly unless you need specific options not exposed by express.json().

Mini Project: Express Webhook Gateway

Build an Express-based webhook gateway that: accepts webhooks from 3 providers, verifies provider-specific signatures, checks Redis-backed idempotency, enqueues processing jobs to Bull, exposes Prometheus metrics, logs all requests to MongoDB, and provides a dashboard API for delivery statistics.

What's Next

Now that you can handle webhooks in Express, learn Webhook with Django for Python-based webhook consumers.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro