How to Handle Webhooks in Express.js
Setting Up a Webhook Endpoint
Express.js is one of the most popular frameworks for handling webhooks in Node.js. A basic webhook endpoint takes just a few lines:
const express = require('express');
const app = express();app.post('/webhooks', express.json(), (req, res) => { const event = req.body; console.log('Webhook received:', event.type);
// Process the event handleEvent(event);
res.status(200).json({ received: true }); });
app.listen(3000, () => { console.log('Server listening on port 3000'); }); ```
The express.json() middleware parses the JSON body. For webhook handlers, you will typically want to customize this to also capture the raw body for signature verification.
Raw Body for Signature Verification
Most webhook providers sign requests using the raw body bytes. If Express parses the body first, you lose the original bytes and cannot verify the signature. Capture the raw body alongside the parsed body:
// Capture raw body for all routes
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));// Or use express.raw() for specific routes app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { // req.body is a Buffer (raw bytes) const sig = req.headers['stripe-signature']; let event;
try {
event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(Webhook Error: ${err.message});
}
handleStripeEvent(event); res.status(200).send('OK'); } ); ```
The verify callback approach works globally but adds overhead to every request. The express.raw() approach is more efficient for specific webhook routes but means the body is a Buffer, not a parsed object — you must parse it yourself after verification.
Handling Different Content Types
Not all webhooks are JSON. Twilio sends form-encoded data, and some providers send XML. Set up different parsers for different routes:
// JSON webhooks (Stripe, GitHub, etc.)
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
handleStripeWebhook
);// Form-encoded webhooks (Twilio, Slack)
app.post('/webhooks/twilio',
express.urlencoded({ extended: true }),
(req, res) => {
const { From, Body, MessageSid } = req.body;
console.log(SMS from ${From}: ${Body});
// Twilio expects TwiML responses
res.type('text/xml');
res.send(
<Response>
<Message>Received your message!</Message>
</Response>
);
}
);
// XML webhooks app.post('/webhooks/legacy', express.text({ type: 'application/xml' }), (req, res) => { const xmlBody = req.body; // string const parsed = parseXML(xmlBody); handleLegacyEvent(parsed); res.status(200).send('OK'); } ); ```
The ReqPour dashboard displays all these formats — JSON with syntax highlighting, form data as key-value pairs, and XML as formatted text — making it easy to debug regardless of the content type.
Async Processing and Error Handling
Webhook providers have strict timeouts (typically 3-10 seconds). If your handler does heavy processing (database writes, API calls, email sends), respond immediately and process asynchronously:
const { Queue } = require('bullmq');
const webhookQueue = new Queue('webhooks');app.post('/webhooks', express.json(), async (req, res) => { // Respond immediately res.status(200).json({ received: true });
// Queue for async processing await webhookQueue.add('process-webhook', { type: req.body.type, data: req.body.data, receivedAt: new Date().toISOString(), }); });
// Process webhooks in a worker const { Worker } = require('bullmq'); new Worker('webhooks', async (job) => { const { type, data } = job.data; switch (type) { case 'payment.completed': await processPayment(data); break; // ... other event types } }); ```
For error handling, use Express error middleware to catch unhandled errors in your webhook routes. Log errors with enough context to debug, but return generic error messages to the caller.
Testing with ReqPour
Set up ReqPour to forward webhooks to your Express server during development:
npx reqpour relay --to http://localhost:3000The relay preserves the full request path, so webhooks sent to https://abc123.reqpour.com/webhooks/stripe arrive at http://localhost:3000/webhooks/stripe on your Express server.
For automated testing, create test helpers that mimic webhook requests:
const request = require('supertest');describe('Webhook handler', () => { it('processes payment.completed events', async () => { const response = await request(app) .post('/webhooks') .set('Content-Type', 'application/json') .send({ type: 'payment.completed', data: { amount: 2500 } });
expect(response.status).toBe(200); // Verify side effects }); }); ```
Use payloads captured from the ReqPour dashboard as test fixtures to ensure your tests use realistic data.
Related
Get started with ReqPour
Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.