Webhooks Cheat Sheet

Quick reference for webhooks: event types, payload structures, security patterns (HMAC, signatures), retry logic, and implementation best practices.

Basics Security Payloads Retry Handling Testing Tools

Webhook Basics

Concept Description Example
Event Source Service sending the webhook Stripe, GitHub, Shopify
Endpoint URL Your server's callback URL https://api.yours.com/webhook
Event Type What triggered the webhook payment.succeeded
Payload JSON data sent to your endpoint { "id": "evt_123", ... }
Signature HMAC hash for verification X-Webhook-Signature: sha256=...
Acknowledgment 2xx response to confirm receipt HTTP 200 OK

Webhook Security

Method Header Algorithm
HMAC-SHA256 X-Webhook-Signature SHA256 hash of payload
HMAC-SHA512 X-Signature SHA512 hash of payload
Stripe-style Stripe-Signature t=timestamp,v1=signature
GitHub-style X-Hub-Signature-256 sha256=hex(HMAC)
Timestamp X-Webhook-Timestamp Reject if > 5 min old
Nonce X-Webhook-Nonce Prevent replay attacks
// Node.js: Verify HMAC signature
const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(payload);
  const expected = 'sha256=' + hmac.digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Usage in Express
app.post('/webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const payload = JSON.stringify(req.body);

  if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process webhook...
  res.status(200).json({ received: true });
});
# Python: Verify HMAC signature
import hmac
import hashlib

def verify_webhook_signature(payload, signature, secret):
    expected = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

# Flask endpoint
@app.route('/webhook', methods=['POST'])
def webhook():
    payload = request.get_data(as_text=True)
    signature = request.headers.get('X-Webhook-Signature')

    if not verify_webhook_signature(payload, signature, app.config['WEBHOOK_SECRET']):
        return jsonify({'error': 'Invalid signature'}), 401

    # Process webhook...
    return jsonify({'received': True}), 200

Common Payload Structures

# Stripe-style webhook payload
{
  "id": "evt_1OZQJxLkDdG3hQ3xYz9vN8wP",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1709726400,
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_1OZQJxLkDdG3hQ3xYz9vN8wP",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_ABC123"
    },
    "previous_attributes": {}
  }
}
# GitHub-style webhook payload
{
  "action": "opened",
  "pull_request": {
    "id": 1234567890,
    "number": 42,
    "state": "open",
    "title": "Fix bug in API",
    "user": { "login": "developer", "id": 12345 }
  },
  "repository": {
    "name": "my-repo",
    "full_name": "org/my-repo",
    "owner": { "login": "org" }
  },
  "sender": { "login": "developer", "id": 12345 }
}
# Shopify-style webhook payload
{
  "id": 12345678901234567890,
  "admin_graphql_api_id": "gid://shopify/Order/1234567890",
  "created_at": "2024-03-06T10:00:00-05:00",
  "updated_at": "2024-03-06T10:00:00-05:00",
  "email": "[email protected]",
  "financial_status": "paid",
  "fulfillment_status": "unfulfilled",
  "total_price": "99.00",
  "currency": "USD",
  "line_items": [
    {
      "id": 12345678901234567,
      "title": "Product Name",
      "quantity": 2,
      "price": "49.50"
    }
  ],
  "customer": {
    "id": 1234567890,
    "email": "[email protected]",
    "first_name": "John",
    "last_name": "Doe"
  }
}

Retry Logic & Backoff

Provider Max Retries Backoff Strategy Timeout
Stripe 3 days Exponential (minutes → hours → days) 30s
GitHub 25 times Exponential backoff 10s
Shopify 4 times over 30 min 1min, 2min, 4min, 8min 5s
Twilio 24 hours Exponential backoff 15s
PayPal 10 times over 3 days Exponential backoff 10s

Best Practice: Exponential Backoff Formula

# Calculate delay with jitter
delay = min(base * (2 ^ attempt), max_delay) + random(0, jitter)

# Example: base=1min, max=1hr, jitter=30s
# Attempt 0: 1-1.5 min
# Attempt 1: 2-2.5 min
# Attempt 2: 4-4.5 min
# Attempt 3: 8-8.5 min
# Attempt 4+: 16 min → capped at 1hr

Webhook Handler Patterns

// Node.js: Async webhook handler with queue
app.post('/webhook', async (req, res) => {
  // 1. Verify signature (synchronous, must be fast)
  const valid = verifySignature(req.body, req.headers);
  if (!valid) return res.status(401).send('Invalid signature');

  // 2. Acknowledge immediately (don't wait for processing)
  res.status(200).send('OK');

  // 3. Queue for async processing
  await webhookQueue.add({
    eventType: req.headers['x-event-type'],
    payload: req.body,
    receivedAt: Date.now()
  });
});

// Worker processes queue
webhookQueue.process(async (job) => {
  const { eventType, payload } = job.data;

  // Idempotency check
  if (await isDuplicate(payload.id)) return;
  await markAsProcessed(payload.id);

  // Route to handler
  switch (eventType) {
    case 'payment.succeeded':
      await handlePaymentSuccess(payload);
      break;
    case 'payment.failed':
      await handlePaymentFailure(payload);
      break;
  }
});
# Python: Idempotent webhook handler
import hashlib
from datetime import timedelta
from django.utils import timezone

def get_event_idempotency_key(payload):
    """Create unique key from payload for deduplication"""
    event_id = payload.get('id') or payload.get('event_id')
    return hashlib.sha256(event_id.encode()).hexdigest()[:16]

@app.route('/webhook', methods=['POST'])
def webhook():
    # Check for duplicates
    key = get_event_idempotency_key(request.json)
    if WebhookEvent.objects.filter(key=key, processed_at__gte=timezone.now() - timedelta(hours=1)).exists():
        return jsonify({'status': 'duplicate'}), 200

    # Process and mark as done
    process_webhook(request.json)
    WebhookEvent.objects.create(key=key, payload=request.json, processed_at=timezone.now())

    return jsonify({'status': 'ok'}), 200

Testing Webhooks

Tool Purpose Command/URL
Stripe CLI Forward Stripe events locally stripe listen --forward-to localhost:3000/webhook
ngrok Expose local server to internet ngrok http 3000
webhook.site Inspect webhook payloads https://webhook.site/unique-id
GitHub Test Delivery Re-deliver webhook from settings Settings → Webhooks → Recent Deliveries
curl Manual webhook testing curl -X POST -d @payload.json localhost:3000/webhook

Best Practices

Always verify webhook signatures before processing

Return 200 OK immediately, process asynchronously

Implement idempotency to handle duplicate deliveries

Log all webhook events for debugging and audit

Use separate endpoints for different event types

Set up alerting for webhook failures

Don't do heavy processing in the webhook request

Don't trust webhook data without verification

Related Resources