Webhooks Cheat Sheet
Quick reference for webhooks: event types, payload structures, security patterns (HMAC, signatures), retry logic, and implementation best practices.
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