Build a Webhook Receiver Service with Express and Signature Verification (Step by Step)
Build a webhook receiver service using Express that verifies HMAC signatures, implements retry logic with exponential backoff, logs events to a database, and provides a dashboard to view incoming webhooks.
What You’ll Build
You’ll build a service that receives webhooks from external services (like Stripe, GitHub, or SendGrid), verifies their authenticity using HMAC signatures, stores them for review, retries failed processing, and displays them in a clean dashboard. This is the same pattern used by DodaTech’s internal event system that processes updates from Durga Antivirus Pro’s signature distribution servers.
Why Webhook Receivers Matter
Webhooks are how modern services talk to each other. When Stripe confirms a payment, GitHub triggers a CI build, or SendGrid reports a delivery, they send a webhook to your server. If your receiver is unreliable — missing signatures, dropping events, or crashing under load — your entire integration pipeline breaks. A robust webhook receiver is the foundation of every event-driven architecture.
Prerequisites
- Node.js and Express.js basics
- SQLite or MongoDB installed
- Basic JWT knowledge (for dashboard login)
Step 1: Project Setup
mkdir webhook-receiver
cd webhook-receiver
npm init -y
npm install express better-sqlite3 crypto dotenv ejs morgan
npm install -D nodemonProject structure:
webhook-receiver/
├── server.js
├── db.js
├── middleware/
│ ├── verifySignature.js
│ └── rateLimit.js
├── routes/
│ ├── webhook.js
│ ├── dashboard.js
│ └── auth.js
├── views/
│ └── dashboard.ejs
└── .envStep 2: Database Setup
// db.js
const Database = require('better-sqlite3');
const path = require('path');
const db = new Database(path.join(__dirname, 'webhooks.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
event TEXT NOT NULL,
payload TEXT NOT NULL,
signature TEXT,
verified INTEGER DEFAULT 0,
status TEXT DEFAULT 'received',
retry_count INTEGER DEFAULT 0,
headers TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME
);
CREATE TABLE IF NOT EXISTS webhook_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER,
action TEXT NOT NULL,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (webhook_id) REFERENCES webhooks(id)
);
CREATE INDEX IF NOT EXISTS idx_webhooks_status ON webhooks(status);
CREATE INDEX IF NOT EXISTS idx_webhooks_created ON webhooks(created_at);
`);
module.exports = db;Step 3: Signature Verification Middleware
Services sign webhooks with a secret key using HMAC-SHA256. The middleware computes the signature and compares it.
// middleware/verifySignature.js
const crypto = require('crypto');
/**
* Verify HMAC-SHA256 signature from the webhook source.
* Expects source to provide the header name via options.
*/
function verifySignature({ secret, headerName = 'x-signature-256', encoding = 'sha256' } = {}) {
return (req, res, next) => {
const signature = req.headers[headerName.toLowerCase()];
if (!signature) {
return res.status(401).json({ error: 'Missing signature header' });
}
const rawBody = req.body; // Must be raw/buffer body for verification
const computed = crypto
.createHmac(encoding, secret)
.update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody))
.digest('hex');
// Timing-safe comparison prevents timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
);
if (!isValid) {
req.webhookVerified = false;
return res.status(403).json({ error: 'Invalid signature' });
}
req.webhookVerified = true;
next();
};
}
module.exports = verifySignature;Expected output: If the signature header is missing, returns 401. If the signature doesn’t match (tampered payload), returns 403. If valid, sets req.webhookVerified = true and proceeds.
Step 4: Webhook Receiver Route
// routes/webhook.js
const express = require('express');
const router = express.Router();
const db = require('../db');
const verifySignature = require('../middleware/verifySignature');
// Stripe-style webhook endpoint
router.post('/stripe',
express.raw({ type: 'application/json' }),
verifySignature({
secret: process.env.STRIPE_WEBHOOK_SECRET || 'whsec_test',
headerName: 'stripe-signature',
}),
handleWebhook('stripe')
);
// GitHub-style webhook endpoint
router.post('/github',
express.json(),
verifySignature({
secret: process.env.GITHUB_WEBHOOK_SECRET || 'ghp_test',
headerName: 'x-hub-signature-256',
}),
handleWebhook('github')
);
// Generic webhook endpoint
router.post('/generic/:source',
express.json(),
handleWebhook('generic')
);
function handleWebhook(source) {
return (req, res) => {
const event = req.headers['x-event-name'] || req.headers['x-github-event'] || 'unknown';
const payload = typeof req.body === 'object' ? JSON.stringify(req.body) : req.body.toString();
const headers = JSON.stringify(req.headers);
const stmt = db.prepare(`
INSERT INTO webhooks (source, event, payload, signature, verified, headers, status)
VALUES (?, ?, ?, ?, ?, ?, 'received')
`);
const result = stmt.run(
source,
event,
payload,
req.headers['stripe-signature'] || req.headers['x-hub-signature-256'] || null,
req.webhookVerified ? 1 : 0,
headers
);
// Log the reception
db.prepare(`
INSERT INTO webhook_logs (webhook_id, action, message)
VALUES (?, 'received', 'Webhook received from ' || ?)
`).run(result.lastInsertRowid, source);
// Acknowledge immediately (webhook senders expect fast 200)
res.status(200).json({ received: true, id: result.lastInsertRowid });
// Process asynchronously
processWebhookAsync(result.lastInsertRowid, source, event, payload);
};
}
async function processWebhookAsync(id, source, event, payload) {
try {
// Simulate processing — replace with your business logic
console.log(`Processing webhook ${id}: ${source}/${event}`);
// Update status
db.prepare(`UPDATE webhooks SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?`).run(id);
db.prepare(`INSERT INTO webhook_logs (webhook_id, action, message) VALUES (?, 'completed', 'Processed successfully')`).run(id);
} catch (err) {
handleProcessingError(id, err);
}
}Step 5: Retry Logic with Exponential Backoff
// Retry failed webhooks with exponential backoff
const MAX_RETRIES = 5;
const BASE_DELAY = 1000; // 1 second
async function handleProcessingError(id, error) {
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id);
if (!webhook) return;
const retryCount = webhook.retry_count + 1;
if (retryCount > MAX_RETRIES) {
db.prepare(`UPDATE webhooks SET status = 'failed', retry_count = ? WHERE id = ?`).run(retryCount, id);
db.prepare(`INSERT INTO webhook_logs (webhook_id, action, message) VALUES (?, 'failed', ?)`)
.run(id, `Failed after ${MAX_RETRIES} retries: ${error.message}`);
return;
}
const delay = BASE_DELAY * Math.pow(2, retryCount - 1); // 1s, 2s, 4s, 8s, 16s
db.prepare(`UPDATE webhooks SET status = 'retrying', retry_count = ? WHERE id = ?`).run(retryCount, id);
db.prepare(`INSERT INTO webhook_logs (webhook_id, action, message) VALUES (?, 'retry', ?)`)
.run(id, `Retry ${retryCount}/${MAX_RETRIES} in ${delay}ms`);
setTimeout(() => {
processWebhookAsync(id, webhook.source, webhook.event, webhook.payload);
}, delay);
}Expected output: Failed webhooks retry at 1s, 2s, 4s, 8s, 16s intervals. After 5 retries, status changes to failed. Each retry is logged.
Step 6: Server Entry Point
// server.js
require('dotenv').config();
const express = require('express');
const morgan = require('morgan');
const path = require('path');
const app = express();
app.use(morgan('combined'));
// Raw body parsing for signature verification endpoints
app.use('/api/webhooks/stripe', express.raw({ type: '*/*' }));
// Standard JSON parsing for everything else
app.use(express.json());
// Routes
app.use('/api/webhooks', require('./routes/webhook'));
app.use('/dashboard', require('./routes/dashboard'));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Webhook receiver on port ${PORT}`));Architecture
sequenceDiagram
participant Sender as Webhook Sender (Stripe/GitHub)
participant Server as Express Server
participant DB as SQLite Database
participant Worker as Async Processor
Sender->>Server: POST /api/webhooks/stripe
Note over Sender,Server: Payload + HMAC signature header
Server->>Server: Verify signature (timing-safe compare)
alt Invalid signature
Server-->>Sender: 403 Invalid signature
else Valid
Server->>DB: Store webhook event
Server-->>Sender: 200 { received: true }
Server->>Worker: Process asynchronously
alt Process succeeds
Worker->>DB: Update status → completed
else Process fails
Worker->>DB: Update status → retrying
Note over Worker: Exponential backoff: 1s, 2s, 4s...
Worker->>DB: Update status → failed (after 5 retries)
end
end
Common Errors
1. Signature verification fails on every request
The most common cause: the raw body is not preserved. When Express parses JSON, it consumes the request stream. The signature middleware receives an object instead of raw bytes. The Stripe endpoint uses express.raw({ type: 'application/json' }) to preserve the raw buffer.
2. Timing-safe comparison seems slower than ===
That’s intentional. Regular string comparison (===) short-circuits on the first differing byte — a timing attacker can measure response times to guess the correct signature byte by byte. crypto.timingSafeEqual always compares every byte, preventing this.
3. Webhooks received but never processed
If the async processor throws synchronously, the error is unhandled. Wrap the entire processWebhookAsync body in a try/catch. The catch calls handleProcessingError which triggers retry logic.
4. Dashboard shows “failed” for webhooks that actually succeeded The async processor updates the status after processing. If the processor crashes before updating, the status stays “received”. Add a heartbeat mechanism or a cleanup cron job that re-checks stuck webhooks.
5. Database locked errors under high load
SQLite does not handle concurrent writes well. For production webhook volumes (1000+/minute), switch to PostgreSQL or use SQLite with WAL mode: db.pragma('journal_mode = WAL').
Practice Questions
1. Why do webhook receivers need to acknowledge with 200 immediately? Webhook senders (Stripe, GitHub) have strict timeouts — typically 5-10 seconds. If you don’t respond in time, they retry. The 200 acknowledgment tells them “we got it, stop sending,” even if processing hasn’t finished. Process asynchronously to avoid timeout.
2. What happens if the server crashes mid-processing?
The webhook is stored with status = 'received' but the async function never runs. On startup, a recovery check should find received or retrying webhooks older than a threshold and re-process them.
3. How does exponential backoff prevent cascading failures? If your processing service is overloaded, immediate retries would make it worse. Exponential backoff (1s → 2s → 4s → 8s → 16s) gives the system time to recover. Random jitter (±50%) prevents thundering herd when multiple webhooks retry simultaneously.
4. Challenge: Webhook replay system
Add an endpoint POST /api/webhooks/:id/replay that re-sends a stored webhook through the processing pipeline. Add a button in the dashboard to trigger replay. This is essential for debugging integration issues.
5. Challenge: Dead letter queue After MAX_RETRIES, instead of marking as failed, move the webhook to a “dead letter” table. Add a cron job that alerts an admin daily with dead letter counts. Build a dashboard view to inspect and manually replay dead letters.
FAQ
Next Steps
- Add webhook authentication with API keys and OAuth
- Explore Async processing with background job queues
- Deploy with Docker and Docker Compose
- Check the REST API project for more Express patterns
- Try the Real-Time Dashboard project for live webhook monitoring
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro