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
Or use a webhook inspector to capture the full request (headers, body, method):
npx @anonymilyhq/cli listen 3000
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);
});
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
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 });
});
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
:443in 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":{}}'
Common Errors and Fixes
Error 1: "SyntaxError: Unexpected token < in JSON at position 0"
Root cause: Your endpoint is returning HTML (likely a 404 page or error page) instead of JSON. The provider is sending a valid webhook, but your server is routing it incorrectly.
Fix:
// Verify your route is registered BEFORE generic error handlers
app.post('/webhook', (req, res) => {
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'));
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' });
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.
Comments
No comments yet. Start the discussion.