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

  1. What Are Webhooks?
  2. When to Use Webhooks
  3. Setting Up Webhook Endpoints
  4. Signature Verification
  5. Retry Logic and Reliability
  6. Security Considerations
  7. Testing Webhooks
  8. 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:

  1. You register a webhook URL with a service provider
  2. An event occurs in the service (e.g., payment succeeds)
  3. Service sends HTTP POST request to your webhook URL
  4. Your server processes the event data
  5. Your server responds with 200 OK (within 5-30 seconds)
  6. If response fails, service retries with exponential backoff

Webhooks vs. Polling

AspectWebhooksPolling
Real-timeYes (instant)No (delayed)
Resource usageLow (event-driven)High (constant requests)
ComplexityMedium (requires endpoint setup)Low (simple GET requests)
ScalabilityHighLow
Best forReal-time events, high volumeSimple 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

  1. Always verify signatures - Never trust webhook payloads without verification
  2. Use raw request body - Verify before parsing JSON (parsing can change byte representation)
  3. Timing-safe comparison - Use crypto.timingSafeEqual() or hmac.compare_digest() to prevent timing attacks
  4. Check timestamps - Reject old webhooks to prevent replay attacks (5-minute tolerance is common)
  5. Store secrets securely - Use environment variables or secret management services
  6. 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

Get the Free Starter Kit