Imagine checking your email every 5 seconds to see if you received a message. That's what polling does — and it's incredibly wasteful. Webhooks flip this model: instead of constantly asking "anything new?", the sender tells you the moment something happens. This push-based approach is how modern applications communicate in real-time — Stripe notifies you when a payment completes, GitHub alerts you when code is pushed, and Shopify tells you when an order is placed.
This guide covers everything about webhooks: what they are, how they work, security (signature verification), retry logic, idempotency, implementation in Node.js, and the common mistakes that cause webhook integrations to fail.
What Are Webhooks?
A webhook is an automated HTTP POST request sent from one application to another when a specific event occurs. You register a URL (your "webhook endpoint"), and when the event happens, the source application sends the event data to that URL immediately.
Real-world analogy: Think of a doorbell. Without it (polling), you would have to keep checking the door every few minutes. With a doorbell (webhook), you are notified instantly when someone arrives. You only get up when there's actually a visitor — no wasted trips.
Webhooks are sometimes called "reverse APIs" — instead of you calling the API to get data, the API calls you when something important happens. This makes them event-driven: your code only runs when there's actually something to process.
How Webhooks Work (Step by Step)
1. Register webhook URL
You tell Stripe: "Send payment events to https://myapp.com/webhooks/stripe"
2. Event occurs
A customer completes a payment on Stripe
3. Webhook sent (HTTP POST)
Stripe POSTs event payload to your URL with signature header
4. Your server processes it
Verify signature → process event → return 200 OK
Webhooks vs Polling
| Feature | Webhooks (Push) | Polling (Pull) |
|---|---|---|
| How it works | Server pushes data when event occurs | Client repeatedly asks server for updates |
| Latency | Near real-time (seconds) | Depends on poll interval (seconds to minutes) |
| Network usage | Low (only when events happen) | High (constant requests, mostly empty) |
| Server load | Low (no empty requests) | High (processing requests that return nothing) |
| Complexity | Medium (need public endpoint + security) | Low (simple GET requests in a loop) |
| Reliability | Needs retry logic (delivery not guaranteed) | Simple (just ask again) |
| Scalability | Excellent (event-driven) | Poor (more clients = more polling traffic) |
| Best for | Real-time events, notifications, integrations | Simple data checking, rate-limited APIs |
Anatomy of a Webhook Request
A webhook is just an HTTP POST request with a JSON body. Here's what a typical one looks like:
POST /webhooks/stripe HTTP/1.1
Host: myapp.com
Content-Type: application/json
Stripe-Signature: t=1719216000,v1=abc123def456...
User-Agent: Stripe/1.0 (+https://stripe.com)
X-Request-ID: evt_1234567890
{
"id": "evt_1234567890",
"type": "payment_intent.succeeded",
"created": 1719216000,
"data": {
"object": {
"id": "pi_abc123",
"amount": 5000,
"currency": "usd",
"status": "succeeded",
"customer": "cus_xyz789"
}
}
}Key elements: the event type identifies what happened, the event ID enables idempotency (detecting duplicates), the timestamp helps prevent replay attacks, and the signature header proves the request came from the legitimate sender.
Webhook Security
Anyone who knows your webhook URL can send fake requests to it. Without verification, an attacker could craft a fake "payment succeeded" webhook and trick your app into granting access or shipping products. Security is non-negotiable.
Signature Verification (HMAC)
The sender signs each webhook payload using a shared secret (HMAC-SHA256). Your server computes the same signature and compares — if they match, the request is authentic.
// Webhook signature verification (Node.js/Express)
const crypto = require("crypto")
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["stripe-signature"]
const secret = process.env.WEBHOOK_SECRET
// Compute expected signature
const payload = req.body.toString()
const timestamp = signature.split(",")[0].replace("t=", "")
const expectedSig = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + payload)
.digest("hex")
// Compare using timing-safe comparison
const receivedSig = signature.split(",")[1].replace("v1=", "")
if (!crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(receivedSig))) {
return res.status(401).json({ error: "Invalid signature" })
}
// Verify timestamp is recent (prevent replay attacks)
const tolerance = 300 // 5 minutes
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > tolerance) {
return res.status(401).json({ error: "Timestamp too old" })
}
// Signature valid — process the event
const event = JSON.parse(payload)
handleWebhookEvent(event)
res.status(200).json({ received: true })
})🔒 Security Rule
Never process a webhook payload without verifying the signature first. Use timing-safe comparison (crypto.timingSafeEqual) to prevent timing attacks. Always validate the timestamp to block replay attacks.
Retry Logic and Idempotency
Why Retries Are Necessary
Networks fail. Servers crash. Timeouts happen. When your webhook endpoint returns a non-2xx response (or times out), the sender will retry. This means the same event can be delivered multiple times. Your handler must be prepared for this.
Typical retry schedule: immediately, then 1 min, 5 min, 30 min, 2 hours, 6 hours, 24 hours. After ~72 hours of failures, most providers disable the webhook and notify you.
Idempotency: Handle Duplicates Safely
Idempotency means processing the same event twice produces the same result as processing it once. Since retries can deliver duplicates, your handler must check if it already processed an event before acting on it:
// Idempotent webhook handler
async function handleWebhookEvent(event) {
// Check if we already processed this event
const existing = await db.findProcessedEvent(event.id)
if (existing) {
console.log(`Event ${event.id} already processed, skipping`)
return // Idempotent — no duplicate action
}
// Process the event
switch (event.type) {
case "payment_intent.succeeded":
await activateSubscription(event.data.object.customer)
break
case "customer.subscription.deleted":
await deactivateAccount(event.data.object.customer)
break
}
// Mark as processed (prevents duplicate processing on retries)
await db.markEventProcessed(event.id, event.type, new Date())
}Building a Webhook Receiver (Express.js)
Here's a production-ready webhook receiver with all best practices:
const express = require("express")
const crypto = require("crypto")
const app = express()
// Important: Use raw body for signature verification
app.post("/webhooks/payments",
express.raw({ type: "application/json" }),
async (req, res) => {
// 1. Verify signature
if (!verifySignature(req)) {
return res.status(401).send("Invalid signature")
}
// 2. Parse payload
const event = JSON.parse(req.body.toString())
// 3. Check idempotency (prevent duplicate processing)
if (await isAlreadyProcessed(event.id)) {
return res.status(200).send("Already processed")
}
// 4. Respond immediately (before processing!)
res.status(200).json({ received: true })
// 5. Process asynchronously (don't block the response)
processEventAsync(event).catch(err => {
console.error("Webhook processing failed:", err)
// Alert monitoring system
})
}
)
// Pro tip: Respond 200 FIRST, then process.
// If processing takes too long, the sender may timeout
// and retry — causing duplicate events.💡 Pro Tip
Always respond with 200 OK immediately, then process the event asynchronously. If your processing takes more than 5-10 seconds, the sender may time out and retry — causing duplicate deliveries.
Common Webhook Mistakes
⚠️ Not verifying webhook signatures
Without signature verification, anyone who discovers your webhook URL can send fake events. An attacker could fake a 'payment succeeded' event and get free access to your service.
✅ Fix: Always verify HMAC signatures before processing. Never trust a webhook payload based solely on its content.
⚠️ Processing synchronously (blocking the response)
If your handler takes 30 seconds (database writes, emails, API calls), the sender times out and retries. You get duplicate events and your system is overwhelmed.
✅ Fix: Return 200 immediately, then process asynchronously using a job queue (Bull, RabbitMQ, SQS).
⚠️ No idempotency (duplicate events cause duplicate actions)
A retried webhook creates two subscriptions, sends two emails, or charges twice. Each retry multiplies the damage.
✅ Fix: Store processed event IDs. Before processing, check if the event was already handled. Skip duplicates.
⚠️ Using HTTP instead of HTTPS
Webhook payloads often contain sensitive data (payment info, user details). Over HTTP, anyone on the network can read them.
✅ Fix: Always use HTTPS for webhook endpoints. Most providers refuse to send webhooks to HTTP URLs.
⚠️ Returning 200 for events you didn't handle
If your handler throws an error but you still return 200, the sender thinks delivery succeeded. You silently lose events with no retry.
✅ Fix: Return 200 only if you successfully received and queued the event. Return 500 for genuine failures so the sender retries.
⚠️ Hardcoding webhook secrets in source code
Secrets in code get committed to Git, exposed in logs, and shared with everyone who has repo access.
✅ Fix: Use environment variables. Store secrets in vault services (AWS Secrets Manager, HashiCorp Vault). Never commit .env files.
Webhook Best Practices
Never process unverified webhooks. Use HMAC-SHA256 with timing-safe comparison.
Return success first, then handle the event in a background job. Prevents timeouts and retries.
Store event IDs and skip duplicates. Webhooks can be delivered multiple times due to retries.
Webhook payloads contain sensitive data. Never expose endpoints over unencrypted HTTP.
Reject webhooks with timestamps older than 5 minutes to prevent replay attacks.
Use event timestamps or sequence numbers. Don't assume delivery order matches event order.
Log received events, signature verification results, and processing outcomes for debugging.
Alert when signature verification fails repeatedly (possible attack) or processing errors spike.
If processing fails, return 500 so the sender retries. Store failed events for manual review.
Change webhook signing secrets every 90 days. Support two active secrets during rotation.
Testing Webhooks
Webhooks are tricky to test because they require a publicly accessible URL. Here are the best approaches:
ngrok
Creates a public URL that tunnels to your localhost. Run 'ngrok http 3000' → get a URL like https://abc123.ngrok.io → register it as your webhook endpoint.
Best for local development and debugging
Webhook.site
Provides a temporary public URL that captures and displays incoming requests. No code needed — just copy the URL and register it as your webhook endpoint.
Best for quick inspection of webhook payloads
cURL (manual testing)
Send a fake webhook to your endpoint: curl -X POST http://localhost:3000/webhooks -H 'Content-Type: application/json' -d '{"type":"test"}'
Best for testing your handler logic
Postman
Create a POST request with the expected headers and payload. Use Postman's pre-request scripts to generate HMAC signatures.
Best for team collaboration on webhook testing
Frequently Asked Questions
What are webhooks?
Webhooks vs Polling — what's the difference?
Can webhooks replace APIs?
What HTTP method do webhooks use?
How do webhooks stay secure?
What happens if webhook delivery fails?
How do I test webhooks locally?
What is webhook signature verification?
What is idempotency in webhooks?
Can webhooks send GET requests?
Related Articles & Tools
Conclusion
Webhooks are the backbone of real-time integrations in modern software. They eliminate wasteful polling, enable instant notifications, and connect services in an event-driven architecture. But they require careful implementation: verify every signature, handle duplicates with idempotency, respond quickly and process asynchronously, and always use HTTPS.
Start simple: register a webhook with a provider you already use (Stripe, GitHub, or Slack), build a receiver that verifies signatures and logs events, then gradually add business logic. Use ngrok for local testing and Webhook.site for quick payload inspection. Once you've built one secure webhook receiver, the pattern applies to every integration you'll build.
