Webhook: Event-Driven HTTP Callbacks
A webhook is an HTTP callback triggered by an event. Instead of your application repeatedly asking 'did anything happen?' (polling), the external system tells you when something happens by sending an...
Key Insights
- Webhooks invert the traditional request-response model—instead of polling for changes, your system receives HTTP callbacks when events occur, reducing latency and server load dramatically.
- Security is non-negotiable: always verify webhook signatures using HMAC, validate timestamps to prevent replay attacks, and process payloads idempotently to handle inevitable duplicate deliveries.
- Building a reliable webhook system requires thinking about both sides—receivers must respond quickly and handle failures gracefully, while publishers need robust queuing and retry mechanisms.
What Is a Webhook?
A webhook is an HTTP callback triggered by an event. Instead of your application repeatedly asking “did anything happen?” (polling), the external system tells you when something happens by sending an HTTP POST to your endpoint.
Consider the difference. With polling, you might check a payment provider every 30 seconds to see if a transaction completed. With webhooks, the payment provider hits your /webhooks/payments endpoint the moment the transaction finalizes. You get near-instant notification without wasting resources on empty responses.
The term “webhook” combines “web” (HTTP) with “hook” (a callback mechanism). It’s a simple concept with profound implications for system architecture. Webhooks enable loose coupling between services, real-time data synchronization, and event-driven architectures without the complexity of message brokers.
How Webhooks Work
The webhook flow involves three phases: registration, trigger, and delivery.
Registration: You tell the publisher (the system sending webhooks) where to send events. This usually involves providing a URL endpoint and selecting which event types you want to receive.
Trigger: Something happens in the publisher’s system—a user makes a purchase, a build completes, a file uploads. The publisher creates an event.
Delivery: The publisher sends an HTTP POST request to your registered URL with the event data as the payload.
Here’s what a typical webhook payload looks like:
{
"id": "evt_1234567890",
"type": "payment.completed",
"created_at": "2024-01-15T14:32:00Z",
"data": {
"payment_id": "pay_abc123",
"amount": 9999,
"currency": "usd",
"customer_id": "cus_xyz789",
"metadata": {
"order_id": "order_456"
}
},
"webhook_id": "whk_delivery_001"
}
The payload includes an event ID for idempotency, a type for routing, a timestamp, and the actual event data. This structure lets you handle different event types with a single endpoint.
Implementing a Webhook Receiver
Your webhook receiver needs to do three things: accept POST requests, process the payload, and return an appropriate status code quickly.
Here’s a practical implementation in Node.js with Express:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Important: Use raw body for signature verification
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks/payments', async (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// Verify signature first (covered in next section)
if (!verifySignature(req.body, signature, timestamp)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
// Acknowledge receipt immediately
res.status(200).json({ received: true });
// Process asynchronously to avoid timeouts
processWebhookEvent(event).catch(err => {
console.error('Webhook processing failed:', err);
// Queue for retry or alert monitoring
});
});
async function processWebhookEvent(event) {
switch (event.type) {
case 'payment.completed':
await handlePaymentCompleted(event.data);
break;
case 'payment.failed':
await handlePaymentFailed(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
Notice that we respond with 200 OK before processing. Webhook publishers typically have short timeouts (5-30 seconds). If you don’t respond quickly, they’ll assume delivery failed and retry—potentially causing duplicate processing.
Securing Webhooks
Anyone can send a POST request to your webhook endpoint. Without verification, an attacker could forge events and trigger unintended actions in your system. Always verify webhook signatures.
Most webhook providers use HMAC-SHA256 signatures. They compute a hash of the payload using a shared secret, include it in a header, and you verify it matches:
function verifySignature(payload, signature, timestamp) {
const SECRET = process.env.WEBHOOK_SECRET;
// Prevent replay attacks - reject old timestamps
const currentTime = Math.floor(Date.now() / 1000);
const eventTime = parseInt(timestamp, 10);
const tolerance = 300; // 5 minutes
if (Math.abs(currentTime - eventTime) > tolerance) {
console.warn('Webhook timestamp outside tolerance window');
return false;
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', SECRET)
.update(signedPayload)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (signatureBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
}
Key security practices:
- Include timestamps in the signature to prevent replay attacks
- Use
crypto.timingSafeEqual()to prevent timing attacks - Store secrets in environment variables, never in code
- Reject requests with missing or malformed signatures
Reliability and Retry Logic
Webhooks will fail. Your server might be down, the network might hiccup, or your code might throw an exception. Design for this reality.
Idempotency is your primary defense. Use the event ID to ensure processing the same event twice produces the same result:
const processedEvents = new Map(); // Use Redis in production
async function handleWebhookIdempotently(event) {
const eventId = event.id;
// Check if already processed
const existing = await getProcessedEvent(eventId);
if (existing) {
console.log(`Event ${eventId} already processed, skipping`);
return { status: 'duplicate', originalResult: existing };
}
// Process the event
const result = await processEvent(event);
// Mark as processed with result
await markEventProcessed(eventId, {
processedAt: new Date().toISOString(),
result: result
});
return { status: 'processed', result };
}
async function getProcessedEvent(eventId) {
// In production, use Redis: await redis.get(`webhook:${eventId}`)
return processedEvents.get(eventId);
}
async function markEventProcessed(eventId, data) {
// Set with expiration (e.g., 7 days)
// await redis.setex(`webhook:${eventId}`, 604800, JSON.stringify(data))
processedEvents.set(eventId, data);
}
Store processed event IDs with an expiration. Most providers retry for a limited window (24-72 hours), so you don’t need to keep records forever.
Sending Webhooks (Publisher Side)
If you’re building a system that sends webhooks, reliability becomes your responsibility. Don’t send webhooks synchronously from your main application flow—use a queue:
const Queue = require('bull');
const webhookQueue = new Queue('webhooks', process.env.REDIS_URL);
// When an event occurs in your system
async function emitWebhookEvent(eventType, data) {
const event = {
id: generateEventId(),
type: eventType,
created_at: new Date().toISOString(),
data: data
};
// Get all subscriptions for this event type
const subscriptions = await getSubscriptionsForEvent(eventType);
// Queue delivery for each subscriber
for (const sub of subscriptions) {
await webhookQueue.add('deliver', {
event: event,
endpoint: sub.url,
secret: sub.secret,
attempt: 1
}, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 60000 // Start with 1 minute, then 2, 4, 8, 16
}
});
}
}
// Process the queue
webhookQueue.process('deliver', async (job) => {
const { event, endpoint, secret, attempt } = job.data;
const timestamp = Math.floor(Date.now() / 1000);
const payload = JSON.stringify(event);
const signature = computeSignature(payload, timestamp, secret);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Timestamp': timestamp.toString()
},
body: payload,
timeout: 30000
});
if (!response.ok) {
throw new Error(`Webhook delivery failed: ${response.status}`);
}
await logDeliverySuccess(event.id, endpoint);
});
Implement exponential backoff for retries. A typical schedule: 1 minute, 5 minutes, 30 minutes, 2 hours, 24 hours. After exhausting retries, notify the subscriber that their endpoint is failing.
Common Use Cases and Best Practices
Webhooks power critical integrations across the industry:
- Payment processing: Stripe, PayPal, and Square notify you of successful charges, refunds, and disputes
- CI/CD pipelines: GitHub triggers builds when code is pushed; deployment platforms notify when deploys complete
- Communication platforms: Slack and Discord notify your app of messages; Twilio reports SMS delivery status
- E-commerce: Shopify notifies of new orders; inventory systems sync stock levels
Best Practices Checklist:
Do:
- Respond to webhooks within 5 seconds
- Verify signatures on every request
- Process events idempotently
- Use queues for async processing
- Log all webhook activity for debugging
- Monitor endpoint health and alert on failures
- Document your webhook payload schemas
Don’t:
- Process webhooks synchronously in the request handler
- Trust webhook payloads without signature verification
- Assume webhooks arrive in order
- Ignore failed deliveries—they indicate integration problems
- Expose sensitive data in webhook payloads unnecessarily
Webhooks are deceptively simple. An HTTP POST with JSON—what could go wrong? Everything, as it turns out. But with proper signature verification, idempotent processing, and robust retry logic, webhooks become a reliable foundation for event-driven architectures. Build them right from the start, and you’ll avoid painful debugging sessions when that critical payment notification gets processed twice.