Skip to main content
AllDevToolsHub
2026-05-24
Last reviewed: Aug 2026
NETWORKING
Est Read: 12_MIN

How to Debug Webhooks: A Complete Developer Playbook (2026)

How to Debug Webhooks: A Complete Developer Playbook (2026)
Processing_Node: 01

#1Webhook debugging: the practical loop

What we tested: We sent requests from our browser-based HTTP tools to test endpoints on localhost and remote servers. Header inspection, status code handling, and CORS preflight behavior were verified against Express.js, Fastify, and nginx backends.

Webhook bugs usually come down to the same few failures: the request never arrived, the signature failed, the handler was too slow, or the event was processed twice.

The debug loop is straightforward: capture the raw payload, verify the signature, reproduce locally, and then fix the handler with the real request in hand.

#2What a Webhook Actually Is

A webhook is a one-way HTTP POST that one service makes to a URL you own, when something happens. That is the whole thing. The complexity comes from three properties of that pattern:

  1. Async. The sender does not wait for your business logic. Most providers expect a 2xx within 5–10 seconds and treat anything else as failure.
  2. Untrusted at the transport layer. Anyone on the internet can POST to your URL. The sender proves authenticity by signing the payload with a shared secret.
  3. At-least-once delivery. Every major provider retries failed deliveries (Stripe up to 3 days, GitHub for 8 hours, Shopify for 48 hours). Your handler must be idempotent.

If you keep those three properties in mind, every webhook bug fits into one of four buckets: it did not arrive, it arrived but signature failed, it arrived but you were too slow, or it arrived more than once and you processed it twice.

#2Step 1, Capture the Raw Payload

When a webhook misbehaves, the first thing to do is see exactly what hit your URL, byte-for-byte, before any framework parses it.

The fastest way in 2026 is a browser-based webhook inspector. The AllDevToolsHub Webhook Tester gives you a unique URL you can paste into any provider's dashboard. Every incoming request shows up live with:

  • Method, path, query parameters
  • Every request header (in original casing)
  • The raw body, exactly as bytes, JSON pretty-printed but not normalized
  • Timing (received-at timestamp, ms since previous request)
  • Source IP and any forwarded-for headers

The reason "raw body, exactly as bytes" matters: webhook signatures are computed over the byte sequence the sender shipped. If your framework silently re-serializes the JSON (different key order, different whitespace), the signature will not validate even though the data is "the same." More on this in the signature section.

Capture-first beats reading docs first. Once you have the actual headers and body in front of you, every subsequent question, which signature scheme does this provider use? what timestamp tolerance? does it include the path?, becomes a five-minute look-up against ground truth.

#2Step 2, Verify the Signature

Signature verification is where 60 % of webhook bugs live. Every provider does it slightly differently. Get this wrong and you either reject legitimate webhooks (false positive) or accept forgeries (security incident).

The common shape across providers:

protocol
sig = HMAC-SHA256(secret, <some string built from the request>)

What differs is the some string. Below is the scheme for the five providers most teams integrate against. Each has a dedicated guide that handles the verification end-to-end.

#3Stripe, Stripe-Signature header

protocol
Stripe-Signature: t=1727398123,v1=5257a869e7ecebeda32...

The signed payload is ${timestamp}.${rawBody}. Compute HMAC-SHA256(webhookSecret, signedPayload), compare against v1. Also reject if timestamp is more than 5 minutes off, Stripe's defence against replay.

The most common Stripe bug: your framework parsed and re-serialized the body, so the bytes you HMAC are not the bytes Stripe HMACed. The fix in Express is express.raw({ type: "application/json" }) on the webhook route only, before express.json() runs anywhere else. Verify your code with the Stripe Webhook Tester, which speaks the exact t=...,v1=... scheme.

#3GitHub, X-Hub-Signature-256 header

protocol
X-Hub-Signature-256: sha256=7d38cdd689735b008b3c702edd92eea23791c5f6

HMAC-SHA256(secret, rawBody), prefixed with sha256=. No timestamp in the signature, so GitHub relies on TLS and your URL secrecy for replay protection. Also sends a legacy X-Hub-Signature: sha1=..., ignore it in 2026.

Compare in constant time (e.g. crypto.timingSafeEqual), a === comparison leaks signature bytes through timing. The GitHub Webhook Tester covers the exact verification dance plus the GitHub-specific events (X-GitHub-Event, X-GitHub-Delivery).

#3Shopify, X-Shopify-Hmac-Sha256 header

protocol
X-Shopify-Hmac-Sha256: aHR0cHM6Ly9leGFtcGxlLmNvbQ==

HMAC-SHA256(secret, rawBody) base64-encoded. Also sends X-Shopify-Shop-Domain so multi-tenant apps can route. Replay protection comes from the topic + shop-domain pair and your own dedupe layer (Shopify does not timestamp its signatures). See the Shopify Webhook Tester for the working example.

#3Discord, X-Signature-Ed25519 header

protocol
X-Signature-Ed25519: 5b1eb05826ec...
X-Signature-Timestamp: 1727398123

Different family entirely: Ed25519 signatures over timestamp + rawBody, verified with Discord's public key (not a shared secret). You must respond to a PING (type 1) with a PONG (type 1) within 3 seconds during interaction registration, or Discord refuses to enable your endpoint. The Discord Webhook Tester handles the Ed25519 verification, which most generic HMAC tools cannot.

#3Slack, X-Slack-Signature header

protocol
X-Slack-Signature: v0=a2114d57b48eac3...
X-Slack-Request-Timestamp: 1727398123

Signed payload is v0:${timestamp}:${rawBody}. Compute HMAC-SHA256(signingSecret, signedPayload), compare to the part after v0=. Reject if timestamp is more than 5 minutes old. Slack's signing secret is per-app, not per-workspace. Verify with the Slack Webhook Tester.

The pattern across all five: read the raw bytes, build the signed string the way the provider specifies, HMAC it, constant-time compare. The bugs are almost always in step 1 (framework re-parsed the body) or step 2 (you misread the docs for what string gets signed).

#2Step 3, Reproduce Locally with a Tunnel

Once you have a captured payload, you want to replay it against your local code in a tight loop without re-clicking buttons in the provider's dashboard.

The two-and-a-half good options in 2026:

  • ngrok / cloudflared / tailscale funnel, point a public URL at localhost:3000. Configure the provider's webhook URL once, hit your endpoint repeatedly. Trade-off: every call is a real provider call, with rate limits and side effects.
  • Replay from capture, take the raw request you captured in step 1 and POST it to localhost:3000 with curl. Fast, repeatable, side-effect-free. The catch: you must recompute the signature (or temporarily disable verification) because the payload's timestamp has aged out of tolerance.

A typical replay command:

bash
curl -X POST http://localhost:3000/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=$(date +%s),v1=$(node sign.js)" \
  --data-binary @captured-body.json

--data-binary is non-negotiable here: --data will strip newlines and break your signature.

For Stripe specifically, stripe listen --forward-to localhost:3000/webhooks/stripe does both jobs at once (tunnel + replay), and is the right answer if Stripe is your only webhook source. For everyone else, the capture-and-replay flow with a generic webhook tester plus curl is faster.

#2Step 4, Respond Fast, Process Slow

Every major provider has a webhook timeout. The common ones in 2026:

ProviderTimeoutBehaviour on timeout
Stripe30 seconds (target 5 s)Retry with backoff, up to 3 days
GitHub10 secondsRetry up to 5 times over 8 hours
Shopify5 secondsRetry up to 19 times over 48 hours
Slack3 secondsMark as failed, retry up to 3 times
Discord (interactions)3 seconds (for initial response)Interaction marked failed if exceeded
GitHub Actions / dispatch10 secondsSingle retry

Five seconds is the safe ceiling for end-to-end processing if you want zero retries from anyone. The correct pattern is the enqueue-then-200 flow:

javascript
app.post("/webhooks/stripe", express.raw({ type: "*/*" }), async (req, res) => {
  if (!verifySignature(req)) return res.sendStatus(401);

  await queue.enqueue("process-stripe-event", {
    eventId: req.headers["stripe-signature"],
    body: req.body.toString(),
  });

  res.sendStatus(200);  // tell Stripe we have it
});

Verification, enqueue, return 200, measured in tens of milliseconds. The actual business logic (database writes, email sends, downstream API calls) runs out-of-band in the worker. This single pattern eliminates the "Stripe says delivered, my system never processed it" class of bug entirely.

#2Step 5, Make Your Handler Idempotent

At-least-once delivery means you will see the same event more than once. Stripe documents this explicitly; GitHub re-delivers retries; Shopify queues stay full for 48 hours. Your handler must produce the same outcome whether it sees the event once or one hundred times.

The two-line pattern:

sql
INSERT INTO processed_webhooks (event_id, source)
VALUES ($1, 'stripe')
ON CONFLICT (event_id, source) DO NOTHING
RETURNING event_id;

If RETURNING gives you a row, you are the first to process this event, go run the side effects. If RETURNING is empty, this is a duplicate, return 200 and exit. The provider's event ID (Stripe-Event-Id, X-GitHub-Delivery, etc.) is your dedupe key; do not invent your own.

Variants on this pattern: a Redis SETNX with TTL, a unique constraint plus catch-and-ignore, a Postgres advisory lock. They all collapse to the same idea: a single source of truth for "have I processed this event ID yet."

#2The Eight Webhook Failure Modes

When a webhook ticket lands, walk this list in order. The first hit is usually the answer.

#31. The request never arrived

Your handler logs are empty. Check the provider's dashboard delivery log first, both Stripe and GitHub show every attempt with response code and body. If the provider says "delivered, got 200," your logging is broken. If it says "connection refused" or "timeout," your URL is unreachable.

Common causes: firewall rule, expired TLS cert, IP allowlist that excludes the provider's outbound range, a tunnel that died, the wrong URL in the dashboard.

#32. Signature verification fails

The provider's bytes do not match the bytes you HMAC. Number-one cause: a framework middleware ran JSON.parse(body) and your verification ran HMAC over the re-serialized JSON, which has different whitespace and key order than what the provider signed.

Fix: receive the raw body for webhook routes only. Express: express.raw({ type: "*/*" }). FastAPI: read await request.body() before pydantic parsing. Go: io.ReadAll(r.Body) before any decode.

#33. The clock is off

Stripe and Slack reject signatures whose timestamp differs from server time by more than 5 minutes. If your server's clock drifts, every webhook fails until NTP catches up. Modern containers have this set up; old VMs do not always.

#34. The handler is too slow

You returned 200, but only after 12 seconds. Provider already timed out and marked the delivery failed; you process the event, then process it again on retry, and your "idempotency" was an afterthought. Move to enqueue-then-200.

#35. Same event processed twice (or N times)

You forgot the idempotency check, or the dedupe table is in a different database from the side-effect table and the two transactions are not coordinated. Either put the dedupe row in the same transaction as the side effect, or use a queue with a deduplication window.

#36. The wrong event handler ran

You match on event type via a string in the payload but the provider added new event types and yours falls through to a generic handler. Default: log unrecognised event types and 200, do not error, if you error, the provider will retry forever.

#37. Network egress timed out on a downstream call

Your handler succeeds against the database, but the email-send call to a third party times out for 30 seconds, and you blocked on it. Provider marked the original webhook failed and retried; meanwhile, the second attempt also blocked on the same downstream. Solution: never make synchronous downstream calls from the webhook request; queue them.

#38. The webhook secret was rotated and you missed it

The provider issued a new signing secret but your config still has the old one. Every request fails verification. The defence is a feature, not a fix: support two secrets in your verifier (current and previous) during rotation windows, and rotate the deploy first, the provider second.

#2Frequently Asked Questions

#3How do I debug a webhook that never reaches my server?

Start in the provider's delivery log, not your logs. Stripe, GitHub, Shopify, and Slack all show every attempt with the response code and body the receiver returned. If the provider says "200 OK," your logging or routing on the receiving side is broken; if it says "connection refused" or "timeout," the request never reached your code and the problem is at the network layer, firewall, TLS cert, IP allowlist, dead tunnel, wrong URL. As a quick first move, point the provider at the Webhook Tester instead of your own URL; if requests arrive there, the provider side is fine and the issue is in your stack.

#3Why does my webhook signature verification keep failing?

Almost always because the bytes you are HMACing are not the bytes the provider HMACed. The number-one cause is a JSON body parser running before your verifier, which re-serializes the payload with different whitespace or key order. Fix: receive the raw body bytes on the webhook route only, before any parser touches it (express.raw, await request.body() in FastAPI, io.ReadAll(r.Body) in Go). The number-two cause is signing the wrong string, Stripe signs timestamp.body, Slack signs v0:timestamp:body, GitHub signs just body. Use a tester that speaks the provider's exact scheme: Stripe, GitHub, Shopify, Discord, or Slack.

#3How long do I have to respond to a webhook?

It depends on the provider, but 5 seconds is the safe ceiling for everyone. Slack and Discord interactions time out at 3 seconds, Shopify at 5, GitHub at 10, Stripe at 30 (though Stripe's own docs recommend 5 s). The right pattern in every case is enqueue-then-200: verify the signature, push the payload onto a queue, return 200 immediately, process the work in a background worker. That keeps your response time in the tens of milliseconds regardless of how slow the downstream work is.

#3How do I make a webhook handler idempotent?

Use the provider's event ID as a dedupe key (Stripe-Event-Id, X-GitHub-Delivery, etc., never invent your own) and write an "I have seen this" row to a database before running side effects, in the same transaction. The classic two-line shape is INSERT INTO processed_webhooks (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id;, if you get a row back, you are the first processor; if not, the event is a duplicate and you return 200 without re-running. This single pattern solves Stripe's at-least-once delivery, GitHub's retries, and Shopify's 48-hour redelivery queue.

#3Can I test webhooks locally without exposing my machine to the internet?

Yes, capture the real payload once, then replay it locally with curl against localhost for as many iterations as you need. Use the Webhook Tester to grab the raw body and full header set from a live provider event, save the body to a file, then curl -X POST http://localhost:3000/webhooks/... --data-binary @body.json -H "...". Use --data-binary, not --data, or curl will strip newlines and break your signature. For signed payloads, you will need to recompute the signature against the new timestamp (most providers reject signatures older than 5 minutes), or temporarily disable verification in dev. Stripe users can do both at once with stripe listen --forward-to.


The webhook bug pyramid is short and stable: most outages are signature mismatches from re-parsed bodies or timeouts from synchronous downstream calls. Capture the raw request, verify with the provider's exact scheme, enqueue-then-200, dedupe on the provider's event ID, that single playbook eliminates ~90 % of webhook tickets.

Capture and inspect live webhook traffic now at the AllDevToolsHub Webhook Tester. For provider-specific verification scaffolding, jump straight to Stripe, GitHub, Shopify, Discord, or Slack. Pair it with the JWT Tokens Explained guide for the surrounding auth surface.

Written by Rahul Jalavadiya, founder of AllDevToolsHub. All tools run locally in your browser.

#2Sources / Further reading

#2Try These Tools

Quick Summary

>- A practical, end-to-end playbook for debugging webhooks in 2026. Covers capturing the payload, inspecting signatures (HMAC, Stripe-Signature, GitHub X-Hub-Signature-256, Shopify, Discord, Slack), reproducing locally with tunnels, handling retries and idempotency, timing out fast, and the eight webhook failure modes that catch teams in production.

Key Takeaways

Key Takeaways

  • Webhook debugging starts with verifying signature authenticity — always validate HMAC signatures before processing payloads.
  • Idempotency is critical — webhooks can be delivered multiple times, so your handler must be safe to run repeatedly with the same payload.
  • Use structured logging with webhook event IDs to trace delivery, processing, and acknowledgment across your system.
Use Cases

When to use it

  • Debugging why a Stripe payment webhook is returning 500 errors in production.
  • Implementing webhook signature verification for GitHub push events.
  • Building an idempotent webhook handler that safely processes duplicate deliveries.
Watch out

Common Mistakes

  • Not verifying webhook signatures — an attacker can send fake webhook payloads to your endpoint.
  • Processing webhooks synchronously — long-running processing can cause timeouts and missed deliveries. Queue the work and acknowledge immediately.
  • Not implementing idempotency — webhook providers retry on failure, so your handler may receive the same event multiple times.
FAQ

How to Debug Webhooks: A Complete Developer Playbook (2026), Frequently Asked

How do I verify a webhook signature?

Compute an HMAC-SHA256 of the raw request body using your webhook secret, then compare it to the signature header (e.g., Stripe-Signature, X-Hub-Signature-256). Use a constant-time comparison function to prevent timing attacks.

What should I do if my webhook endpoint is down?

Most providers (Stripe, GitHub, Shopify) retry failed deliveries with exponential backoff for up to 72 hours. Ensure your endpoint returns 200 quickly and processes the payload asynchronously via a queue.

RJRahul JalavadiyaFounder & Lead Engineer
Published 2026-05-24Last reviewed 2026-08-23

Tools Mentioned in This Article

Tools, tactics, and toughened-up tips, once a week

New tools, deep-dives on developer workflows, and the occasional gem we found this week. No spam, no tracking. Unsubscribe anytime.

Found an error or have feedback?

We correct errors quickly and document changes in our changelog. Report issues at support@alldevtoolshub.com.

Last reviewed: 2026-08-23
Security Memo
AT

Rahul Jalavadiya

Engineering Protocol V1

Specializing in local-first architecture and Zero-Trust developer workflows. No data leaves the machine.