Debug Webhooks: A Practical Troubleshooting Guide (2026)

Troubleshoot webhooks step by step: when an event never arrives, when it arrives but fails (signature mismatch, wrong content-type, body parsing), plus duplicates, ordering, timeouts and retries. Inspect, forward to localhost, and replay.

A webhook that "doesn't work" almost always fails at one of two points: it never reaches your handler, or it reaches your handler and the handler chokes on it. The trick to debugging webhooks quickly is figuring out which of those two it is — and then you only have a handful of likely causes to check.

This guide walks the failure modes in order, with the fastest way to confirm each one. If you haven't already, the companion piece on how to test webhooks covers the setup; here we focus on what to do when something's broken.

First: is it arriving at all?

Before you touch your code, find out whether the request is even leaving the provider. Most providers keep a delivery log — Stripe, GitHub and Shopify all show every attempt and the HTTP response they received. Look there first.

The URL is wrong. This is the boring, common one. A trailing slash, the wrong path (/webhook vs /webhooks), http instead of https, or a stale endpoint from a previous environment. Copy the URL straight from your dashboard and compare it character for character with what the provider has saved.

The provider isn't configured to send the event. Many providers only deliver the event types you subscribe to. If you wired up payment_intent.succeeded but you're triggering a checkout.session.completed, nothing arrives — and that's correct behaviour, not a bug. Confirm the event type is enabled.

A firewall or private network is blocking it. If your endpoint lives on localhost, a private LAN, or behind a corporate firewall, the provider physically can't reach it. Public providers can only call public URLs. This is exactly what Webhook Relay solves: it exposes a stable public endpoint and the relay agent forwards each request inbound to your private destination, no open ports required.

Confirm the request leaves the provider. Point the provider at a free Webhook Bin and trigger the event. If it shows up in the bin, the provider is sending correctly and the problem is downstream (your URL, server or firewall). If it doesn't show up in the bin, the problem is on the provider's side — wrong event subscription, disabled endpoint, or an account/auth issue.

It's arriving but failing

Now the request is reaching you and something downstream rejects it. Inspect the exact request first — in a Webhook Bin or your Webhook Relay request log — so you're debugging facts, not assumptions.

Signature mismatch

If your handler returns 401/400 on a "valid" webhook, signature verification is the usual suspect. The number-one cause: you're verifying against a parsed-and-re-serialized body instead of the raw bytes. Re-encoding JSON changes whitespace and key ordering, and the HMAC no longer matches.

Checklist:

  • Verify against the raw request body, exactly as received.
  • Use the correct signing secret (the per-endpoint secret, not your API key — they're often different).
  • Read the right header. GitHub sends X-Hub-Signature-256 (SHA256); Stripe and Shopify also use SHA256 but format the header differently.

Walk through the mechanics in how to verify a webhook signature, and use the free HMAC generator & verifier to paste the raw body, the secret and the header value and confirm they actually match — that isolates whether the problem is your verification code or the data feeding it.

Wrong content-type

Your framework picks a body parser based on the Content-Type header. If the provider sends application/x-www-form-urlencoded (Twilio, some Slack endpoints) but your code assumes JSON — or vice versa — you'll get an empty object or a parse error even though the body is perfectly fine. Check the header in the captured request and match your parser to it.

Body parsing

Even with the right content-type, the structure may not be what you expect. Some providers wrap the real payload in an envelope ({ "data": { "object": {...} } }), send a JSON string inside a form field, or batch multiple events into an array. Inspect a real captured payload before writing field accessors, and guard against missing keys instead of assuming a shape.

Duplicates and out-of-order events

Webhook delivery is at-least-once, not exactly-once. Two realities follow:

  • Duplicates. A provider that times out waiting for your 2xx will retry — even if your handler actually succeeded. Make handlers idempotent: key off the event ID (or a hash of the payload) and skip anything you've already processed.
  • Ordering. Events can arrive out of order. A subscription.updated can land before the subscription.created. Don't assume sequence; reconcile against the provider's current state, or fetch the latest object by ID when order matters.

Timeouts and retries

Most providers give your endpoint a short window (often a few seconds) to respond, and treat anything that's slow or non-2xx as a failure to retry.

  • Respond fast, work later. Acknowledge with 2xx immediately, then do heavy lifting (DB writes, third-party calls) asynchronously. A handler that does all its work before responding is the most common cause of phantom retries and duplicates.
  • Return the right status. A 500 invites retries; a 400 may make the provider give up. Return 2xx once you've safely accepted the event.

The debugging workflow

A loop that makes all of the above fast:

  1. Inspect — send the webhook to a Webhook Bin to capture the exact method, headers, query and body. Now you know what's really being sent.
  2. Forward to localhost — run the relay agent and forward into your dev environment so you can set breakpoints in your IDE against live events:
    relay forward --bucket my-app http://localhost:8080/webhook
    
    The agent connects outbound, so there's nothing to expose and the public URL stays stable while you iterate.
  3. Replay — capture a request once and resend it as many times as you need. You re-run the handler without re-triggering the source event (no need to create another payment to test the payment.succeeded path).

Inspect, forward, replay — repeat until the handler is green.

Common pitfalls, at a glance

  • Verifying the signature against a re-serialized body instead of the raw bytes.
  • Assuming JSON when the provider sent form-encoded (check Content-Type).
  • Doing slow work before responding, triggering retries and duplicates.
  • Non-idempotent handlers that double-process retried events.
  • A stale or private endpoint URL the provider can't actually reach.

Start debugging

Open a Webhook Bin to see the exact request a provider is sending, or create a free account to forward live webhooks into your IDE and replay them against your handler until it works.