DEV Community

Henry Hang
Henry Hang

Posted on • Originally published at hookcap.dev

What Is a Webhook? A Developer's Guide to Understanding and Debugging Webhooks

What Is a Webhook? A Developer's Guide to Understanding and Debugging Webhooks

If you have integrated with any modern API -- Stripe, GitHub, Shopify, Twilio, Slack -- you have encountered webhooks. They are one of those concepts that seems simple on the surface but causes real headaches when something goes wrong. This guide explains what webhooks are, how they work, and (more importantly) how to debug them when they break.

What Is a Webhook?

A webhook is an HTTP request that a service sends to your server when something happens.

That is it. There is no special protocol, no binary format, no persistent connection. A webhook is a plain HTTP POST request with a JSON body, sent from one server to another.

The difference between a webhook and a normal API call is direction. With a normal API, your code calls the service. With a webhook, the service calls your code.

Polling vs. Webhooks

Without webhooks, you would need to repeatedly ask a service "did anything change?" This is called polling.

POLLING (you ask repeatedly):

Your Server                    Stripe
    |--- Any new payments? ------>|
    |<-------- Nope. -------------|
    |                             |
    |--- Any new payments? ------>|
    |<-------- Nope. -------------|
    |                             |
    |--- Any new payments? ------>|
    |<-- Yes, here is one. ------|
Enter fullscreen mode Exit fullscreen mode
WEBHOOKS (they tell you):

Your Server                    Stripe
    |                             |
    |    (customer pays)          |
    |                             |
    |<-- POST /webhooks/stripe ---|
    |    {payment: details}       |
    |--- 200 OK ---------------->|
Enter fullscreen mode Exit fullscreen mode

Webhooks are more efficient. You do not waste resources asking repeatedly, and you learn about events as soon as they happen instead of on your next poll interval.

How Webhooks Work

The flow for setting up and receiving webhooks has four steps:

Step 1: Register your endpoint. You tell the service "send events to this URL." This is usually done through a dashboard or API call.

# Example: registering a webhook endpoint with Stripe
curl https://api.stripe.com/v1/webhook_endpoints \
  -u sk_test_your_key: \
  -d url="https://yourapp.com/webhooks/stripe" \
  -d "enabled_events[]"="payment_intent.succeeded"
Enter fullscreen mode Exit fullscreen mode

Step 2: The event occurs. A customer makes a payment, a pull request is opened, an order is placed -- whatever the triggering event is.

Step 3: The service sends an HTTP POST to your URL. The request body contains details about the event.

POST /webhooks/stripe HTTP/1.1
Host: yourapp.com
Content-Type: application/json
Stripe-Signature: t=1679012345,v1=abc123...

{
  "id": "evt_1N2abc",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_3N2xyz",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Your server processes the event and responds with 200 OK. The service considers the delivery successful if it receives a 2xx response within a timeout window (typically 5-30 seconds).

Anatomy of a Webhook Request

A webhook is just an HTTP request, but most services include these elements:

Component Purpose Example
Method Always POST POST /webhooks/stripe
Content-Type Usually JSON application/json
Body Event payload {"type": "payment.succeeded", ...}
Signature header Verify authenticity Stripe-Signature: t=...,v1=...
Event type What happened payment_intent.succeeded
Timestamp When it happened "created": 1679012345

Common Failure Modes (And How to Fix Them)

Here is where things get practical. These are the webhook failures developers hit most often, in rough order of frequency.

1. Signature Verification Failure

Most services sign webhook payloads using HMAC-SHA256 or a similar scheme. Your server must verify this signature to confirm the request actually came from the service and was not tampered with.

Symptoms: Your server returns 400/401, or you reject the webhook in code. The service shows "delivery failed" in its dashboard.

Common cause: Your verification code is checking the parsed JSON body instead of the raw request body. Even a single byte difference (like whitespace changes from JSON parsing) invalidates the signature.

// WRONG: body is already parsed
app.use(express.json());
app.post('/webhook', (req, res) => {
  verify(req.body, signature); // req.body is an object, not raw bytes
});

// RIGHT: use raw body for verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  verify(req.body, signature); // req.body is a Buffer
});
Enter fullscreen mode Exit fullscreen mode

Fix: Make sure your webhook route receives the raw request body. In Express, use express.raw() on the webhook route. In other frameworks, check their docs for accessing the raw body before parsing.

2. Endpoint Not Reachable

Symptoms: The service reports delivery failure. Your server logs show nothing (no request received).

Causes:

  • Your server is not running or not publicly accessible
  • DNS is not configured correctly
  • Firewall or security group is blocking incoming requests
  • TLS certificate is expired or invalid (most services require HTTPS)
  • The URL path is wrong (trailing slash matters for some frameworks)

Debugging steps:

# Test your endpoint manually
curl -X POST https://yourapp.com/webhooks/stripe \
  -H "Content-Type: application/json" \
  -d '{"test": true}'

# Check DNS resolution
dig yourapp.com

# Check TLS certificate
openssl s_client -connect yourapp.com:443 -servername yourapp.com
Enter fullscreen mode Exit fullscreen mode

3. Timeout

Symptoms: The service reports delivery failed, but your server did receive and process the request. You see the webhook in your logs, but the service shows a failure.

Cause: Your handler took too long to respond. Most services have a timeout between 5 and 30 seconds. If your handler does heavy work (database writes, external API calls, sending emails) synchronously before responding, you will hit this.

Fix: Acknowledge the webhook immediately with a 200 response, then process asynchronously.

# Python/Flask example
@app.route('/webhook', methods=['POST'])
def handle_webhook():
    event = verify_signature(request)

    # Queue for async processing
    task_queue.enqueue(process_event, event)

    # Respond immediately
    return '', 200
Enter fullscreen mode Exit fullscreen mode

4. Duplicate Delivery

Symptoms: Your system processes the same event multiple times. A customer gets charged twice, or receives duplicate emails.

Cause: Webhook senders retry on failure (or perceived failure -- see timeouts above). If your first handler execution timed out but still completed, the retry delivers the same event again.

Fix: Make your handler idempotent. Store the event ID and check for duplicates before processing.

app.post('/webhook', async (req, res) => {
  const event = verifyAndParse(req);

  // Check if we already processed this event
  const existing = await db.query(
    'SELECT id FROM processed_events WHERE event_id = $1',
    [event.id]
  );

  if (existing.rows.length > 0) {
    return res.status(200).json({ already_processed: true });
  }

  // Process and record
  await processEvent(event);
  await db.query(
    'INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())',
    [event.id]
  );

  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

5. Payload Parsing Errors

Symptoms: Your server returns 500. Logs show JSON parse errors or missing fields.

Causes:

  • The webhook body is not valid JSON (rare, but happens with some services that send form-encoded data)
  • Your code expects fields that do not exist for this event type
  • The payload structure changed (API version mismatch)

Fix: Validate the payload structure before accessing nested fields. Use optional chaining or explicit checks.

// Fragile
const email = event.data.object.customer.email;

// Defensive
const email = event?.data?.object?.customer?.email;
if (!email) {
  console.warn('No customer email in event', event.id);
}
Enter fullscreen mode Exit fullscreen mode

A Debugging Workflow That Works

When a webhook integration is not working, follow this order:

1. Verify the webhook is being sent. Check the sender's dashboard. Stripe, GitHub, Shopify, and most services have a webhook delivery log that shows every attempt, the response code, and the response body.

2. Verify the webhook reaches your server. Check your server logs for the incoming request. If there is nothing, the problem is network-level (DNS, firewall, TLS, routing).

3. Capture and inspect the raw payload. Use a webhook testing tool to see exactly what is being sent. This eliminates guesswork about the payload structure.

Tools for this:

  • webhook.site -- instant throwaway URL, no signup needed
  • HookCap (hookcap.dev) -- real-time streaming with replay capability
  • ngrok localhost:4040 -- inspect requests passing through the tunnel

Point the webhook sender at your testing URL temporarily, trigger an event, and examine the full request: method, headers, body, timing.

4. Test signature verification in isolation. Take the raw payload and headers from step 3 and run them through your verification code locally. This isolates whether the problem is in signature verification or downstream processing.

5. Test your handler with a known-good payload. Use curl or a replay tool to send the captured payload directly to your local server:

curl -X POST http://localhost:3000/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=1679012345,v1=..." \
  -d @captured-payload.json
Enter fullscreen mode Exit fullscreen mode

6. Check for non-obvious failures. If the webhook is received and your server returns 200, but the expected side effects are not happening, check:

  • Is the event type handled in your switch/case? Unhandled types often silently succeed.
  • Is the database write failing silently?
  • Is there an async processing queue, and is it running?

Retries and Reliability

Most webhook senders implement retry logic. If your server does not return a 2xx response, the sender will try again. Typical retry schedules:

  • Stripe: Up to 3 days, with exponential backoff (first retry after ~1 hour)
  • GitHub: 1 retry after a short delay
  • Shopify: 19 retries over 48 hours

This means your server does not need to have 100% uptime to receive all webhooks -- short outages are covered by retries. But your handler must be idempotent (see failure mode #4 above), because retries mean the same event can be delivered more than once.

Security Considerations

Three rules for handling webhooks securely:

  1. Always verify signatures. Never skip signature verification, even in development. A webhook endpoint is a publicly accessible URL. Without verification, anyone can send fake events to it.

  2. Do not trust the payload blindly. After verifying the signature, fetch the referenced resource from the API to confirm its state. This protects against replay attacks and stale data.

  3. Use HTTPS. Webhook payloads often contain sensitive data (customer emails, payment amounts, order details). Always use HTTPS endpoints, and reject any service that sends webhooks over plain HTTP.

Summary

Webhooks are simple in concept (one server sends an HTTP POST to another) but require care in implementation. The main things to get right:

  • Verify signatures using the raw request body
  • Respond quickly (200 OK), process asynchronously
  • Make handlers idempotent to survive retries
  • Use a capture tool during development to inspect payloads before writing handler code
  • Follow the debugging workflow: sender logs, then network, then payload, then handler

Once the integration is solid, webhooks are one of the most reliable and efficient patterns for connecting services. The debugging is a one-time cost that pays off for the lifetime of the integration.

Top comments (0)