How to handle webhooks in Node.js
Webhooks are HTTP POST requests from external services notifying your application about events — a Stripe payment succeeding, a GitHub push, or a PayPal subscription renewing. As the creator of CoreUI with 25 years of backend development experience, I’ve built webhook handlers for payment processors, version control systems, and communication platforms where reliability and security are critical. The two non-negotiable requirements are: verify the webhook signature before processing, and return 200 immediately then handle the event asynchronously. Failing to verify signatures exposes you to spoofed events; slow synchronous processing risks timeouts and missed retries.
Create a generic webhook handler with signature verification.
// src/webhooks/webhook.handler.js
import crypto from 'crypto'
export function verifyHmacSignature(payload, signature, secret, algorithm = 'sha256') {
const hmac = crypto.createHmac(algorithm, secret)
hmac.update(typeof payload === 'string' ? payload : JSON.stringify(payload))
const digest = `${algorithm}=${hmac.digest('hex')}`
// Use timingSafeEqual to prevent timing attacks
const a = Buffer.from(digest)
const b = Buffer.from(signature)
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
// src/webhooks/github.router.js
import { Router } from 'express'
import { verifyHmacSignature } from './webhook.handler.js'
import { processGitHubEvent } from './github.service.js'
const router = Router()
// Must receive raw body for signature verification
router.post(
'/github',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-hub-signature-256']
const event = req.headers['x-github-event']
if (!signature) {
return res.status(400).json({ error: 'Missing signature' })
}
if (!verifyHmacSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' })
}
// Respond immediately - GitHub expects a quick response
res.status(200).json({ received: true })
// Process asynchronously after responding
const payload = JSON.parse(req.body.toString())
processGitHubEvent(event, payload).catch(console.error)
}
)
export { router as githubWebhookRouter }
crypto.timingSafeEqual prevents timing attacks where an attacker could guess the signature character by character based on response time differences. express.raw() preserves the raw body bytes needed for signature computation.
Generic Webhook Router for Multiple Services
Route events to the appropriate handler.
// src/webhooks/webhook.router.js
import { Router } from 'express'
import Stripe from 'stripe'
import { verifyHmacSignature } from './webhook.handler.js'
const router = Router()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
// Stripe webhook
router.post(
'/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
)
} catch {
return res.status(400).send('Invalid Stripe signature')
}
res.json({ received: true })
await handleStripeEvent(event)
}
)
// Generic HMAC-verified webhook (GitHub, Shopify, etc.)
router.post(
'/:service',
express.raw({ type: 'application/json' }),
async (req, res) => {
const { service } = req.params
const secret = process.env[`${service.toUpperCase()}_WEBHOOK_SECRET`]
const sigHeader = req.headers['x-hub-signature-256'] ?? req.headers['x-signature']
if (!secret || !verifyHmacSignature(req.body, sigHeader, secret)) {
return res.status(401).json({ error: 'Unauthorized' })
}
res.json({ received: true })
const payload = JSON.parse(req.body)
processEvent(service, payload).catch(console.error)
}
)
Dynamically looking up the secret from environment variables (GITHUB_WEBHOOK_SECRET, SHOPIFY_WEBHOOK_SECRET, etc.) keeps the router generic without adding a new route for every service.
Idempotent Event Processing
Prevent duplicate processing when webhooks are delivered more than once.
async function handleStripeEvent(event) {
// Check if we've already processed this event
const existing = await prisma.webhookEvent.findUnique({
where: { eventId: event.id }
})
if (existing) {
console.log(`Event ${event.id} already processed`)
return
}
// Process the event
if (event.type === 'payment_intent.succeeded') {
await fulfillOrder(event.data.object.metadata.orderId)
}
// Record that we processed it
await prisma.webhookEvent.create({
data: { eventId: event.id, type: event.type }
})
}
Most webhook providers guarantee at-least-once delivery — the same event may arrive multiple times. Storing processed event IDs and checking before processing makes your handler idempotent, preventing double-fulfillment or double-charging.
Best Practice Note
This is the webhook handling pattern used in CoreUI backend templates for payment and notification integrations. For high-volume webhooks, queue incoming events with Bull or BullMQ and process them with workers to keep the response time under 5 seconds (most providers retry if they don’t receive a response quickly). See how to integrate Stripe in Node.js for the complete Stripe webhook implementation.



