Skip to main content
โšก Calmops

Webhooks Best Practices: Security, Reliability, and Implementation

Introduction

Webhooks enable real-time communication between systems. When implemented correctly, they provide instant notifications. When done poorly, they cause missed events and debugging nightmares. This guide covers best practices.


Understanding Webhooks

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                   How Webhooks Work                           โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                             โ”‚
โ”‚  Without Webhooks (Polling):                                โ”‚
โ”‚                                                             โ”‚
โ”‚  Client        Server         Client        Server         โ”‚
โ”‚    โ”‚              โ”‚             โ”‚              โ”‚            โ”‚
โ”‚    โ”‚โ”€โ”€โ”€Requestโ”€โ”€โ”€โ–ถโ”‚             โ”‚โ”€โ”€โ”€Requestโ”€โ”€โ”€โ–ถโ”‚           โ”‚
โ”‚    โ”‚โ—€โ”€โ”€Responseโ”€โ”€โ”‚             โ”‚โ—€โ”€โ”€Responseโ”€โ”€โ”€โ”‚           โ”‚
โ”‚    โ”‚              โ”‚             โ”‚              โ”‚            โ”‚
โ”‚    โ”‚โ”€โ”€โ”€Requestโ”€โ”€โ”€โ–ถโ”‚  (every 1m)โ”‚โ”€โ”€โ”€Requestโ”€โ”€โ”€โ–ถโ”‚           โ”‚
โ”‚    โ”‚โ—€โ”€โ”€Responseโ”€โ”€โ”€โ”‚             โ”‚โ—€โ”€โ”€Responseโ”€โ”€โ”€โ”‚           โ”‚
โ”‚                                                             โ”‚
โ”‚  With Webhooks (Push):                                     โ”‚
โ”‚                                                             โ”‚
โ”‚  Server        Client         Server        Client          โ”‚
โ”‚    โ”‚              โ”‚             โ”‚              โ”‚            โ”‚
โ”‚    โ”‚โ”€โ”€โ”€Webhookโ”€โ”€โ”€โ–ถโ”‚  (instant) โ”‚โ”€โ”€โ”€Webhookโ”€โ”€โ”€โ–ถโ”‚          โ”‚
โ”‚    โ”‚โ—€โ”€200 OKโ”€โ”€โ”€โ”€โ”€โ”€โ”‚             โ”‚โ—€โ”€200 OKโ”€โ”€โ”€โ”€โ”€โ”€โ”‚          โ”‚
โ”‚                                                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Webhook Security

1. Signature Verification

// HMAC Signature Verification (Node.js)
import crypto from 'crypto';

interface WebhookPayload {
  event: string;
  data: Record<string, unknown>;
  timestamp: number;
}

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  
  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Express middleware
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), 
  (req, res) => {
    const signature = req.headers['stripe-signature'] as string;
    const payload = req.body.toString();
    
    if (!verifyWebhookSignature(payload, signature, process.env.STRIPE_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    
    const event = JSON.parse(payload);
    // Process event...
    
    res.json({ received: true });
  }
);

2. HMAC-SHA256 Implementation

// Generic HMAC webhook verification
class WebhookVerifier {
  private secret: string;
  
  constructor(secret: string) {
    this.secret = secret;
  }
  
  verify(payload: string, signature: string): boolean {
    const computedSignature = crypto
      .createHmac('sha256', this.secret)
      .update(payload, 'utf8')
      .digest('hex');
    
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(computedSignature)
    );
  }
  
  sign(payload: object): string {
    const payloadString = JSON.stringify(payload);
    return crypto
      .createHmac('sha256', this.secret)
      .update(payloadString, 'utf8')
      .digest('hex');
  }
}

3. Additional Security Measures

# Webhook security checklist
security:
  - "Always verify signatures"
  - "Use HTTPS only"
  - "Validate payload schema"
  - "Implement IP allowlisting"
  - "Add timestamp validation"
  - "Set maximum payload size"
// IP allowlisting
import { isIP } from 'net';

const ALLOWED_IPS = ['203.0.113.1', '198.51.100.2'];

function checkSourceIP(req: Request): boolean {
  const clientIP = req.ip || req.socket.remoteAddress;
  return ALLOWED_IPS.includes(clientIP);
}

// Timestamp validation
function isTimestampValid(timestamp: number, maxAgeMs: number = 300000): boolean {
  const now = Date.now();
  return timestamp > now - maxAgeMs && timestamp < now + maxAgeMs;
}

Retry Strategies

1. Exponential Backoff

// Webhook retry with exponential backoff
const RETRY_DELAYS = [0, 60000, 300000, 900000, 3600000]; // 0s, 1m, 5m, 15m, 1h

async function processWebhookWithRetry(
  payload: WebhookPayload,
  maxRetries: number = 4
): Promise<void> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      await sendToWebhook(payload);
      return;
    } catch (error) {
      if (attempt === maxRetries) {
        console.error('Max retries reached, giving up');
        // Send to dead letter queue
        await sendToDLQ(payload);
        return;
      }
      
      console.log(`Retry ${attempt + 1} in ${RETRY_DELAYS[attempt]}ms`);
      await sleep(RETRY_DELAYS[attempt]);
    }
  }
}

2. Webhook Retry Schedule

# Recommended retry schedule
retry_schedule:
  - attempt: 1
    delay: "0s (immediate)"
    reason: "network error"
    
  - attempt: 2
    delay: "1 minute"
    reason: "temporary failure"
    
  - attempt: 3
    delay: "5 minutes"
    reason: "still failing"
    
  - attempt: 4
    delay: "15 minutes"
    reason: "service may be down"
    
  - attempt: 5
    delay: "1 hour"
    reason: "final attempt before DLQ"

3. Idempotency

// Idempotent webhook processing
import { Redis } from 'ioredis';

const redis = new Redis();

async function processWebhook(eventId: string, payload: WebhookPayload) {
  // Check if already processed
  const processed = await redis.set(
    `webhook:${eventId}`,
    'processed',
    'EX', 86400, // 24 hour expiry
    'NX' // Only set if not exists
  );
  
  if (!processed) {
    console.log(`Event ${eventId} already processed, skipping`);
    return;
  }
  
  // Process the webhook
  await handleEvent(payload);
}

Receiving Webhooks

1. Express Handler

// Robust webhook endpoint
app.post(
  '/webhooks/provider',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    try {
      // 1. Verify signature
      const signature = req.headers['x-webhook-signature'] as string;
      if (!verifySignature(req.body, signature)) {
        return res.status(401).json({ error: 'Invalid signature' });
      }
      
      // 2. Parse payload
      const payload = JSON.parse(req.body.toString());
      
      // 3. Verify timestamp (prevent replay attacks)
      if (!isTimestampValid(payload.timestamp)) {
        return res.status(400).json({ error: 'Invalid timestamp' });
      }
      
      // 4. Process asynchronously (respond quickly)
      processWebhook(payload).catch(console.error);
      
      // 5. Return 200 immediately
      res.status(200).json({ received: true });
      
    } catch (error) {
      console.error('Webhook processing error:', error);
      res.status(500).json({ error: 'Processing failed' });
    }
  }
);

2. Queue-Based Processing

// Queue webhook for async processing
import { Queue } from 'bull';

const webhookQueue = new Queue('webhooks', process.env.REDIS_URL);

app.post('/webhooks/provider', async (req, res) => {
  // Quick validation
  const signature = req.headers['x-webhook-signature'];
  if (!verifySignature(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  // Add to queue
  await webhookQueue.add({
    payload: JSON.parse(req.body.toString()),
    receivedAt: Date.now(),
  });
  
  // Respond immediately
  res.status(200).json({ received: true });
});

// Queue processor
webhookQueue.process(async (job) => {
  const { payload } = job.data;
  await handleWebhookEvent(payload);
});

Testing Webhooks

1. Local Testing

// Use webhook CLI or local server
// npx localtunnel --port 3000

// Or ngrok
// ngrok http 3000

// Test endpoint
app.post('/webhook-test', (req, res) => {
  console.log('Received webhook:', req.body);
  res.status(200).json({ success: true });
});

2. Webhook Testing Tools

# Webhook testing services
tools:
  - "Webhook.site - Inspect and test webhooks"
  - "RequestBin - Collect HTTP requests"
  - "ngrok - Expose local server"
  - "localtunnel - Simple tunneling"

Key Takeaways

  • Always verify signatures - Never trust payloads without verification
  • Respond quickly - Process asynchronously, return 200 immediately
  • Implement retries - Exponential backoff with dead letter queue
  • Make idempotent - Prevent duplicate processing
  • Validate timestamps - Prevent replay attacks

External Resources

Comments