crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) for constant-time signature verification.crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal) — timing attacks leak secret bytes| Component | Sender Responsibility | Receiver Responsibility | Security Consideration |
|---|---|---|---|
| Registration | Provide endpoint URL input + secret generation | Validate URL is HTTPS, store secret securely | Generate 256-bit secrets with CSPRNG |
| Payload signing | HMAC-SHA256(secret, timestamp + "." + body) | Recompute HMAC from raw body + timestamp header | Sign raw bytes, never re-serialized JSON |
| Signature header | Send as X-Webhook-Signature or X-Hub-Signature-256 | Extract and compare with constant-time function | Use crypto.timingSafeEqual / hmac.compare_digest |
| Timestamp | Include X-Webhook-Timestamp header (Unix epoch) | Reject if abs(now - timestamp) > 300 seconds | Prevents replay attacks |
| Delivery | POST JSON with Content-Type + signature headers | Return 200/202 within 5 seconds | Use HTTPS only, verify TLS certificates |
| Idempotency | Include unique X-Webhook-Id per event | Store processed IDs in DB/Redis (TTL 7-30 days) | Prevents duplicate processing from retries |
| Retry logic | Exponential backoff: 1s, 2s, 4s, 8s... up to 12h | Return 2xx for success, 4xx for permanent failure | Add jitter to prevent thundering herd |
| Dead letter queue | Move to DLQ after max retries (typically 5-8) | N/A (sender-side concern) | Alert on DLQ depth for monitoring |
| Timeout | Set 10-30 second connection timeout | Enqueue work, respond fast | Sender retries on timeout, receiver must be idempotent |
| Secret rotation | Support dual-secret window during rotation | Accept either old or new secret during transition | Rotate every 90 days minimum |
START
├── Are you the SENDER or RECEIVER?
│ ├── SENDER ↓
│ │ ├── Scale > 10K events/min?
│ │ │ ├── YES → Use persistent queue (Redis/SQS/RabbitMQ) + worker pool
│ │ │ └── NO → In-process retry with backoff is sufficient
│ │ ├── Need guaranteed ordering?
│ │ │ ├── YES → Partition queue by endpoint ID, single consumer per partition
│ │ │ └── NO → Parallel delivery workers (default, much higher throughput)
│ │ └── DEFAULT → Queue-based sender with HMAC signing + exponential backoff
│ │
│ └── RECEIVER ↓
│ ├── Processing takes > 5 seconds?
│ │ ├── YES → ACK immediately (202), process via background queue
│ │ └── NO → Process inline, return 200 with empty body
│ ├── Duplicate events would cause harm (payments, orders)?
│ │ ├── YES → Store webhook ID in DB with UNIQUE constraint before processing
│ │ └── NO → Idempotency still recommended but less critical
│ └── DEFAULT → Verify signature → check idempotency → enqueue → return 202
Create a cryptographically secure random secret per registered endpoint. Never use predictable values. [src1]
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
// Store in database associated with the endpoint
Verify: Secret is 64 hex characters (256 bits): secret.length === 64 → true
Construct the signing input from the timestamp and raw body, then compute HMAC-SHA256. [src1]
function signWebhook(secret, timestamp, body) {
const signingInput = `${timestamp}.${body}`;
return crypto
.createHmac('sha256', secret)
.update(signingInput)
.digest('hex');
}
Verify: Signature is 64 hex characters: signature.length === 64 → true
Send the webhook with signature, timestamp, and unique event ID headers. [src6]
async function deliverWebhook(endpointUrl, body, secret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const eventId = crypto.randomUUID();
const signature = signWebhook(secret, timestamp, body);
const response = await fetch(endpointUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Id': eventId,
'X-Webhook-Timestamp': timestamp,
'X-Webhook-Signature': `sha256=${signature}`,
},
body: body,
signal: AbortSignal.timeout(10000),
});
return { status: response.status, eventId };
}
Verify: Response status is 2xx: response.status >= 200 && response.status < 300
Always verify against the raw body bytes using constant-time comparison. [src5]
function verifyWebhookSignature(secret, timestamp, rawBody, receivedSignature) {
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) {
throw new Error('Webhook timestamp too old');
}
const signingInput = `${timestamp}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret)
.update(signingInput).digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
const receivedBuf = Buffer.from(
receivedSignature.replace('sha256=', ''), 'utf8'
);
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
Verify: Returns true for valid signatures, false for tampered payloads
Store processed webhook IDs before processing to prevent duplicates. [src3]
async function processWebhookIdempotent(db, webhookId, handler) {
try {
await db.query(
'INSERT INTO processed_webhooks (webhook_id, received_at) VALUES ($1, NOW())',
[webhookId]
);
} catch (err) {
if (err.code === '23505') { // unique_violation
return { status: 'duplicate', message: 'Already processed' };
}
throw err;
}
const result = await handler();
return result;
}
Verify: Sending the same webhook ID twice returns { status: 'duplicate' } on second call
Retry failed deliveries with increasing delays and randomized jitter. [src2]
async function deliverWithRetry(endpointUrl, body, secret, maxRetries = 5) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await deliverWebhook(endpointUrl, body, secret);
if (result.status >= 200 && result.status < 300) {
return { success: true, attempt, eventId: result.eventId };
}
if (result.status >= 400 && result.status < 500 && result.status !== 429) {
return { success: false, permanent: true };
}
} catch (err) { /* network error — retry */ }
if (attempt < maxRetries) {
const baseDelay = Math.min(1000 * Math.pow(2, attempt), 43200000);
await new Promise(r => setTimeout(r, Math.random() * baseDelay));
}
}
return { success: false, exhausted: true };
}
Verify: Failed delivery retries with increasing delays and moves to DLQ after max retries
// Input: POST request with JSON body + signature headers
// Output: 200/202 for valid webhooks, 401 for invalid signature
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));
app.post('/webhooks', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const webhookId = req.headers['x-webhook-id'];
if (!signature || !timestamp || !webhookId) {
return res.status(401).json({ error: 'Missing headers' });
}
const isValid = verifyWebhookSignature(
process.env.WEBHOOK_SECRET, timestamp, req.rawBody, signature
);
if (!isValid) return res.status(401).json({ error: 'Invalid signature' });
queue.add({ webhookId, body: req.body });
res.status(202).json({ received: true });
});
# Input: POST request with JSON body + HMAC signature headers
# Output: 200/202 for valid webhooks, 401 for invalid
import hmac, hashlib, time
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_signature(secret, timestamp, raw_body, received_sig):
if abs(time.time() - int(timestamp)) > 300:
return False
signing_input = f"{timestamp}.".encode() + raw_body
expected = hmac.new(
secret.encode(), signing_input, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig.removeprefix("sha256="))
@app.route("/webhooks", methods=["POST"])
def receive_webhook():
sig = request.headers.get("X-Webhook-Signature", "")
ts = request.headers.get("X-Webhook-Timestamp", "")
raw = request.get_data() # raw bytes, NOT request.json
if not verify_signature(app.config["WEBHOOK_SECRET"], ts, raw, sig):
return jsonify({"error": "Invalid signature"}), 401
task_queue.enqueue(process_event, request.json)
return jsonify({"received": True}), 202
// Input: HTTP POST with JSON body + HMAC signature headers
// Output: 200/202 for valid webhooks, 401 for invalid
func verifySignature(secret, timestamp string, rawBody []byte, receivedSig string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
signingInput := fmt.Sprintf("%s.%s", timestamp, string(rawBody))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(receivedSig))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Webhook-Signature")
ts := r.Header.Get("X-Webhook-Timestamp")
if !verifySignature(os.Getenv("WEBHOOK_SECRET"), ts, rawBody, sig) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"received":true}`))
}
// BAD — re-serialized JSON changes key order and whitespace
app.post('/webhooks', express.json(), (req, res) => {
const body = JSON.stringify(req.body); // key order may differ!
const expected = crypto.createHmac('sha256', secret)
.update(body).digest('hex');
if (expected === receivedSignature) { /* ... */ }
});
// GOOD — use the exact bytes the sender signed
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));
app.post('/webhooks', (req, res) => {
const expected = crypto.createHmac('sha256', secret)
.update(req.rawBody).digest('hex');
// constant-time comparison follows...
});
// BAD — string === leaks timing information
if (computedSignature === receivedSignature) {
processWebhook(req.body);
}
// GOOD — constant-time comparison prevents timing attacks
const expected = Buffer.from(computedSignature, 'hex');
const received = Buffer.from(receivedSignature, 'hex');
if (expected.length === received.length &&
crypto.timingSafeEqual(expected, received)) {
processWebhook(req.body);
}
// BAD — long processing causes sender timeout and unnecessary retries
app.post('/webhooks', async (req, res) => {
await updateDatabase(req.body); // 2 seconds
await sendNotification(req.body); // 3 seconds
await generateReport(req.body); // 5 seconds
res.status(200).json({ ok: true }); // 10s total — sender may timeout
});
// GOOD — respond immediately, process in background
app.post('/webhooks', (req, res) => {
queue.add({ event: req.body, webhookId: req.headers['x-webhook-id'] });
res.status(202).json({ received: true }); // < 50ms response
});
// BAD — retries forever, wasting resources
async function deliver(url, body) {
while (true) {
try { await fetch(url, { method: 'POST', body }); return; }
catch { await sleep(1000); }
}
}
// GOOD — cap retries, move failures to DLQ
async function deliver(url, body, maxRetries = 5) {
for (let i = 0; i <= maxRetries; i++) {
try {
const res = await fetch(url, { method: 'POST', body });
if (res.ok) return { delivered: true };
} catch {}
const delay = Math.min(1000 * 2 ** i, 43200000) * Math.random();
await sleep(delay);
}
await deadLetterQueue.add({ url, body, failedAt: new Date() });
return { delivered: false, dlq: true };
}
express.json({ verify }) or request.get_data() in Flask to capture raw bytes before parsing. [src5]Math.random() * baseDelay) to exponential backoff. [src2]# Test webhook delivery locally (sender simulation)
curl -X POST http://localhost:3000/webhooks \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=$(echo -n '1708800000.{\"event\":\"test\"}' | openssl dgst -sha256 -hmac 'your-secret' | awk '{print $2}')" \
-H "X-Webhook-Timestamp: 1708800000" \
-H "X-Webhook-Id: test-$(uuidgen)" \
-d '{"event":"test"}'
# Compute HMAC-SHA256 signature manually
echo -n 'timestamp.payload' | openssl dgst -sha256 -hmac 'your-secret-key'
# Test signature verification in Python
python3 -c "import hmac, hashlib; print(hmac.new(b'secret', b'1708800000.{\"test\":1}', hashlib.sha256).hexdigest())"
# Monitor webhook delivery queue depth (Redis)
redis-cli LLEN webhook:delivery:queue
# Check dead letter queue
redis-cli LLEN webhook:dlq
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Server-to-server event notification (backend-to-backend) | Client needs real-time updates from server | Server-Sent Events (SSE) or WebSockets |
| Decoupled systems where receiver controls the endpoint | Guaranteed strict event ordering is required | Message queue (Kafka, RabbitMQ, SQS) |
| Low-to-medium frequency events (<10K/min per endpoint) | Ultra-high throughput (>100K events/sec) | Event streaming (Kafka, Pulsar) |
| Receiver is a third-party system you don't control | Both sides within same infrastructure | Internal message queue or pub/sub |
| Simple integration with no persistent connection needed | Bidirectional communication is required | WebSockets |
| Serverless/edge receivers (can't hold connections open) | Receiver frequently offline for extended periods | Message queue with durable storage |
| Push model preferred over pull (reduce polling overhead) | Minutes-old data is acceptable | Simple polling with caching |