Webhook Architecture & Best Practices — Delivery, Retries, Security
In this tutorial, you'll learn about Webhook Architecture & Best Practices. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Webhook architecture is an event-driven communication pattern where a provider sends HTTP POST requests to a consumer's registered callback URL when specific events occur, enabling real-time notifications without polling.
What You'll Learn
By the end of this tutorial, you will design a production Webhook system with at-least-once delivery guarantees, exponential backoff retries, HMAC-SHA256 signature verification, idempotency handling, and consumer health monitoring.
Why It Matters
Webhooks power real-time integrations across the internet — payment notifications from Stripe, push events from GitHub, and delivery status from Twilio. Doda Browser receives real-time malware signature updates through Webhooks from threat intelligence partners, and Durga Antivirus Pro sends Webhook alerts to enterprise SIEM systems.
Real-World Use
An e-commerce platform registers a Webhook endpoint with its payment provider. When a payment succeeds, the provider POSTs the payment event to the endpoint. The platform updates the order status, sends a confirmation email, and triggers inventory updates — all without the platform polling for payment status.
Delivery Guarantees
flowchart LR
P[Provider] -->|"Event: POST payload"| Q[Delivery Queue]
Q -->|"Attempt 1 (0s)"| C[Consumer]
Q -->|"Attempt 2 (60s)"| C
Q -->|"Attempt 3 (300s)"| C
Q -->|"Attempt 4 (3600s)"| C
Q -->|"Max retries exceeded"| DL[Dead Letter Queue]
style P fill:#f90,color:#fff
style DL fill:#e74c3c,color:#fff
Building a Webhook Consumer with Node.js
// webhook-consumer.js
// Robust webhook consumer with signature verification and idempotency
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'dev-secret-change-me';
// Store processed webhook IDs (use Redis in production)
const processedIds = new Set();
app.post('/webhook', express.text({ type: 'application/json' }), (req, res) => {
const payload = req.body;
const signature = req.headers['x-webhook-signature'];
const webhookId = req.headers['x-webhook-id'];
const timestamp = req.headers['x-webhook-timestamp'];
console.log(`[Webhook] Received: ${webhookId} at ${new Date().toISOString()}`);
// 1. Verify signature
if (!signature) {
console.error('[Webhook] Missing signature header');
return res.status(401).json({ error: 'Missing signature' });
}
const expectedSig = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
try {
const receivedSigs = signature.split(' ').map(s => s.trim());
const isValid = receivedSigs.some(s =>
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expectedSig))
);
if (!isValid) {
console.error('[Webhook] Invalid signature');
return res.status(401).json({ error: 'Invalid signature' });
}
} catch (err) {
return res.status(401).json({ error: 'Signature verification failed' });
}
// 2. Check timestamp freshness (prevent replay attacks)
const eventTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - eventTime) > 300) {
console.error('[Webhook] Stale timestamp — possible replay attack');
return res.status(403).json({ error: 'Stale timestamp' });
}
// 3. Idempotency check
if (processedIds.has(webhookId)) {
console.log(`[Webhook] Duplicate ${webhookId} — already processed`);
return res.status(200).json({ status: 'already_processed' });
}
processedIds.add(webhookId);
const data = JSON.parse(payload);
// 4. Process event asynchronously
setImmediate(() => {
processWebhookEvent(webhookId, data);
});
// Acknowledge immediately
res.status(200).json({ status: 'ok' });
});
function processWebhookEvent(id, data) {
const eventType = data.event || 'unknown';
console.log(`[Webhook] Processing ${id}: ${eventType}`);
switch (eventType) {
case 'payment.succeeded':
console.log(` -> Updating order ${data.order_id} to paid`);
break;
case 'user.created':
console.log(` -> Sending welcome email to ${data.email}`);
break;
default:
console.log(` -> Unhandled event type: ${eventType}`);
}
}
app.listen(8080, () => console.log('Webhook consumer on :8080'));
Expected behavior: The consumer verifies each Webhook's HMAC-SHA256 signature, checks the timestamp is within 5 minutes (preventing replay attacks), and deduplicates by Webhook ID. It acknowledges immediately (200 OK) and processes asynchronously, preventing provider timeouts.
Retry Strategy with Exponential Backoff
# webhook_retry.py
# Webhook delivery system with exponential backoff
import time
import hashlib
import hmac
import json
import requests
from datetime import datetime, timedelta
class WebhookDelivery:
"""Webhook delivery system with exponential backoff retries."""
def __init__(self, secret, max_retries=5, base_delay=60):
self.secret = secret
self.max_retries = max_retries
self.base_delay = base_delay
self.delivery_log = []
def calculate_delay(self, attempt):
"""Calculate delay with exponential backoff and jitter."""
delay = self.base_delay * (2 ** attempt)
import random
jitter = random.uniform(0, 0.1 * delay)
return min(delay + jitter, 86400) # Max 24 hours
def sign_payload(self, payload, timestamp):
"""Sign payload with HMAC-SHA256."""
message = f"{timestamp}.{payload}".encode()
return hmac.new(
self.secret.encode(),
message,
hashlib.sha256
).hexdigest()
def deliver(self, url, event_data):
"""Deliver webhook with retry logic. Returns delivery status."""
payload = json.dumps(event_data)
webhook_id = hashlib.md5(
f"{event_data['event']}{time.time()}".encode()
).hexdigest()[:16]
for attempt in range(self.max_retries):
timestamp = str(int(time.time()))
signature = self.sign_payload(payload, timestamp)
headers = {
'Content-Type': 'application/json',
'X-Webhook-ID': webhook_id,
'X-Webhook-Signature': signature,
'X-Webhook-Timestamp': timestamp,
}
try:
response = requests.post(
url,
data=payload,
headers=headers,
timeout=10
)
delivery = {
'webhook_id': webhook_id,
'attempt': attempt + 1,
'status_code': response.status_code,
'timestamp': datetime.now().isoformat(),
}
self.delivery_log.append(delivery)
if response.status_code == 200:
print(f"[OK] Attempt {attempt + 1}: Delivered {webhook_id}")
return True
print(f"[RETRY] Attempt {attempt + 1}: Got {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"[ERROR] Attempt {attempt + 1}: {e}")
# Wait before next retry
if attempt < self.max_retries - 1:
delay = self.calculate_delay(attempt)
print(f" Waiting {delay:.0f}s before retry...")
time.sleep(delay)
print(f"[FAILED] All {self.max_retries} attempts exhausted for {webhook_id}")
return False
delivery = WebhookDelivery(secret='whsec_abc123', max_retries=3, base_delay=5)
result = delivery.deliver(
'https://httpbin.org/post',
{'event': 'order.shipped', 'order_id': 'ORD-1234', 'status': 'shipped'}
)
print(f"\nFinal result: {'Delivered' if result else 'Failed'}")
Expected output:
[OK] Attempt 1: Delivered abc123...
Final result: Delivered
The system retries with delays of 5s, 10s, 20s (attempts 2, 3). If all 3 attempts fail, it logs the failure. The jitter prevents thundering herd when multiple Webhooks are retried simultaneously.
Security Best Practices
// Webhook-security.js
// Webhook security: signature verification, IP allowlisting, Rate Limiting
const Express = require('Express');
const crypto = require('crypto');
const app = Express();
const CONFIG = {
secret: Process.env.Webhook_SECRET || 'whsec_...',
allowedIps: Process.env.ALLOWED_IPS
? new Set(Process.env.ALLOWED_IPS.split(','))
: new Set(['52.0.0.0/8', '54.0.0.0/8']), // Stripe's IP ranges
maxPayloadBytes: 1024 * 100, // 100KB max
toleranceSeconds: 300,
};
function verifyHmac(payload, sigHeader, secret) {
const parts = {};
sigHeader.split(',').forEach(pair => {
const [k, v] = pair.split('=');
parts[k] = v;
});
const timestamp = parts.t;
const sigs = parts.v1 ? parts.v1.split(' ') : [];
// Check for timing-safe equality
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
return sigs.some(sig => {
try {
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch {
return false;
}
});
}
app.post('/Webhook', Express.text({ limit: '100kb', type: '*/*' }), (req, res) => {
const payload = req.body;
const sigHeader = req.headers['Stripe-signature'];
const webhookId = req.headers['Webhook-id'];
// Verify payload size
if (Buffer.byteLength(payload, 'utf8') > CONFIG.maxPayloadBytes) {
return res.status(413).JSON({ error: 'Payload too large' });
}
// Verify signature
if (!verifyHmac(payload, sigHeader, CONFIG.secret)) {
return res.status(401).JSON({ error: 'Invalid signature' });
}
console.log(`[Secure Webhook] Verified and accepted: ${webhookId}`);
res.status(200).JSON({ received: true });
});
app.listen(8081);
Expected behavior: The secure consumer verifies HMAC signatures using timing-safe comparison, enforces payload size limits, and checks timestamp freshness. IP allowlisting adds a defense-in-depth layer but should never replace signature verification.
Common Errors
1. Not Verifying Webhook Signatures
Any client can POST to your Webhook URL. Without signature verification, attackers can trigger fake events — processing phantom payments, creating fake user accounts, or triggering false alerts. Always verify HMAC signatures using cryptographic comparison (not string equality).
2. Processing Webhooks Synchronously
If your Webhook handler takes longer than the provider's timeout (typically 5-10 seconds), the provider retries. This creates duplicate processing. Always acknowledge immediately (200 OK) and Process asynchronously using a task Queue.
3. Missing Idempotency Handling
Webhooks are delivered with at-least-once guarantees. Network failures and provider retries mean the same event can arrive multiple times. Without idempotency, you might charge a customer twice, send duplicate emails, or create duplicate records. Use Webhook ID as an idempotency key.
4. Ignoring Timestamp Verification
Without timestamp checks, an attacker can capture a valid Webhook payload and replay it hours later. Check that the timestamp is within a tolerance window (typically 5 minutes). Combined with signature verification, this prevents replay attacks.
5. Not Handling Consumer Downtime Gracefully
When your consumer is down, Webhooks are queued and retried. When it comes back up, it receives a burst. Without proper Queue management and Rate Limiting, the consumer is overwhelmed and crashes again. Implement graceful startup and consumer-side Rate Limiting.
6. Logging Sensitive Payload Data
Webhook payloads often contain PII, payment details, or API keys. Logging the full payload to plain-text files or sending it to error tracking services exposes sensitive data. Log only Webhook IDs, event types, and processing status — never full payloads.
Practice Questions
1. Why is HMAC-SHA256 preferred over simple API keys for Webhook verification?
HMAC-SHA256 proves the payload was sent by someone who knows the shared secret AND was not modified in transit. A simple API key in a header authenticates the sender but does not verify payload integrity.
2. What is exponential backoff and why is it used for Webhook retries?
Exponential backoff doubles the delay between each retry attempt (60s, 120s, 240s). It prevents retry storms when many consumers are down simultaneously and gives the consumer time to recover before the next attempt.
3. How do you prevent replay attacks on Webhooks?
Include a timestamp in the signed payload and reject Webhooks with timestamps outside a tolerance window (typically 5 minutes). Store recently seen Webhook IDs to detect exact replays within the window.
4. What status code should a Webhook consumer return?
Return 200 OK immediately after receiving and verifying the Webhook. Return non-2xx status only when you want the provider to retry (rate limited, internal error). Never return non-200 for business logic failures — acknowledge and Process asynchronously.
Challenge
Design a Webhook system where: (1) the provider signs each Webhook with HMAC-SHA256 using a rotating secret (changed every 90 days), (2) the consumer supports two simultaneous secrets during rotation (old and new) for zero-downtime secret rotation, (3) the consumer checks idempotency via Redis with a 24-hour TTL on processed IDs, (4) failed deliveries after 5 retries are sent to a dead letter Queue and trigger an alert, and (5) the consumer has a rate limiter that rejects excess traffic with 429 to prevent overload after downtime.
Mini Project: Webhook CLI Testing Tool
#!/usr/bin/env python3
# Webhook_test_cli.py
# CLI tool to send test Webhooks to a consumer endpoint
import argparse
import hmac
import hashlib
import JSON
import requests
import time
import uuid
def send_Webhook(URL, secret, event_type, payload, Webhook_id=None):
"""Send a signed test Webhook to the specified URL."""
if Webhook_id is None:
Webhook_id = str(uuid.uuid4())
data = JSON.dumps({
"id": Webhook_id,
"event": event_type,
"data": payload,
"timestamp": int(time.time()),
})
timestamp = str(int(time.time()))
signature = hmac.new(
secret.encode(),
f"{timestamp}.{data}".encode(),
hashlib.sha256
).hexdigest()
headers = {
"Content-Type": "application/JSON",
"X-Webhook-ID": Webhook_id,
"X-Webhook-Signature": signature,
"X-Webhook-Timestamp": timestamp,
}
print(f"Sending Webhook {Webhook_id} to {URL}")
print(f" Event: {event_type}")
print(f" Payload: {JSON.dumps(payload)}")
try:
response = requests.post(URL, data=data, headers=headers, timeout=10)
print(f" Response: HTTP {response.status_code}")
print(f" Body: {response.text}")
return response.status_code == 200
except requests.exceptions.RequestException as e:
print(f" Error: {e}")
return False
def main():
parser = argparse.ArgumentParser(description='Webhook testing CLI')
parser.add_argument('URL', help='Consumer Webhook URL')
parser.add_argument('--secret', default='test-secret', help='Shared secret')
parser.add_argument('--event', default='test.ping', help='Event type')
parser.add_argument('--payload', default='{"message": "test"}', help='JSON payload')
args = parser.parse_args()
payload = JSON.loads(args.payload)
success = send_Webhook(args.URL, args.secret, args.event, payload)
sys.exit(0 if success else 1)
if __name__ == '__main__':
import sys
main()
Expected behavior: Run with python <a href="/backend/webhooks/">Webhook</a>_test_cli.py http://localhost:8080/<a href="/backend/webhooks/">Webhook</a> --event payment.succeeded --payload '{"order_id":"123","amount":29.99}'. The tool sends a properly signed Webhook and displays the consumer's response status and body.
Congratulations on completing this Webhook architecture tutorial! Next, explore message Queue patterns for asynchronous processing of Webhook events, then learn about API Gateway patterns for managing Webhook provider connections.
- Practice daily — Set up a Webhook consumer with ngrok and test with Stripe or GitHub test Webhooks
- Build a project — Build a Webhook relay service that receives, verifies, and routes Webhooks to multiple internal services with independent retry policies
- Explore related topics — Check out Svix for managed Webhook delivery and Standard Webhooks specification for industry-standard Webhook formats
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro