Webhooks are great in theory. In practice, they're a black box that fires HTTP requests into the void and hopes something catches them.
You're integrating Stripe, GitHub, or Shopify webhooks and something isn't working. The payload might be malformed. The signature might be wrong. Your endpoint might be returning a 500. But you can't see any of it because the request happens server-to-server.
Here's how to actually debug webhooks without pulling your hair out.
Step 1: See the Raw Request
Before writing any handler code, you need to see exactly what the webhook provider is sending. Use a request inspection tool:
# Create a temporary inspection endpoint (no signup needed)
curl -s https://webhookvault.anethoth.com/api/v1/demo/create | jq .
This gives you a unique URL. Point your webhook at it, trigger the event, and inspect the captured request:
{
"endpoint_url": "https://webhookvault.anethoth.com/hook/abc123",
"inspect_url": "https://webhookvault.anethoth.com/demo/abc123",
"expires_in": "1 hour"
}
Alternatively, use a simple echo endpoint to mirror any request back to you:
# Returns your request as JSON — method, headers, body, everything
curl -X POST https://webhookvault.anethoth.com/api/v1/echo \
-H 'Content-Type: application/json' \
-H 'Stripe-Signature: t=123,v1=abc' \
-d '{"type": "checkout.session.completed"}'
Response:
{
"method": "POST",
"headers": {
"content-type": "application/json",
"stripe-signature": "t=123,v1=abc"
},
"body": "{\"type\": \"checkout.session.completed\"}",
"timestamp": "2026-04-20T12:00:00Z"
}
Step 2: Verify the Payload Structure
The #1 webhook bug: your code expects a different JSON structure than what arrives.
Common gotchas:
-
Stripe wraps everything in a
data.objectkey -
GitHub puts the event type in a header (
X-GitHub-Event), not the body -
Shopify base64-encodes the HMAC in
X-Shopify-Hmac-SHA256
Always log the raw body before parsing:
from fastapi import FastAPI, Request
import json
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request):
body = await request.body()
headers = dict(request.headers)
# Log EVERYTHING first
print(json.dumps({
"headers": headers,
"body": body.decode('utf-8', errors='replace'),
"content_type": headers.get('content-type', ''),
}, indent=2))
# Then process
payload = json.loads(body)
event_type = payload.get('type', 'unknown')
# ...
Step 3: Signature Verification
Most webhook providers sign payloads so you can verify they're authentic. This is where most integrations silently break.
Stripe Signature Verification
import stripe
import hmac
import hashlib
def verify_stripe_signature(payload: bytes, sig_header: str, secret: str) -> bool:
"""Verify Stripe webhook signature."""
try:
stripe.Webhook.construct_event(payload, sig_header, secret)
return True
except stripe.error.SignatureVerificationError:
return False
Common mistake: Using request.json() (parsed) instead of request.body() (raw bytes) for verification. The signature is computed on the raw bytes.
GitHub Signature Verification
import hmac
import hashlib
def verify_github_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify GitHub webhook signature (SHA-256)."""
expected = 'sha256=' + hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Step 4: Handle Retries Gracefully
Webhook providers retry failed deliveries. If your endpoint is slow or returns 5xx, you'll get duplicate events.
The idempotency pattern:
import sqlite3
def handle_webhook(event_id: str, payload: dict):
conn = sqlite3.connect('webhooks.db')
# Check if we've already processed this event
existing = conn.execute(
'SELECT 1 FROM processed_events WHERE event_id = ?',
(event_id,)
).fetchone()
if existing:
return {"status": "already_processed"}
# Process the event
process_event(payload)
# Mark as processed
conn.execute(
'INSERT INTO processed_events (event_id, processed_at) VALUES (?, datetime("now"))',
(event_id,)
)
conn.commit()
return {"status": "processed"}
Step 5: Replay Failed Webhooks
When your endpoint was down and you missed events, you need to replay them. Most providers have a retry UI in their dashboard, but for development, you can replay captured requests:
# Capture webhooks with WebhookVault, then replay to your local server
curl -X POST https://webhookvault.anethoth.com/api/v1/endpoints/YOUR_ENDPOINT/requests/REQ_ID/replay \
-H 'X-API-Key: your_key' \
-H 'Content-Type: application/json' \
-d '{"target_url": "http://localhost:8000/webhook"}'
Quick Debugging Checklist
- [ ] Can you see the raw request? (Use an inspection tool)
- [ ] Is the Content-Type what you expect? (
application/jsonvsapplication/x-www-form-urlencoded) - [ ] Are you reading raw bytes for signature verification?
- [ ] Is your endpoint returning 200 within 5 seconds? (Most providers timeout at 5-15s)
- [ ] Are you handling duplicate events (idempotency)?
- [ ] Is your webhook secret correct? (Copy-paste errors are common)
- [ ] Is your endpoint accessible from the internet? (Not just localhost)
Testing Locally
For local development, use a tool that echoes requests so you can verify your client code:
# Check what headers your HTTP client is actually sending
curl -s https://webhookvault.anethoth.com/api/v1/headers | jq .
This returns all your request headers with analysis — useful for debugging proxy issues, CORS problems, and auth header formatting.
Resources:
- WebhookVault — Webhook debugging API (free demo endpoints)
- Free HTTP Echo API — Mirror any request back as JSON
- Free Headers Inspector — Analyze your request headers
- Webhook Provider Guides — Setup guides for 20 providers (Stripe, GitHub, Shopify, etc.)
Top comments (0)