DEV Community

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 :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":{}}'

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.