Back to Blog
Hook Mesh Team

Building Webhooks with Express.js: Best Practices

Learn how to build production-ready webhooks with Express.js. Complete guide covering sending webhooks, receiving webhooks, raw body parsing, signature verification middleware, error handling, and queue integration.

Building Webhooks with Express.js: Best Practices

Building Webhooks with Express.js: Best Practices

Express.js provides flexibility for webhooks, but production reliability requires correct patterns: body parsing, response timing, and error handling. This guide covers sending and receiving webhooks. For framework variations, see webhooks with Next.js and serverless patterns.

Sending Webhooks from Express Routes

Basic Webhook Dispatch

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

async function sendWebhook(endpoint, secret, eventType, payload) {
  const timestamp = Math.floor(Date.now() / 1000);
  const body = JSON.stringify({
    id: `evt_${crypto.randomUUID()}`,
    type: eventType,
    created_at: new Date().toISOString(),
    data: payload
  });

  const signature = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  return axios.post(endpoint, body, {
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Signature': `t=${timestamp},v1=${signature}`
    },
    timeout: 30000
  });
}

Triggering Webhooks from Routes

app.post('/api/orders', async (req, res) => {
  try {
    const order = await createOrder(req.body);

    // Respond immediately
    res.status(201).json(order);

    // Then dispatch webhooks (fire and forget for now)
    const subscriptions = await getWebhookSubscriptions(req.user.id, 'order.created');
    for (const sub of subscriptions) {
      sendWebhook(sub.url, sub.secret, 'order.created', order)
        .catch(err => console.error('Webhook delivery failed:', err.message));
    }
  } catch (error) {
    res.status(500).json({ error: 'Failed to create order' });
  }
});

This works but production systems need retry logic, delivery tracking, and queue-based processing. See webhook implementation guides.

Receiving Webhooks: The Raw Body Requirement

The most common mistake is body parsing. Express's default express.json() discards the original bytes needed for signature verification. See verifying webhooks in Node.js for detailed patterns:

Using express.raw() for Webhook Routes

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

// Regular JSON parsing for most routes
app.use(express.json());

// Raw body parsing specifically for webhooks
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer containing raw bytes
  const signature = req.headers['stripe-signature'];

  // Verify and process
});

Conditional Body Parsing

For cleaner architecture, use conditional middleware:

app.use((req, res, next) => {
  if (req.path.startsWith('/webhooks/')) {
    express.raw({ type: 'application/json' })(req, res, next);
  } else {
    express.json()(req, res, next);
  }
});

Signature Verification Middleware

Create reusable middleware instead of verifying in every handler. See webhook security best practices:

const crypto = require('crypto');

function createWebhookVerifier(getSecret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];

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

    try {
      // Parse signature components
      const elements = Object.fromEntries(
        signature.split(',').map(part => part.split('='))
      );
      const timestamp = elements.t;
      const receivedSig = elements.v1;

      // Validate timestamp (5-minute tolerance)
      const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
      if (age > 300) {
        return res.status(401).json({ error: 'Request timestamp too old' });
      }

      // Get secret (may vary by source)
      const secret = await getSecret(req);

      // Compute expected signature
      const payload = req.body.toString();
      const expectedSig = crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.${payload}`)
        .digest('hex');

      // Timing-safe comparison
      const isValid = crypto.timingSafeEqual(
        Buffer.from(receivedSig, 'hex'),
        Buffer.from(expectedSig, 'hex')
      );

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

      // Parse body for downstream handlers
      req.webhookPayload = JSON.parse(payload);
      req.webhookTimestamp = timestamp;
      next();
    } catch (error) {
      console.error('Webhook verification error:', error);
      res.status(401).json({ error: 'Signature verification failed' });
    }
  };
}

Applying the Middleware

const verifyStripeWebhook = createWebhookVerifier(
  () => process.env.STRIPE_WEBHOOK_SECRET
);

const verifyCustomerWebhook = createWebhookVerifier(
  async (req) => {
    const source = req.headers['x-webhook-source'];
    const config = await getWebhookConfig(source);
    return config.secret;
  }
);

app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  verifyStripeWebhook,
  handleStripeWebhook
);

app.post('/webhooks/incoming',
  express.raw({ type: 'application/json' }),
  verifyCustomerWebhook,
  handleIncomingWebhook
);

Error Handling and Response Timing

Webhook providers expect responses within 30 seconds. Pattern: acknowledge immediately, process asynchronously.

Respond First, Process Later

app.post('/webhooks/payments',
  express.raw({ type: 'application/json' }),
  verifyWebhook,
  async (req, res) => {
    const event = req.webhookPayload;

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

    // Process asynchronously
    try {
      await processPaymentEvent(event);
    } catch (error) {
      // Log for manual investigation - don't affect response
      console.error('Failed to process webhook:', event.id, error);
      await logFailedWebhook(event, error);
    }
  }
);

Comprehensive Error Handling

app.post('/webhooks/orders',
  express.raw({ type: 'application/json' }),
  verifyWebhook,
  async (req, res) => {
    const event = req.webhookPayload;

    try {
      // Quick validation only
      if (!event.type || !event.data) {
        return res.status(400).json({ error: 'Invalid payload structure' });
      }

      // Respond with 200 before heavy processing
      res.status(200).json({ received: true, id: event.id });

      // Queue for processing
      await webhookQueue.add('process-order-event', {
        eventId: event.id,
        eventType: event.type,
        payload: event.data,
        receivedAt: new Date().toISOString()
      });
    } catch (error) {
      // If we haven't responded yet, send error
      if (!res.headersSent) {
        res.status(500).json({ error: 'Internal error' });
      }
      console.error('Webhook handling error:', error);
    }
  }
);

Queue Integration for Reliable Processing

Queue-based processing decouples receipt from processing, enables retries, and prevents timeouts:

Bull Queue Integration

const Queue = require('bull');

const webhookQueue = new Queue('webhooks', process.env.REDIS_URL);

// Producer: your webhook endpoint
app.post('/webhooks/events',
  express.raw({ type: 'application/json' }),
  verifyWebhook,
  async (req, res) => {
    const event = req.webhookPayload;

    await webhookQueue.add('incoming-webhook', {
      id: event.id,
      type: event.type,
      data: event.data,
      headers: {
        signature: req.headers['x-webhook-signature'],
        source: req.headers['x-webhook-source']
      }
    }, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 5000 },
      removeOnComplete: 100
    });

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

// Consumer: separate process or worker
webhookQueue.process('incoming-webhook', async (job) => {
  const { id, type, data } = job.data;

  switch (type) {
    case 'payment.completed':
      await handlePaymentCompleted(data);
      break;
    case 'subscription.cancelled':
      await handleSubscriptionCancelled(data);
      break;
    default:
      console.log(`Unhandled webhook type: ${type}`);
  }
});

Idempotency: Handling Duplicate Webhooks

Handlers must be idempotent—same webhook twice = same effect as once. See webhook idempotency guide for comprehensive strategies:

const processedWebhooks = new Map(); // Use Redis in production

async function idempotentHandler(eventId, handler) {
  // Check if already processed
  const existing = await redis.get(`webhook:processed:${eventId}`);
  if (existing) {
    console.log(`Duplicate webhook ignored: ${eventId}`);
    return { duplicate: true };
  }

  // Process the webhook
  const result = await handler();

  // Mark as processed with 24-hour TTL
  await redis.setex(`webhook:processed:${eventId}`, 86400, JSON.stringify({
    processedAt: new Date().toISOString(),
    result: 'success'
  }));

  return result;
}

// Usage in handler
app.post('/webhooks/payments', verifyWebhook, async (req, res) => {
  const event = req.webhookPayload;
  res.status(200).json({ received: true });

  await idempotentHandler(event.id, async () => {
    await updateOrderStatus(event.data.orderId, 'paid');
    await sendConfirmationEmail(event.data.customerEmail);
  });
});

Logging for Debugging

function webhookLogger(req, res, next) {
  const startTime = Date.now();
  const eventId = req.webhookPayload?.id || 'unknown';

  console.log(JSON.stringify({
    level: 'info',
    type: 'webhook_received',
    eventId,
    eventType: req.webhookPayload?.type,
    source: req.headers['x-webhook-source'],
    timestamp: new Date().toISOString()
  }));

  res.on('finish', () => {
    console.log(JSON.stringify({
      level: res.statusCode >= 400 ? 'error' : 'info',
      type: 'webhook_response',
      eventId,
      statusCode: res.statusCode,
      durationMs: Date.now() - startTime
    }));
  });

  next();
}

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

Integrating with Hook Mesh

Hook Mesh simplifies sending and receiving. Push events to Hook Mesh for delivery and retries, or use our SDK for receiving:

Sending Webhooks with Hook Mesh

const { HookMesh } = require('@hookmesh/sdk');

const hookMesh = new HookMesh({ apiKey: process.env.HOOKMESH_API_KEY });

app.post('/api/orders', async (req, res) => {
  const order = await createOrder(req.body);
  res.status(201).json(order);

  // Hook Mesh handles delivery, retries, and logging
  await hookMesh.events.send({
    type: 'order.created',
    data: order,
    customerId: req.user.organizationId
  });
});

Receiving with Hook Mesh Verification

const { verifyWebhook } = require('@hookmesh/sdk');

app.post('/webhooks/incoming',
  express.raw({ type: 'application/json' }),
  (req, res, next) => {
    try {
      req.webhookPayload = verifyWebhook(
        req.body,
        req.headers['x-hookmesh-signature'],
        process.env.HOOKMESH_WEBHOOK_SECRET
      );
      next();
    } catch (error) {
      res.status(401).json({ error: 'Verification failed' });
    }
  },
  async (req, res) => {
    res.status(200).json({ received: true });
    await processWebhook(req.webhookPayload);
  }
);

Summary

Key details for production: raw body parsing, fast responses with async processing, queue integration, and idempotency. Hook Mesh handles delivery reliability, retries, and signature management, letting you focus on product logic. Understanding these patterns ensures robust, secure integrations.

Related Posts