Webhook Implementation Guide: Building Reliable Event-Driven Systems
By X402 Team | Last Updated: February 2026
Direct Answer
Webhooks are HTTP callbacks that enable real-time event notifications between systems. When implementing webhooks, you need to: create secure HTTPS endpoints that accept POST requests, verify webhook signatures using HMAC-SHA256, implement idempotency to handle duplicate events, use exponential backoff for retries (typically 3-5 attempts), respond with 200 status within 5 seconds, and log all webhook events for debugging. Webhooks are ideal for real-time updates like payment confirmations, user actions, or system events.
Table of Contents
- What Are Webhooks?
- When to Use Webhooks
- Setting Up Webhook Endpoints
- Signature Verification
- Retry Logic and Reliability
- Security Considerations
- Testing Webhooks
- Common Pitfalls to Avoid
What Are Webhooks?
Understanding Webhooks
Webhooks are user-defined HTTP callbacks that are triggered by specific events. Instead of your application constantly polling an API to check for updates, webhooks allow services to push data to your application in real-time when events occur.
Key Characteristics:
- Event-Driven: Triggered by specific events (e.g., payment completed, user registered)
- Push-Based: Server pushes data to your endpoint (vs. pull-based polling)
- Real-Time: Near-instant notification when events occur
- HTTP POST: Typically use POST requests with JSON payloads
- Asynchronous: Non-blocking communication between services
How Webhooks Work
┌─────────────┐ Event Occurs ┌──────────────┐
│ Service │────────────────────────────>│ Your Server │
│ (Sender) │ POST /webhook/endpoint │ (Receiver) │
└─────────────┘<────────────────────────────└──────────────┘
200 OK Response
Typical Flow:
- You register a webhook URL with a service provider
- An event occurs in the service (e.g., payment succeeds)
- Service sends HTTP POST request to your webhook URL
- Your server processes the event data
- Your server responds with 200 OK (within 5-30 seconds)
- If response fails, service retries with exponential backoff
Webhooks vs. Polling
| Aspect | Webhooks | Polling |
|---|---|---|
| Real-time | Yes (instant) | No (delayed) |
| Resource usage | Low (event-driven) | High (constant requests) |
| Complexity | Medium (requires endpoint setup) | Low (simple GET requests) |
| Scalability | High | Low |
| Best for | Real-time events, high volume | Simple use cases, low volume |
When to Use Webhooks
Ideal Use Cases
1. Payment Processing
Payment succeeds → Webhook notifies your app → Update order status → Send confirmation email
2. User Activity Notifications
User signs up → Webhook triggers → Add to CRM → Send welcome email → Update analytics
3. Continuous Integration/Deployment
Git push → Webhook to CI server → Run tests → Build → Deploy if successful
4. Content Management
Content published → Webhook triggers → Clear cache → Update search index → Notify subscribers
5. Third-Party Integrations
Slack message → Webhook to your app → Process command → Post response
When NOT to Use Webhooks
Use Polling Instead When:
- Events are infrequent (< 1 per hour)
- You need to control request timing
- The service doesn't support webhooks
- Security requirements prevent exposing endpoints
- You need synchronous responses
Use Message Queues Instead When:
- You need guaranteed delivery with complex retry logic
- Events must be processed in strict order
- You need to buffer high-volume events
- Multiple consumers need to process the same events
Setting Up Webhook Endpoints
Basic Webhook Endpoint (Node.js/Express)
const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');
const app = express();
// Use raw body for signature verification
app.use('/webhook', bodyParser.raw({ type: 'application/json' }));
app.post('/webhook/payments', async (req, res) => {
try {
// Verify signature (see next section)
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse the event
const event = JSON.parse(req.body.toString());
// Respond quickly (within 5 seconds)
res.status(200).json({ received: true });
// Process event asynchronously
await processWebhookEvent(event);
} catch (error) {
console.error('Webhook processing error:', error);
// Return 200 even on processing errors to prevent retries
// Log the error for investigation
res.status(200).json({ received: true });
}
});
async function processWebhookEvent(event) {
// Check for duplicate events (idempotency)
const eventId = event.id;
const isDuplicate = await checkIfEventProcessed(eventId);
if (isDuplicate) {
console.log(Event ${eventId} already processed, skipping);
return;
}
// Process the event based on type
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSuccess(event.data);
break;
case 'payment.failed':
await handlePaymentFailure(event.data);
break;
case 'subscription.created':
await handleSubscriptionCreated(event.data);
break;
default:
console.log(Unhandled event type: ${event.type});
}
// Mark event as processed
await markEventAsProcessed(eventId);
}
app.listen(3000, () => {
console.log('Webhook endpoint listening on port 3000');
});
Webhook Endpoint (Python/Flask)
from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import logging
app = Flask(__name__)
@app.route('/webhook/payments', methods=['POST'])
def webhook_handler():
try:
# Get raw body for signature verification
payload = request.get_data()
signature = request.headers.get('X-Webhook-Signature')
# Verify signature
if not verify_signature(payload, signature):
return jsonify({'error': 'Invalid signature'}), 401
# Parse event
event = json.loads(payload)
# Respond quickly (within 5 seconds)
# Return 200 immediately, process asynchronously
response = jsonify({'received': True})
# Queue event for async processing
# (Use Celery, RQ, or similar for production)
process_webhook_event.delay(event)
return response, 200
except Exception as e:
logging.error(f'Webhook error: {str(e)}')
# Return 200 to prevent retries for processing errors
return jsonify({'received': True}), 200
def process_webhook_event(event):
"""Process webhook event asynchronously"""
event_id = event.get('id')
event_type = event.get('type')
# Check for duplicates (idempotency)
if is_event_processed(event_id):
logging.info(f'Event {event_id} already processed')
return
# Process based on event type
handlers = {
'payment.succeeded': handle_payment_success,
'payment.failed': handle_payment_failure,
'subscription.created': handle_subscription_created
}
handler = handlers.get(event_type)
if handler:
handler(event.get('data'))
else:
logging.warning(f'Unhandled event type: {event_type}')
# Mark as processed
mark_event_processed(event_id)
if __name__ == '__main__':
app.run(port=3000)
Key Implementation Principles
1. Respond Quickly
- Return 200 OK within 5-30 seconds (check provider requirements)
- Process events asynchronously in background job
- Don't wait for downstream operations (email, database writes)
2. Implement Idempotency
- Use event IDs to track processed events
- Store processed event IDs in database or cache
- Skip duplicate events gracefully
3. Handle Errors Gracefully
- Return 200 OK even if processing fails (to prevent unnecessary retries)
- Log errors for investigation
- Implement dead letter queue for failed events
4. Use HTTPS Only
- Never accept webhooks over HTTP
- Validate SSL certificates
- Use proper TLS configuration
Signature Verification
Why Signature Verification Matters
Without signature verification, attackers could:
- Send fake webhook events to your endpoint
- Trigger unauthorized actions (refunds, account changes)
- Access sensitive operations
- Cause data corruption or financial loss
HMAC-SHA256 Signature Verification (Node.js)
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
// Most services use HMAC-SHA256
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(payload).digest('hex');
// Create expected signature format (check provider docs)
const expected = sha256=${digest};
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Usage in webhook endpoint
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['stripe-signature'];
const secret = process.env.STRIPE_WEBHOOK_SECRET;
try {
// Verify signature
if (!verifySignature(req.body, signature, secret)) {
console.error('Invalid signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature valid, process event
const event = JSON.parse(req.body.toString());
processWebhookEvent(event);
res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook error:', err.message);
res.status(400).json({ error: err.message });
}
});
HMAC-SHA256 Signature Verification (Python)
import hmac
import hashlib
def verify_signature(payload, signature, secret):
"""Verify HMAC-SHA256 webhook signature"""
# Compute expected signature
mac = hmac.new(
secret.encode('utf-8'),
msg=payload,
digestmod=hashlib.sha256
)
expected = 'sha256=' + mac.hexdigest()
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(signature, expected)
Usage in Flask endpoint
@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_data()
signature = request.headers.get('Stripe-Signature')
secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
if not verify_signature(payload, signature, secret):
return jsonify({'error': 'Invalid signature'}), 401
# Process event
event = json.loads(payload)
process_webhook_event(event)
return jsonify({'received': True}), 200
Timestamp-Based Signature Verification (Stripe Style)
Some services include timestamps in signatures to prevent replay attacks:
function verifyStripeSignature(payload, header, secret) {
const tolerance = 300; // 5 minutes
// Parse signature header
const signatures = header.split(',').reduce((acc, pair) => {
const [key, value] = pair.split('=');
if (key === 't') acc.timestamp = parseInt(value);
if (key === 'v1') acc.signature = value;
return acc;
}, {});
// Check timestamp tolerance (prevent replay attacks)
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - signatures.timestamp > tolerance) {
throw new Error('Webhook timestamp too old');
}
// Compute expected signature
const signedPayload = ${signatures.timestamp}.${payload};
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(signedPayload).digest('hex');
// Compare signatures
return crypto.timingSafeEqual(
Buffer.from(signatures.signature),
Buffer.from(digest)
);
}
Best Practices for Signature Verification
- Always verify signatures - Never trust webhook payloads without verification
- Use raw request body - Verify before parsing JSON (parsing can change byte representation)
- Timing-safe comparison - Use
crypto.timingSafeEqual()orhmac.compare_digest()to prevent timing attacks - Check timestamps - Reject old webhooks to prevent replay attacks (5-minute tolerance is common)
- Store secrets securely - Use environment variables or secret management services
- Log verification failures - Monitor for potential attacks or misconfiguration
Retry Logic and Reliability
Understanding Webhook Retries
When your endpoint fails to respond with 200 OK, most services will retry delivery using exponential backoff:
Attempt 1: Immediate
Attempt 2: 5 seconds later
Attempt 3: 25 seconds later
Attempt 4: 2 minutes later
Attempt 5: 10 minutes later
Attempt 6: 1 hour later
Implementing Idempotency
Since webhooks may be delivered multiple times, implement idempotency to handle duplicates:
const redis = require('redis');
const client = redis.createClient();
async function processWebhookEvent(event) {
const eventId = event.id;
const lockKey = webhook:lock:${eventId};
const processedKey = webhook:processed:${eventId};
// Check if already processed
const alreadyProcessed = await client.get(processedKey);
if (alreadyProcessed) {
console.log(Event ${eventId} already processed);
return;
}
// Acquire lock to prevent concurrent processing
const lockAcquired = await client.set(lockKey, '1', {
NX: true, // Only set if not exists
EX: 300 // Expire after 5 minutes
});
if (!lockAcquired) {
console.log(Event ${eventId} is being processed by another worker);
return;
}
try {
// Process the event
await handleEvent(event);
// Mark as processed (store for 7 days)
await client.set(processedKey, Date.now(), { EX: 604800 });
} finally {
// Release lock
await client.del(lockKey);
}
}
Database-Based Idempotency
from sqlalchemy import Column, String, DateTime, Boolean
from datetime import datetime
class WebhookEvent(Base):
__tablename__ = 'webhook_events'
id = Column(String, primary_key=True)
type = Column(String, nullable=False)
received_at = Column(DateTime, default=datetime.utcnow)
processed = Column(Boolean, default=False)
processed_at = Column(DateTime, nullable=True)
def process_webhook_event(event):
event_id = event['id']
# Check if event exists
db_event = session.query(WebhookEvent).filter_by(id=event_id).first()
if db_event:
if db_event.processed:
logging.info(f'Event {event_id} already processed')
return
else:
# Create new event record
db_event = WebhookEvent(
id=event_id,
type=event['type']
)
session.add(db_event)
session.commit()
try:
# Process the event
handle_event(event)
# Mark as processed
db_event.processed = True
db_event.processed_at = datetime.utcnow()
session.commit()
except Exception as e:
logging.error(f'Failed to process event {event_id}: {str(e)}')
raise
Implementing Your Own Retry Logic
If you're sending webhooks to other services:
async function sendWebhook(url, payload, maxRetries = 5) {
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': generateSignature(payload)
},
body: JSON.stringify(payload),
timeout: 30000 // 30 second timeout
});
if (response.status === 200) {
console.log(Webhook delivered successfully to ${url});
return { success: true, attempts: attempt + 1 };
}
// 4xx errors (except 429) don't warrant retries
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
console.error(Webhook rejected by ${url}: ${response.status});
return { success: false, error: 'rejected', attempts: attempt + 1 };
}
} catch (error) {
console.error(Webhook delivery attempt ${attempt + 1} failed:, error.message);
}
// Exponential backoff with jitter
const delay = Math.min(1000 Math.pow(2, attempt) + Math.random() 1000, 60000);
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
}
console.error(Failed to deliver webhook to ${url} after ${maxRetries} attempts);
return { success: false, error: 'max_retries', attempts: maxRetries };
}
Exponential Backoff with Jitter (Python)
import time
import random
import requests
def send_webhook_with_retry(url, payload, max_retries=5):
"""Send webhook with exponential backoff retry"""
attempt = 0
while attempt < max_retries:
try:
response = requests.post(
url,
json=payload,
headers={
'Content-Type': 'application/json',
'X-Webhook-Signature': generate_signature(payload)
},
timeout=30
)
if response.status_code == 200:
logging.info(f'Webhook delivered to {url}')
return {'success': True, 'attempts': attempt + 1}
# Don't retry client errors (except rate limits)
if 400 <= response.status_code < 500 and response.status_code != 429:
logging.error(f'Webhook rejected: {response.status_code}')
return {'success': False, 'error': 'rejected'}
except requests.RequestException as e:
logging.error(f'Webhook attempt {attempt + 1} failed: {str(e)}')
# Exponential backoff with jitter
delay = min(2 attempt + random.random(), 60)
time.sleep(delay)
attempt += 1
logging.error(f'Failed to deliver webhook after {max_retries} attempts')
return {'success': False, 'error': 'max_retries', 'attempts': max_retries}
Security Considerations
1. Always Use HTTPS
// Enforce HTTPS in production
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.status(403).json({ error: 'HTTPS required' });
}
next();
});
2. Validate Webhook Source
IP Whitelist* (if provider publishes IP ranges):
const allowedIPs = [
'192.0.2.0/24',
'198.51.100.0/24'
];
function isIPAllowed(ip) {
// Use ip-range-check library
return ipRangeCheck(ip, allowedIPs);
}
app.post('/webhook', (req, res) => {
const clientIP = req.ip;
if (!isIPAllowed(clientIP)) {
console.error(Unauthorized IP: ${clientIP});
return res.status(403).json({ error: 'Forbidden' });
}
// Process webhook
});
3. Rate Limiting
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 15 60 1000, // 15 minutes
max: 1000, // Limit each IP to 1000 requests per window
message: 'Too many webhook requests',
standardHeaders: true,
legacyHeaders: false,
});
app.use('/webhook', webhookLimiter);
4. Payload Size Limits
app.use(express.json({ limit: '1mb' })); // Limit payload to 1MB
app.post('/webhook', (req, res) => {
// Additional size validation
const payloadSize = Buffer.byteLength(JSON.stringify(req.body));
if (payloadSize > 1024 1024) { // 1MB
return res.status(413).json({ error: 'Payload too large' });
}
// Process webhook
});
5. Implement Webhook Secrets Rotation
class WebhookSecretManager {
constructor() {
this.currentSecret = process.env.WEBHOOK_SECRET_CURRENT;
this.previousSecret = process.env.WEBHOOK_SECRET_PREVIOUS;
}
verifySignature(payload, signature) {
// Try current secret first
if (this.verify(payload, signature, this.currentSecret)) {
return true;
}
// Fall back to previous secret (during rotation period)
if (this.previousSecret && this.verify(payload, signature, this.previousSecret)) {
console.warn('Webhook verified with old secret');
return true;
}
return false;
}
verify(payload, signature, secret) {
const hmac = crypto.createHmac('sha256', secret);
const digest = 'sha256=' + hmac.update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}
}
6. Logging and Monitoring
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'webhook-error.log', level: 'error' }),
new winston.transports.File({ filename: 'webhook-combined.log' })
]
});
app.post('/webhook', async (req, res) => {
const eventId = req.body.id;
const eventType = req.body.type;
logger.info('Webhook received', {
eventId,
eventType,
timestamp: new Date().toISOString(),
ip: req.ip
});
try {
await processWebhookEvent(req.body);
logger.info('Webhook processed successfully', { eventId });
res.status(200).json({ received: true });
} catch (error) {
logger.error('Webhook processing failed', {
eventId,
error: error.message,
stack: error.stack
});
// Still return 200 to prevent retries
res.status(200).json({ received: true });
}
});
Testing Webhooks
Local Testing with ngrok
# Install ngrok
npm install -g ngrok
Start your webhook server locally
node webhook-server.js
Create public URL (in another terminal)
ngrok http 3000
Use the ngrok URL for webhook registration
Example: https://abc123.ngrok.io/webhook/payments
Testing with Webhook Testing Services
1. webhook.site - Instant webhook URL for testing
# Get a unique URL
curl https://webhook.site/token
View received webhooks in browser
https://webhook.site/{your-token}
2. RequestBin - Inspect webhook requests
# Create a bin
curl -X POST https://requestbin.com/api/v1/bins
Send test webhooks
View in RequestBin dashboard
Mock Webhook Events (Node.js)
const axios = require('axios');
const crypto = require('crypto');
async function sendTestWebhook(url, payload, secret) {
// Generate signature
const hmac = crypto.createHmac('sha256', secret);
const signature = 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex');
try {
const response = await axios.post(url, payload, {
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'User-Agent': 'TestWebhook/1.0'
}
});
console.log('Test webhook sent successfully:', response.status);
return response.data;
} catch (error) {
console.error('Test webhook failed:', error.message);
throw error;
}
}
// Usage
const testPayload = {
id: 'evt_test_123',
type: 'payment.succeeded',
data: {
payment_id: 'pay_123',
amount: 5000,
currency: 'usd',
status: 'succeeded'
}
};
sendTestWebhook(
'http://localhost:3000/webhook/payments',
testPayload,
'your-webhook-secret'
);
Integration Tests (Jest)
const request = require('supertest');
const app = require('./app');
const crypto = require('crypto');
describe('Webhook Endpoint', () => {
const secret = 'test-secret';
function generateSignature(payload) {
const hmac = crypto.createHmac('sha256', secret);
return 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex');
}
test('should accept valid webhook', async () => {
const payload = {
id: 'evt_123',
type: 'payment.succeeded',
data: { amount: 1000 }
};
const signature = generateSignature(payload);
const response = await request(app)
.post('/webhook/payments')
.set('X-Webhook-Signature', signature)
.send(payload);
expect(response.status).toBe(200);
expect(response.body.received).toBe(true);
});
test('should reject invalid signature', async () => {
const payload = {
id: 'evt_123',
type: 'payment.succeeded',
data: { amount: 1000 }
};
const response = await request(app)
.post('/webhook/payments')
.set('X-Webhook-Signature', 'invalid-signature')
.send(payload);
expect(response.status).toBe(401);
});
test('should handle duplicate events idempotently', async () => {
const payload = {
id: 'evt_duplicate',
type: 'payment.succeeded',
data: { amount: 1000 }
};
const signature = generateSignature(payload);
// Send same event twice
await request(app)
.post('/webhook/payments')
.set('X-Webhook-Signature', signature)
.send(payload);
const response = await request(app)
.post('/webhook/payments')
.set('X-Webhook-Signature', signature)
.send(payload);
expect(response.status).toBe(200);
// Verify event was only processed once in database
});
});
Python Testing (pytest)
import pytest
import hmac
import hashlib
import json
from app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def generate_signature(payload, secret):
mac = hmac.new(
secret.encode('utf-8'),
msg=json.dumps(payload).encode('utf-8'),
digestmod=hashlib.sha256
)
return 'sha256=' + mac.hexdigest()
def test_webhook_with_valid_signature(client):
payload = {
'id': 'evt_123',
'type': 'payment.succeeded',
'data': {'amount': 1000}
}
signature = generate_signature(payload, 'test-secret')
response = client.post(
'/webhook/payments',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Webhook-Signature': signature
}
)
assert response.status_code == 200
assert response.json['received'] == True
def test_webhook_rejects_invalid_signature(client):
payload = {'id': 'evt_123', 'type': 'payment.succeeded'}
response = client.post(
'/webhook/payments',
data=json.dumps(payload),
headers={
'Content-Type': 'application/json',
'X-Webhook-Signature': 'invalid'
}
)
assert response.status_code == 401
Monitoring Webhook Delivery
// Track webhook delivery metrics
const webhookMetrics = {
totalReceived: 0,
successfullyProcessed: 0,
failed: 0,
averageProcessingTime: 0
};
app.post('/webhook', async (req, res) => {
const startTime = Date.now();
webhookMetrics.totalReceived++;
try {
await processWebhookEvent(req.body);
webhookMetrics.successfullyProcessed++;
const processingTime = Date.now() - startTime;
webhookMetrics.averageProcessingTime =
(webhookMetrics.averageProcessingTime + processingTime) / 2;
res.status(200).json({ received: true });
} catch (error) {
webhookMetrics.failed++;
res.status(200).json({ received: true });
}
});
// Metrics endpoint
app.get('/webhook/metrics', (req, res) => {
res.json({
...webhookMetrics,
successRate: (webhookMetrics.successfullyProcessed / webhookMetrics.totalReceived * 100).toFixed(2) + '%'
});
});
Common Pitfalls to Avoid
1. ❌ Not Responding Quickly Enough
Wrong:
app.post('/webhook', async (req, res) => {
await processEvent(req.body); // Takes 30 seconds
await sendEmail(); // Takes 10 seconds
await updateDatabase(); // Takes 5 seconds
res.status(200).json({ received: true }); // Timeout!
});
Right:
app.post('/webhook', async (req, res) => {
// Respond immediately
res.status(200).json({ received: true });
// Process asynchronously
processEventAsync(req.body);
});
2. ❌ Not Implementing Idempotency
Wrong:
app.post('/webhook', (req, res) => {
// No duplicate check
createOrder(req.body); // Creates duplicate orders on retries!
res.status(200).send();
});
Right:
app.post('/webhook', async (req, res) => {
const eventId = req.body.id;
if (await isEventProcessed(eventId)) {
return res.status(200).json({ received: true });
}
await createOrder(req.body);
await markEventAsProcessed(eventId);
res.status(200).json({ received: true });
});
3. ❌ Ignoring Signature Verification
Wrong:
app.post('/webhook', (req, res) => {
// No signature check - anyone can send fake webhooks!
processEvent(req.body);
res.status(200).send();
});
Right:
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
processEvent(req.body);
res.status(200).send();
});
4. ❌ Returning Error Status on Processing Failures
Wrong:
app.post('/webhook', async (req, res) => {
try {
await processEvent(req.body);
res.status(200).send();
} catch (error) {
res.status(500).send(); // Causes unnecessary retries!
}
});
Right:
app.post('/webhook', async (req, res) => {
// Always return 200 to acknowledge receipt
res.status(200).json({ received: true });
try {
await processEvent(req.body);
} catch (error) {
// Log error and handle separately
logError(error);
addToDeadLetterQueue(req.body);
}
});
5. ❌ Not Logging Webhook Events
Wrong:
app.post('/webhook', (req, res) => {
processEvent(req.body); // No logging - can't debug issues!
res.status(200).send();
});
Right:
app.post('/webhook', async (req, res) => {
logger.info('Webhook received', {
eventId: req.body.id,
eventType: req.body.type,
timestamp: new Date().toISOString()
});
try {
await processEvent(req.body);
logger.info('Webhook processed successfully', { eventId: req.body.id });
} catch (error) {
logger.error('Webhook processing failed', {
eventId: req.body.id,
error: error.message
});
}
res.status(200).json({ received: true });
});
Summary Checklist
When implementing webhooks, ensure you:
- [ ] Use HTTPS only - Never accept webhooks over HTTP
- [ ] Verify signatures - Always validate HMAC signatures before processing
- [ ] Respond quickly - Return 200 OK within 5-30 seconds
- [ ] Process asynchronously - Use background jobs for event processing
- [ ] Implement idempotency - Track and skip duplicate events
- [ ] Handle retries gracefully - Return 200 even on processing errors
- [ ] Log all events - Record receipts, successes, and failures
- [ ] Implement rate limiting - Protect against abuse
- [ ] Monitor webhook health - Track success rates and processing times
- [ ] Test thoroughly - Unit tests, integration tests, and local testing with ngrok
- [ ] Secure your secrets - Use environment variables, rotate regularly
- [ ] Handle errors gracefully - Use dead letter queues for failed events
Additional Resources
- Stripe Webhooks Guide: https://stripe.com/docs/webhooks
- GitHub Webhooks Documentation: https://docs.github.com/webhooks
- Webhook.site: https://webhook.site (testing tool)
- ngrok: https://ngrok.com (local webhook testing)
- RequestBin: https://requestbin.com (webhook inspection)
Tags: webhooks, event-driven architecture, API integration, real-time notifications, HMAC signatures, retry logic, idempotency, Express.js, Flask, Node.js, Python
Related Guides:
- API Development Best Practices
- Building SDK Wrappers
- Integration Patterns and Strategies
Start Building with X402
Get our free X402 Implementation Starter Kit with ready-to-use templates, code examples, and best practices.
What is included:
- Quick-start implementation templates
- API integration examples
- Configuration best practices guide