Building a webhook receiver in Node.js requires four things done right: raw body capture, signature verification, immediate acknowledgment, and idempotent processing.
The Key Problem: Raw Body Parsing
Express's express.json() middleware parses the body into an object. But signature verification requires the raw bytes exactly as received. Parsing and re-serializing changes whitespace, making the HMAC differ from what the provider computed.
Setup
npm install expressComplete Receiver
import express from 'express'
import crypto from 'crypto'
const app = express()
// Capture raw body BEFORE json parsing
app.use('/webhooks', express.raw({ type: 'application/json' }))
app.use(express.json())
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET
function verifySignature(rawBody, sigHeader) {
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
const actual = Buffer.from(sigHeader.replace('sha256=', ''), 'hex')
const exp = Buffer.from(expected, 'hex')
if (actual.length !== exp.length) return false
return crypto.timingSafeEqual(actual, exp)
}
const processedEvents = new Set()
app.post('/webhooks/github', (req, res) => {
const sig = req.headers['x-hub-signature-256']
if (!sig || !verifySignature(req.body, sig)) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(req.body.toString())
const eventId = req.headers['x-github-delivery']
res.status(200).json({ received: true })
if (processedEvents.has(eventId)) return
processedEvents.add(eventId)
setImmediate(() => processEvent(event, eventId))
})
async function processEvent(event, eventId) {
console.log('Processing event:', eventId, event.action)
}
app.listen(3000)Stripe-Specific: Timestamp Verification
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
return res.status(400).send('Webhook signature verification failed')
}
res.status(200).json({ received: true })
setImmediate(() => handleStripeEvent(event))
}
)Production Checklist
- Use
express.raw()notexpress.json()for webhook routes - Always use
crypto.timingSafeEqual() - Store processed event IDs in Redis or a DB table
- Return 200 before doing any async work
- Log the raw payload and headers
Use WebhookWhisper during development to get a public URL and inspect every request.