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.
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.type) {
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 type: ${event.type}`);
}
}
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/wajub