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

#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:
- Async. The sender does not wait for your business logic. Most providers expect a
2xxwithin 5–10 seconds and treat anything else as failure. - 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.
- 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:
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
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
X-Hub-Signature-256: sha256=7d38cdd689735b008b3c702edd92eea23791c5f6HMAC-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
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
X-Signature-Ed25519: 5b1eb05826ec...
X-Signature-Timestamp: 1727398123Different 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
X-Slack-Signature: v0=a2114d57b48eac3...
X-Slack-Request-Timestamp: 1727398123Signed 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 atlocalhost: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:3000withcurl. 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:
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:
| Provider | Timeout | Behaviour on timeout |
|---|---|---|
| Stripe | 30 seconds (target 5 s) | Retry with backoff, up to 3 days |
| GitHub | 10 seconds | Retry up to 5 times over 8 hours |
| Shopify | 5 seconds | Retry up to 19 times over 48 hours |
| Slack | 3 seconds | Mark as failed, retry up to 3 times |
| Discord (interactions) | 3 seconds (for initial response) | Interaction marked failed if exceeded |
| GitHub Actions / dispatch | 10 seconds | Single 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:
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:
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
- GitHub - Webhooks documentation
- Stripe - Webhook signatures verification
- Svix - Webhook idempotency guide
- HMAC - RFC 2104: Keyed-Hashing for Message Authentication
#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
- 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.
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.
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.
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.
Tools Mentioned in This Article
API Response Diff
Compare two API responses to find structural differences.
SSE Tester
Monitor and debug Server-Sent Events (SSE) streams in real-time.
Webhook Tester
Test and inspect webhook delivery and payloads for your API.
WebSocket Tester
Connect, send, and receive messages from WebSocket (WS/WSS) servers.
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.