I have spent hundreds of hours debugging webhooks. Stripe payments not processing. GitHub Actions not triggering. Shopify orders getting lost.
Every time, I wished I had a systematic approach instead of flailing around. So I wrote one.
The Checklist
Step 1: Verify the webhook is actually being sent
Before debugging YOUR code, confirm the sender is actually firing:
- Stripe: Dashboard > Developers > Webhooks > Recent events
- GitHub: Settings > Webhooks > Recent Deliveries
- Shopify: Settings > Notifications > Webhooks > Check responses
If the sender shows no attempts, the issue is on their side (wrong event type selected, webhook disabled, etc).
Step 2: Capture the raw request
Do not guess what is being sent. Inspect the actual payload:
# Create a temporary capture endpoint (free, no signup)
curl -X POST https://webhookvault.anethoth.com/api/v1/demo/endpoints
# Response: {"url": "https://webhookvault.anethoth.com/hook/abc123", ...}
# Point your webhook sender to this URL temporarily
Or use the echo endpoint to see exactly what arrives:
curl -X POST https://webhookvault.anethoth.com/api/v1/echo \
-H "Content-Type: application/json" \
-H "X-Custom-Header: test" \
-d ''{"event": "test"}''
Step 3: Check your response code
Webhook senders care about your response:
| Code | Meaning | Sender behavior |
|---|---|---|
| 200-299 | Success | Marks delivered |
| 301-399 | Redirect | Most senders DONT follow redirects |
| 400-499 | Client error | Retries then disables |
| 500-599 | Server error | Retries with backoff |
| Timeout | No response | Retries with backoff |
Common gotcha: Returning 200 after your handler crashes. Many frameworks catch exceptions and return 200 by default.
Step 4: Verify the signature
If you are not verifying webhook signatures, stop everything and add it. An unverified webhook endpoint is an unauthenticated API that anyone can call.
import stripe
@app.post("/stripe-webhook")
async def stripe_webhook(request: Request):
payload = await request.body()
sig = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(payload, sig, webhook_secret)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
handle_event(event)
Step 5: Handle idempotency
Webhooks can fire multiple times for the same event. Your handler MUST be idempotent:
@app.post("/webhook")
async def webhook(request: Request):
data = await request.json()
event_id = data["id"]
if db.execute("SELECT 1 FROM processed_events WHERE event_id = ?", (event_id,)).fetchone():
return {"ok": True, "status": "already_processed"}
process_event(data)
db.execute("INSERT INTO processed_events (event_id) VALUES (?)", (event_id,))
return {"ok": True}
Step 6: Handle out-of-order delivery
Webhooks are NOT guaranteed to arrive in order. Design your handler to be order-independent, or use event timestamps to detect and handle out-of-order delivery.
Quick debugging toolkit
Free tools you can use right now:
| Tool | What it does |
|---|---|
| WebhookVault Echo | Mirror any HTTP request back as JSON |
| WebhookVault Headers | Inspect your request headers |
| WebhookVault Demo | Create temporary capture endpoints |
What is the worst webhook debugging experience you have had? Mine was a Shopify webhook that only failed on orders over 999.99 because of a locale-dependent number formatting issue.
Top comments (0)