Webhooks are the standard for real-time communication between services. Every time you see "real-time notifications" or "instant updates" in a product, there is probably a webhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. behind it. But webhooks are tricky, many integrations work fine in development and fall apart in production on edge cases that developers never considered.
WebhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. vs APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. pollingWhat is polling?Repeatedly asking a server at regular intervals if anything has changed, which works but wastes resources when nothing is new.
There are two ways to learn about events in another system: you can ask repeatedly (polling) or you can be told when something happens (webhooks). The difference matters for cost, latencyWhat is latency?The time delay between sending a request and receiving the first byte of the response, usually measured in milliseconds., and reliability.
Polling (the naive approach):
// Your server asks Stripe every minute: "Any new orders?"
setInterval(async () => {
const orders = await fetch('https://api.stripe.com/v1/orders?status=new');
for (const order of orders.data) {
await processOrder(order);
}
}, 60000);Problems with polling: up to 60 seconds of latency, unnecessary API calls when nothing changed, you burn through rate limits, and you pay for compute to run the polling loop 24/7.
Webhook (the event-driven approach):
// Stripe calls YOUR URL the instant an order is created
app.post('/webhooks/stripe', (req, res) => {
const event = req.body;
// Process immediately - no delay, no wasted calls
handleEvent(event);
res.status(200).send('OK');
});| Approach | Latency | API calls | Cost | Complexity |
|---|---|---|---|---|
| Polling every 60s | Up to 60s delay | 1,440/day (even if 0 events) | High (wasted compute + API calls) | Low |
| Polling every 5s | Up to 5s delay | 17,280/day | Very high | Low |
| Webhook | Near-instant | Only when events happen | Low | Medium (signature, idempotency) |
The trade-off is clear: webhooks are cheaper and faster, but they require you to handle security (signature verification), reliability (idempotency), and infrastructure (a publicly accessible endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users.).
WebhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. lifecycle
Here is what happens when an event triggers a webhook:
1. Event occurs at Stripe (e.g., payment succeeds)
2. Stripe creates an event object with a unique ID
3. Stripe POSTs the event to your registered URL
4. Your server receives the request
5. You verify the signature
6. You respond 200 (acknowledgment)
7. You process the event asynchronouslyThe critical insight is that steps 5-6 should happen in milliseconds. Step 7 (the actual business logic) can take as long as it needs because you have already acknowledged receipt.
What can go wrong
| Failure scenario | Sender behavior | Your responsibility |
|---|---|---|
| Server down | Retries with exponential backoff (hours to days) | Ensure idempotent processing when you recover |
| Response too slow (>10s) | Times out, treats as failure, retries | Respond 200 immediately, process async |
| Error response (4xx/5xx) | Retries (except permanent 4xx in some providers) | Fix the handler, not the response code |
| Unstable network | Request lost or duplicated | Deduplicate using event IDs |
| Payload too large | May truncate or fail | Check content-length limits on your server |
Each of these scenarios happens in production. Not "might happen", happens. If you handle 1,000 webhooks per day, you will see retries within the first week. If you handle 10,000 per day, you will see every failure mode on this list within a month.
WebhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. signature
Why verify? Your webhook URL is just a public HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users.. Anyone on the internet can POST to it. How do you know the request actually came from Stripe and not from an attacker?
How signatures work:
- You and Stripe share a secret key (the webhook signing secret)
- When Stripe sends a webhook, it computes an HMACWhat is hmac?A keyed hash function that produces a fixed-length digest from a message and a secret key - used in webhook signatures and JWT signing.-SHA256 of the payloadWhat is payload?The data sent in the body of an HTTP request, such as the JSON object you include when creating a resource through a POST request. using that secret
- Stripe includes the signature in the
Stripe-Signatureheader - You recompute the HMAC on your side and compare, if they match, the request is genuine
const stripe = require('stripe')(process.env.STRIPE_SECRET);
app.post('/webhooks/stripe', (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
// Signature verification - this is the critical line
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.log(`Webhook signature verification failed.`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// If we reach here, the event is authentic
handleEvent(event);
res.status(200).json({ received: true });
});Different providers use different signature schemes. Stripe uses HMAC-SHA256 in a custom header. GitHub uses HMAC-SHA256 in X-Hub-Signature-256. Twilio uses a different algorithm entirely. Always check the providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually.'s documentation for the exact verification method.
| Provider | Signature header | Algorithm | Raw body required? |
|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 + timestamp | Yes |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | Yes |
| Twilio | X-Twilio-Signature | HMAC-SHA1 + URL | No (uses form params) |
| Shopify | X-Shopify-Hmac-SHA256 | HMAC-SHA256 (base64) | Yes |
| Slack | X-Slack-Signature | HMAC-SHA256 + timestamp | Yes |
Idempotency: the key concept
Problem: The same webhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. can arrive multiple times. This happens because of retries after timeouts, network duplicates, or sender bugs. If your handler creates an order on every call, duplicate webhooks create duplicate orders.
Solution: Use the event's unique ID as an idempotency keyWhat is idempotency key?A unique client-generated string sent with a mutation request so the server can safely deduplicate retried requests.. Before processing, check if you have already handled this event. If yes, skip it and return 200.
app.post('/webhooks/stripe', async (req, res) => {
const eventId = req.body.id; // Unique Stripe event ID (evt_...)
// Check if already processed
if (await isEventProcessed(eventId)) {
console.log(`Event ${eventId} already processed, skipping`);
return res.status(200).send('Already processed');
}
try {
await processPayment(req.body);
// Mark as processed (with TTL for cleanup)
await markEventProcessed(eventId, { ttl: '7d' });
res.status(200).send('OK');
} catch (error) {
// Do NOT mark as processed - let the sender retry
console.error('Processing failed:', error);
res.status(500).send('Error');
}
});The key detail is what happens on error: you return 500 without marking the event as processed. This tells the sender to retry, and the next attempt will find the event unprocessed and try again. Only mark an event as processed after successful handling.
Best practices
Respond quickly, process later
// Good: acknowledge immediately, process in the background
app.post('/webhook', async (req, res) => {
if (!isValidSignature(req)) {
return res.status(400).send('Invalid signature');
}
// Respond immediately - the sender is waiting
res.status(200).send('Received');
// Background processing (queue, worker, or async)
await processWebhookAsync(req.body);
});// Bad: blocking processing before responding
app.post('/webhook', async (req, res) => {
await processPayment(req.body); // Can take 5s!
await sendConfirmationEmail(); // + 2s
await updateInventory(); // + 1s
res.status(200).send('OK'); // 8 seconds later - timeout risk
});Retry behavior by status codeWhat is status code?A three-digit number in an HTTP response that tells the client what happened: 200 means success, 404 means not found, 500 means the server broke.
| HTTP Code | Stripe behavior | GitHub behavior | Your intent |
|---|---|---|---|
| 200-299 | Success, no retry | Success, no retry | "I got it, all good" |
| 4xx (except 429) | No retry | Retries for 3 days | "Bad request, do not retry" |
| 429 | Retry with backoff | Retry with backoff | "Too many requests, slow down" |
| 5xx | Retry up to 3 days | Retry for 3 days | "Server error, please retry" |
Event type routing
Always check the event type before processing. A single webhookWhat is webhook?An HTTP request that a service sends to your server when a specific event occurs, like a payment completing. Your server processes the event automatically. endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. typically receives many different event types, and handling the wrong type can cause data corruption.
switch (event.type) {
case 'payment_intent.succeeded':
await fulfillOrder(event.data.object);
break;
case 'payment_intent.payment_failed':
await notifyCustomer(event.data.object);
break;
case 'customer.subscription.deleted':
await revokeAccess(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}Log unhandled event types rather than ignoring them silently. This makes it easy to discover new events you should be handling when you expand your integration.