DEV Community

Jack
Jack

Posted on • Originally published at anethoth.com

How to Debug Webhooks Without Losing Your Mind

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 .
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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.object key
  • 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')
    # ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

Quick Debugging Checklist

  • [ ] Can you see the raw request? (Use an inspection tool)
  • [ ] Is the Content-Type what you expect? (application/json vs application/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 .
Enter fullscreen mode Exit fullscreen mode

This returns all your request headers with analysis — useful for debugging proxy issues, CORS problems, and auth header formatting.


Resources:

Top comments (0)