Webhook Design Patterns: Reliability, Security, and Retry Strategies
Build production-ready webhooks with HMAC signature verification, idempotent processing, retry logic with exponential backoff, and dead letter queues. Includes
Webhook Design Patterns: Reliability, Security, and Retry Strategies
Webhooks seem simple โ a third party calls your URL when something happens. But production webhook processing is where naive implementations fall apart. Duplicate deliveries crash idempotent-free handlers. Signature skipping lets attackers forge events. Missing retry logic means lost data when your server is briefly down.
This guide covers the patterns that make webhooks reliable enough for financial transactions, subscription billing, and payment processing โ the places where dropped events have real business consequences.
Webhooks vs Polling: When Each Makes Sense
| Approach | Latency | Server Load | Complexity | Use When |
|---|---|---|---|---|
| Polling | High (interval-bound) | High (wasted requests) | Low | No webhook support from provider |
| Long polling | Low | Medium | Medium | Provider supports it |
| Webhooks | Near-real-time | Low (event-driven) | Medium | Provider pushes events |
| WebSockets | Real-time | Medium | High | Bidirectional, high-frequency |
| Server-Sent Events | Real-time | Low | Low | One-way, browser-to-server not needed |
For third-party integrations (Stripe, GitHub, Shopify, Twilio), webhooks are almost always the right choice. For internal service communication, consider a message queue (SQS, Kafka) instead โ you get replay, backpressure, and better observability.
Pattern 1: Signature Verification (HMAC-SHA256)
Every production webhook must verify the signature before processing. Without this, any attacker who knows your endpoint URL can forge events โ including "payment succeeded" events that trigger fulfillment.
Stripe-style HMAC verification in Node.js:
// webhooks/verifySignature.ts
import crypto from 'crypto';
interface WebhookVerificationResult {
valid: boolean;
timestamp?: number;
error?: string;
}
export function verifyStripeSignature(
rawBody: Buffer,
signatureHeader: string,
webhookSecret: string,
toleranceSeconds = 300 // Reject events older than 5 minutes
): WebhookVerificationResult {
// Signature format: t=timestamp,v1=signature1,v1=signature2
const parts = signatureHeader.split(',');
const timestampPart = parts.find(p => p.startsWith('t='));
const v1Signatures = parts
.filter(p => p.startsWith('v1='))
.map(p => p.slice(3));
if (!timestampPart || v1Signatures.length === 0) {
return { valid: false, error: 'Malformed signature header' };
}
const timestamp = parseInt(timestampPart.slice(2), 10);
const age = Math.floor(Date.now() / 1000) - timestamp;
if (age > toleranceSeconds) {
return { valid: false, error: `Event too old: ${age}s` };
}
// Compute expected signature
const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload, 'utf8')
.digest('hex');
// Constant-time comparison to prevent timing attacks
const isValid = v1Signatures.some(sig => {
try {
return crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex')
);
} catch {
return false;
}
});
return isValid
? { valid: true, timestamp }
: { valid: false, error: 'Signature mismatch' };
}
Critical: You must read the raw body before any JSON parsing. Express's json() middleware destroys the raw body. Use this instead:
// app.ts
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }), // Raw body preserved
async (req, res) => {
const result = verifyStripeSignature(
req.body, // Buffer, not parsed object
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (!result.valid) {
console.warn('Webhook signature invalid:', result.error);
return res.status(400).json({ error: result.error });
}
const event = JSON.parse(req.body.toString());
await processWebhookEvent(event);
res.json({ received: true });
}
);
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Pattern 2: Idempotent Event Processing
Webhook providers guarantee at-least-once delivery โ not exactly-once. Stripe may deliver the same event twice if your server returned a 500 or timed out. Your handler must be safe to call multiple times with the same event.
// webhooks/idempotency.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function processOnce<T>(
eventId: string,
handler: () => Promise<T>
): Promise<{ result: T | null; duplicate: boolean }> {
// Try to claim the event ID
try {
await prisma.processedWebhookEvent.create({
data: {
eventId,
processedAt: new Date(),
},
});
} catch (err: any) {
// Unique constraint violation = already processed
if (err.code === 'P2002') {
console.info(`Duplicate webhook event ${eventId} โ skipping`);
return { result: null, duplicate: true };
}
throw err;
}
// First time seeing this event โ process it
try {
const result = await handler();
return { result, duplicate: false };
} catch (err) {
// Remove the lock so it can be retried
await prisma.processedWebhookEvent.delete({ where: { eventId } });
throw err;
}
}
// Usage
async function handlePaymentSucceeded(event: Stripe.Event) {
await processOnce(event.id, async () => {
const session = event.data.object as Stripe.PaymentIntent;
await prisma.order.update({
where: { stripePaymentIntentId: session.id },
data: { status: 'PAID', paidAt: new Date() },
});
await sendOrderConfirmationEmail(session.metadata.orderId);
});
}
The Prisma schema for the idempotency table:
model ProcessedWebhookEvent {
eventId String @id
processedAt DateTime
@@index([processedAt]) // For cleanup jobs
}
Clean up old records with a nightly job: DELETE FROM processed_webhook_events WHERE processed_at < NOW() - INTERVAL '30 days'.
Pattern 3: Async Processing with a Queue
Never do heavy work synchronously in a webhook handler. Webhook providers have short timeout windows (Stripe: 30s, GitHub: 10s). If your handler takes too long, the provider retries โ triggering duplicate processing.
The correct pattern:
Webhook arrives โ validate signature โ enqueue job โ respond 200
โ
Worker processes job asynchronously
With BullMQ (Redis-backed):
// webhooks/queue.ts
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis(process.env.REDIS_URL!);
export const webhookQueue = new Queue('webhooks', {
connection,
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000, // 1s, 2s, 4s, 8s, 16s
},
removeOnComplete: { age: 86400 }, // Keep 24h of completed jobs
removeOnFail: { count: 100 }, // Keep last 100 failed jobs
},
});
// Webhook handler โ just enqueue
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const result = verifyStripeSignature(req.body, req.headers['stripe-signature'] as string, process.env.STRIPE_WEBHOOK_SECRET!);
if (!result.valid) return res.status(400).json({ error: result.error });
await webhookQueue.add('stripe-event', {
eventId: req.headers['stripe-signature']?.split(',')[0],
payload: JSON.parse(req.body.toString()),
});
res.json({ received: true }); // Respond immediately
});
// Worker โ process asynchronously
const worker = new Worker('webhooks', async (job) => {
const { eventId, payload } = job.data;
switch (payload.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(payload);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(payload);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(payload);
break;
default:
console.info(`Unhandled event type: ${payload.type}`);
}
}, { connection });
worker.on('failed', (job, err) => {
console.error(`Webhook job ${job?.id} failed after ${job?.attemptsMade} attempts:`, err);
});
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Pattern 4: Sending Webhooks (Outbound)
If your platform sends webhooks to customers, you need the same reliability guarantees on the sending side.
// webhooks/sender.ts
interface WebhookDelivery {
id: string;
endpoint: string;
secret: string;
payload: object;
attemptNumber: number;
}
async function deliverWebhook(delivery: WebhookDelivery): Promise<boolean> {
const timestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify(delivery.payload);
const signature = crypto
.createHmac('sha256', delivery.secret)
.update(`${timestamp}.${body}`)
.digest('hex');
try {
const res = await fetch(delivery.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Timestamp': timestamp.toString(),
'X-Webhook-Signature': `v1=${signature}`,
'X-Webhook-Delivery-Id': delivery.id,
'User-Agent': 'YourApp-Webhook/1.0',
},
body,
signal: AbortSignal.timeout(10_000), // 10s timeout
});
await logDeliveryAttempt(delivery.id, res.status, delivery.attemptNumber);
return res.status >= 200 && res.status < 300;
} catch (err) {
await logDeliveryAttempt(delivery.id, 0, delivery.attemptNumber, String(err));
return false;
}
}
// Retry schedule: immediately, +1m, +5m, +30m, +2h, +8h, +24h
const RETRY_DELAYS_MS = [0, 60_000, 300_000, 1_800_000, 7_200_000, 28_800_000, 86_400_000];
Expose a delivery log in your dashboard so customers can see webhook history, retry manually, and debug failures. This alone cuts support tickets significantly.
Security Checklist
| Check | Why It Matters |
|---|---|
| โ HMAC-SHA256 signature verification | Prevents forged events |
| โ Timestamp tolerance (โค5 min) | Prevents replay attacks |
| โ Constant-time comparison | Prevents timing oracle attacks |
| โ Idempotency by event ID | Prevents double-processing |
| โ Raw body for signature | Prevents signature bypass via re-serialization |
| โ Respond 200 before processing | Prevents provider retry storms |
| โ Rotate secrets after exposure | Limits breach window |
| โ Allowlist provider IPs (optional) | Defense in depth (Stripe publishes IPs) |
Implementation Cost
| Scope | Effort | Cost Estimate |
|---|---|---|
| Basic webhook receiver (signature + idempotency) | 8โ16 hours | $1,200โ2,400 |
| Full async processing with BullMQ | 16โ24 hours | $2,400โ3,600 |
| Outbound webhook system with delivery logs | 24โ40 hours | $3,600โ6,000 |
| Full platform (receive + send + dashboard) | 60โ100 hours | $9,000โ15,000 |
Senior backend developer rates: $120โ150/hr (US). Offshore: $40โ70/hr.
Working With Viprasol
We've built webhook infrastructure for payment platforms, e-commerce integrations, and SaaS products handling millions of events per month. Our implementations include signature verification, idempotency, async queuing, retry logic, and delivery dashboards โ built to handle the edge cases that production traffic exposes.
โ Discuss your integration requirements with our backend team.
See Also
- API Security Best Practices โ securing the full API surface
- Event-Driven Architecture โ Kafka and SQS for internal event processing
- Redis Use Cases โ BullMQ, rate limiting, and pub/sub patterns
- Payment Gateway Integration โ Stripe webhook implementation end-to-end
- Web Development Services โ backend API and integration work
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.