Webhook 400 Bad Request: Debugging & Fixes

Webhook 400 Bad Request: Debugging & Fixes

The 400 Bad Request Problem A webhook 400 bad request error hits your logs at 2 AM. Your integration was working yesterday. The provider (Stripe, GitHub, Shopify) is sending data, but your handler is rejecting it with HTTP 400. You've checked the endpoint URL. You've verified the domain. But the webhook malformed request keeps coming back. This is one of the most frustrating webhook integration failures because the error message is intentionally vague—HTTP 400 means "I couldn't parse or validate what you sent," but not why. The root cause could be anywhere: raw body parsing, header mismatches, JSON encoding, signature verification, or middleware order. This guide walks you through the exact debugging steps and fixes that work. Prerequisites A running backend service (Node.js, Python, Go, etc.) with a webhook handler already defined Access to your webhook provider's documentation and request logs (Stripe Dashboard, GitHub Settings, etc.) Basic familiarity with HTTP headers, JSON, and raw request bodies A tool to inspect incoming webhook payloads (curl, Postman, or a local webhook inspector) Understanding of your framework's middleware execution order (Express, Fastify, Django, Flask, etc.) Debugging Webhook 400 Bad Request Errors: Step-by-Step Step 1: Capture the Actual Request The first rule: never debug blind. You need to see the exact bytes your handler received. # If you're running locally, start a simple echo server to see raw requests nc -l 3000 Enter fullscreen mode Exit fullscreen mode Or use a webhook inspector to capture the full request (headers, body, method): npx @anonymilyhq/cli listen 3000 Enter fullscreen mode Exit fullscreen mode This command gives you a stable endpoint URL like https://api.anonymily.com/h/my-endpoint that captures all incoming webhooks and forwards them to your local handler. You'll see the exact payload, headers, and timestamps in the web dashboard—crucial for debugging. Step 2: Check Content-Type and Raw Body Handling The most common cause of webhook 400 errors is raw body parsing. Many providers (Stripe, GitHub, Twilio) require the raw, unparsed request body for signature verification. If your middleware parses JSON before you extract the raw body, the signature check fails. // ❌ WRONG: JSON middleware runs first, raw body is lost app.use(express.json()); app.post('/webhook', (req, res) => { const signature = req.headers['x-signature']; // req.body is parsed, but raw bytes are gone const verified = verifySignature(req.body, signature); // FAILS res.sendStatus(200); }); // ✅ CORRECT: Capture raw body before JSON parsing app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-signature']; const rawBody = req.body; // Still raw Buffer const verified = verifySignature(rawBody, signature); // Works const parsed = JSON.parse(rawBody); res.sendStatus(200); }); Enter fullscreen mode Exit fullscreen mode Step 3: Validate JSON Encoding Some providers send Content-Type: application/json; charset=utf-8. Others send application/x-www-form-urlencoded with JSON nested inside. Check your provider's docs. # Python example: handle both encodings from flask import Flask, request import json app = Flask(__name__) @app.route('/webhook', methods=['POST']) def webhook(): content_type = request.headers.get('Content-Type', '') if 'application/json' in content_type: payload = request.get_json() elif 'application/x-www-form-urlencoded' in content_type: # Some providers send JSON in a form field payload = json.loads(request.form.get('payload', '{}')) else: return 'Unsupported Content-Type', 400 if not payload: return 'Empty or invalid JSON', 400 # Process payload return '', 200 Enter fullscreen mode Exit fullscreen mode Step 4: Verify Signature Headers Providers sign webhooks to prove authenticity. A missing or mismatched signature header causes 400 errors if your handler rejects unsigned requests. // Stripe example: signature in X-Stripe-Signature header const crypto = require('crypto'); function verifyStripeSignature(rawBody, signature, secret) { const [timestamp, signedContent] = signature.split(',').map(s => s.split('=')[1]); const computed = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody.toString()}`) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(computed), Buffer.from(signedContent) ); } app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-stripe-signature']; if (!signature) { return res.status(400).json({ error: 'Missing signature header' }); } try { if (!verifyStripeSignature(req.body, signature, process.env.STRIPE_WEBHOOK_SECRET)) { return res.status(400).json({ error: 'Invalid signature' }); } } catch (err) { return res.status(400).json({ error: 'Signature verification failed' }); } res.json({ received: true }); }); Enter fullscreen mode Exit fullscreen mode Step 5: Check Endpoint Configuration Ensure your webhook endpoint URL is exactly what the provider expects: Protocol: HTTPS (not HTTP, unless testing locally) Port: 443 for HTTPS, 80 for HTTP. Don't include :443 in the URL Path: Match the exact path registered with the provider Query parameters: Some providers allow them; others reject URLs with query strings # Test your endpoint with curl, mimicking the provider's request curl -X POST https://yourdomain.com/webhook \ -H "Content-Type: application/json" \ -H "X-Signature: your-signature-here" \ -d '{"event":"test","data":{}}' Enter fullscreen mode Exit fullscreen mode Common Errors and Fixes Error 1: "SyntaxError: Unexpected token { res.json({ received: true }); }); // Generic 404 handler comes AFTER specific routes app.use((req, res) => { res.status(404).json({ error: 'Not found' }); }); // Test the route exists console.log(app._router.stack.filter(r => r.route?.path === '/webhook')); Enter fullscreen mode Exit fullscreen mode Error 2: "413 Payload Too Large" or "Unexpected end of JSON input" Root cause: Your middleware has a size limit that's too small, or the body parser is truncating large payloads. Fix: // Increase payload size limits app.use(express.json({ limit: '50mb' })); app.use(express.raw({ type: 'application/json', limit: '50mb' })); // For Fastify fastify.register(require('@fastify/raw-body'), { limit: '50mb' }); Enter fullscreen mode Exit fullscreen mode Frequently Asked Questions Q: Why does my webhook handler return 200 but the provider shows it as failed? A: The provider likely has a timeout (usually 5–30 seconds). If your handler takes longer, the provider cancels the request mid-response. Return 200 immediately, then process the payload asynchronously in a background job. Also verify you're actually returning 200 and not a 200 with an error body that the provider interprets as failure. Q: Can I test webhook 400 errors locally without deploying? A: Yes. Use a webhook inspector like Anonymily to capture payloads and replay them. The command npx @anonymilyhq/cli listen 3000 forwards webhooks to your local handler, and the dashboard lets you inspect exactly what failed. You can also replay requests to test fixes without waiting for the provider to retry. Q: How do I know if the issue is my code or the provider's webhook? A: Check your provider's webhook logs (Stripe Dashboard, GitHub Settings > Webhooks, etc.). If the provider shows a successful send but your server logs show nothing, the issue is networking or routing. If the provider shows a failed send, capture the request with a webhook inspector to see what was actually sent. Next Steps Start by capturing your next webhook with a local inspector. Run npx @anonymilyhq/cli listen 3000 and check the dashboard at https://anonymily.com—you'll see the exact headers, body, and timestamps. This single step eliminates 80% of guesswork. If you're integrating with GitHub, Stripe, or Shopify, test webhooks locally without ngrok to avoid port-forwarding complexity. For production, remember that Anonymily is a dev tool; for production receive-at-scale with retries and SLA, use Hookdeck or Svix. The 400 error almost always traces back to raw body handling, signature verification, or routing. Debug methodically, capture the actual request, and you'll fix it in minutes.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.