Webhooks Explained

Complete guide — how webhooks work, security, retries, idempotency, and implementation

API & IntegrationJune 28, 202618 min readBy Keyur Patel

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

FeatureWebhooks (Push)Polling (Pull)
How it worksServer pushes data when event occursClient repeatedly asks server for updates
LatencyNear real-time (seconds)Depends on poll interval (seconds to minutes)
Network usageLow (only when events happen)High (constant requests, mostly empty)
Server loadLow (no empty requests)High (processing requests that return nothing)
ComplexityMedium (need public endpoint + security)Low (simple GET requests in a loop)
ReliabilityNeeds retry logic (delivery not guaranteed)Simple (just ask again)
ScalabilityExcellent (event-driven)Poor (more clients = more polling traffic)
Best forReal-time events, notifications, integrationsSimple 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

Always verify signatures

Never process unverified webhooks. Use HMAC-SHA256 with timing-safe comparison.

Respond with 200 immediately, process async

Return success first, then handle the event in a background job. Prevents timeouts and retries.

Implement idempotency

Store event IDs and skip duplicates. Webhooks can be delivered multiple times due to retries.

Use HTTPS exclusively

Webhook payloads contain sensitive data. Never expose endpoints over unencrypted HTTP.

Validate timestamps

Reject webhooks with timestamps older than 5 minutes to prevent replay attacks.

Process events in order when it matters

Use event timestamps or sequence numbers. Don't assume delivery order matches event order.

Log all webhook attempts

Log received events, signature verification results, and processing outcomes for debugging.

Set up monitoring and alerts

Alert when signature verification fails repeatedly (possible attack) or processing errors spike.

Handle the failure path gracefully

If processing fails, return 500 so the sender retries. Store failed events for manual review.

Rotate secrets periodically

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 are automated HTTP POST requests sent from one application to another when a specific event occurs. Instead of constantly checking for updates (polling), the source application pushes data to your server the moment something happens — like a payment completing or a file being uploaded.
Webhooks vs Polling — what's the difference?
Polling repeatedly asks 'any updates?' every few seconds (wasteful). Webhooks push updates instantly when events occur (efficient). Polling is like checking your mailbox every 5 minutes. Webhooks are like getting a doorbell ring when a package arrives.
Can webhooks replace APIs?
No. Webhooks and APIs serve different purposes. APIs let you request data on-demand (pull). Webhooks notify you when events happen (push). Most systems use both — APIs for reads/writes and webhooks for real-time event notifications.
What HTTP method do webhooks use?
Webhooks almost always use HTTP POST because they are delivering data (the event payload) to your server. The payload is sent as JSON in the request body. Your endpoint should return 200 OK to acknowledge receipt.
How do webhooks stay secure?
Secure webhooks use: HTTPS (encrypted transport), HMAC signature verification (proves the sender's identity), timestamp validation (prevents replay attacks), and secret rotation. Always verify the signature before processing any webhook payload.
What happens if webhook delivery fails?
Most webhook providers implement retry logic with exponential backoff. If your server returns a non-2xx response or times out, the provider retries after increasing intervals (1 min, 5 min, 30 min, etc.). After multiple failures, the webhook may be disabled.
How do I test webhooks locally?
Use ngrok or similar tunneling tools to expose your localhost to the internet. Run 'ngrok http 3000' to get a public URL that forwards to your local server. Register this URL as your webhook endpoint for testing.
What is webhook signature verification?
The sender creates an HMAC hash of the payload using a shared secret key and includes it in a header (like X-Signature). Your server computes the same hash and compares — if they match, the request is authentic and hasn't been tampered with.
What is idempotency in webhooks?
Idempotency means processing the same webhook event multiple times produces the same result as processing it once. Since webhooks can be delivered multiple times (retries), your handler must check if an event was already processed (using the event ID) before acting on it.
Can webhooks send GET requests?
Technically possible but almost never done. Webhooks use POST because they deliver data (the event payload). GET requests have no body and are meant for retrieving data, not delivering it. Some verification challenges (like Slack URL verification) use GET, but actual event delivery is always POST.

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.