Express.js Webhook Handler
Build a robust webhook receiver with Express.js — signature verification, idempotency, retry handling, and event routing.
8 min read
This recipe shows how to build a production-grade webhook endpoint with Express.js. It covers signature verification, idempotent event processing, and proper error handling.
The algorithm, authoritatively
The verifySignature function below implements the HMAC algorithm documented at Webhook
signature verification — that page is the
source of truth if the algorithm ever changes. This recipe just shows it wired into an actual
Express server.
Prerequisites
- A Wajub account with your webhook secret
- Node.js 18+
mkdir wajub-webhooks && cd wajub-webhooks
npm init -y
npm install express1. Create the webhook endpoint
Create server.ts:
import express from 'express';
import crypto from 'crypto';
const app = express();
const WEBHOOK_SECRET = process.env.WAJUB_WEBHOOK_SECRET!;
// Store processed event IDs to ensure idempotency
const processedEvents = new Set<string>();
function verifySignature(rawBody: string, timestamp: string, signatureHeader: string): boolean {
const expected =
'v1=' +
crypto.createHmac('sha256', WEBHOOK_SECRET).update(`${timestamp}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
app.post('/webhooks/wajub', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-wajub-signature'] as string;
const timestamp = req.headers['x-wajub-timestamp'] as string;
const rawBody = req.body.toString('utf8');
// 1. Verify the signature (HMAC-SHA256 over "{timestamp}.{payload}")
if (!signature || !timestamp || !verifySignature(rawBody, timestamp, signature)) {
console.error('Invalid signature');
return res.status(400).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
// 2. Acknowledge immediately — always return 200 fast
res.status(200).json({ received: true });
// 3. Idempotency check
if (processedEvents.has(event.id)) {
console.log(`Event ${event.id} already processed — skipping`);
return;
}
processedEvents.add(event.id);
// 4. Process asynchronously
try {
await handleEvent(event);
} catch (err) {
console.error(`Failed to process event ${event.id}:`, err);
// The event will be retried by Wajub
}
});
async function handleEvent(event: any) {
switch (event.event) {
case 'payment.succeeded':
console.log(`Payment ${event.data.id} succeeded!`);
await fulfillOrder(event.data.id);
break;
case 'payment.failed':
console.log(`Payment ${event.data.id} failed: ${event.data.reason}`);
await notifyCustomer(event.data.id, event.data.reason);
break;
case 'transfer.succeeded':
console.log(`Transfer ${event.data.id} completed`);
await markTransferComplete(event.data.id);
break;
default:
console.log(`Unhandled event: ${event.event}`);
}
}
async function fulfillOrder(id: string) {
// Your order fulfillment logic
}
async function notifyCustomer(id: string, reason: string) {
// Your notification logic
}
async function markTransferComplete(id: string) {
// Your transfer tracking logic
}
// Clean up old event IDs periodically (every hour)
setInterval(() => {
if (processedEvents.size > 10000) {
processedEvents.clear();
}
}, 3600000);
app.listen(3001, () => {
console.log('Webhook server listening on port 3001');
});Key principles
- Verify first — Always validate
X-Wajub-Signature/X-Wajub-Timestampbefore processing - Respond fast — Send
200immediately, then process asynchronously - Be idempotent — Deduplicate on
event.id, not on your order reference - Never throw — Catch errors so your server doesn't crash on malformed events
Testing locally
Use the Wajub CLI to forward webhooks to your local server:
wajub listen --forward-to http://localhost:3001/webhooks/wajubWas this page helpful?