CanHook
Features Pricing Docs FAQ Blog Log in Get started free

Webhook Fires But Nothing Happens? It's Probably a Content-Type Mismatch

By The CanHook Team · August 28, 2026 · 1029 words
In short

Your handler almost certainly assumes the wrong body format. If the sender's Content-Type doesn't match what your parser expects — JSON vs application/x-www-form-urlencoded vs multipart/form-data — the body silently comes back empty or unparsed, even though the sender still saw a 200.

Your webhook sender shows a 200 in its delivery log, your access log confirms the request landed, and your handler still does nothing. Nine times out of ten the problem isn't your business logic — it's a Content-Type mismatch between what the sender posted and what your code assumed it would receive. The body isn't missing. It's just never being parsed.

What "fires but does nothing" actually means

A 200 status only confirms your server accepted the connection and returned a response. It says nothing about whether your handler successfully parsed the body and ran your logic. Most webhook frameworks return 200 as soon as the request is received, before any application code touches the payload — so a parsing failure that gets swallowed by a try/catch, or a body-parser middleware that quietly skips an unrecognized type, produces exactly the symptom you're seeing: success on the sender's side, silence on yours.

The most common cause: parsing the wrong format

Not every webhook sender uses JSON. Stripe, GitHub, and Shopify send application/json. Older integrations — PayPal IPN, some Twilio callbacks, some legacy CRM and payment gateways — still send application/x-www-form-urlencoded, the same key=value format an HTML form posts. If your handler assumes JSON and gets form-encoded data (or the reverse), json_decode() returns null, or your form parser sees a single opaque JSON string as one field with no keys.

The two requests look almost identical on the wire except for one header:

curl -X POST https://canhook.com/h/abc123def456 \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data "event=payment.succeeded&amount=2000"

curl -X POST https://canhook.com/h/abc123def456 \
  -H "Content-Type: application/json" \
  --data '{"event":"payment.succeeded","amount":2000}'

Same destination, same intent, structurally different bodies. A handler written for one silently fails on the other.

The PHP multipart/form-data trap

This one catches even experienced developers. When a request arrives as multipart/form-data, PHP's built-in parser consumes the raw request stream to populate $_POST and $_FILES — and php://input is not available for that content type. If your handler always reads php://input expecting raw JSON, a multipart payload gives you an empty string, every time, with no error.

<?php
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';

if (str_starts_with($contentType, 'application/json')) {
    $payload = json_decode(file_get_contents('php://input'), true);
} elseif (str_starts_with($contentType, 'application/x-www-form-urlencoded')) {
    $payload = $_POST; // already parsed by PHP, php://input is also fine here
} elseif (str_starts_with($contentType, 'multipart/form-data')) {
    $payload = $_POST; // php://input is unavailable for this type
} else {
    http_response_code(415);
    exit;
}

Branching on the header explicitly, instead of assuming one format everywhere, turns a silent failure into either a correct parse or a loud 415.

Node and Express: body-parser only parses what you configure

Express's express.json() and express.urlencoded() middleware each only run when the incoming Content-Type matches the type they're registered for. A vendor that sends a nonstandard type like application/vnd.vendor+json won't match the default application/json filter, and the middleware skips the body entirely — req.body comes back as an empty object with no error thrown.

app.post(
  '/webhooks/vendor',
  express.json({ type: ['application/json', 'application/vnd.vendor+json'] }),
  (req, res) => {
    if (!req.body || Object.keys(req.body).length === 0) {
      return res.status(400).send('empty or unparsed body');
    }
    // handle req.body
    res.sendStatus(200);
  }
);

Widening the type option to match what the sender actually declares fixes it in one line — but you have to know what they're declaring first.

Capture the raw request before you touch your handler

Guessing at the Content-Type wastes a debugging session. Point the sender at a CanHook catch URL instead of your production handler and it stores the method, every header, the raw body, and the Content-Type exactly as received — no parsing, no assumptions. You can then compare that raw capture against what your handler code expects, byte for byte, instead of inferring it from a stack trace or an empty log line. See getting started for creating an endpoint, and check plan limits if you need longer retention while you track down an intermittent mismatch.

A content-type-aware parsing checklist

  1. Read the Content-Type header before choosing a parser — never assume one format for every sender.
  2. Match on the type prefix (str_starts_with / startsWith), since real headers often carry a charset suffix like application/json; charset=utf-8.
  3. Return 415 for a type you don't handle instead of letting a parser fail silently and return an empty result.
  4. Test your handler against both a JSON body and a form-encoded body if the provider's docs mention either.
  5. Re-verify after a provider's API version bump — some vendors change the default Content-Type during a migration without flagging it as breaking.

None of this requires touching your business logic. It's entirely about knowing, with certainty, what actually arrived — and the fastest way to know that is to look at the raw request instead of the code that's failing to parse it. Once verification and parsing are solid, the related failure mode is usually signature verification breaking on the raw body, and if your handler is firing twice with the correct payload, see handling duplicate deliveries and retries.

Why this is easy to miss in the first place

Most handlers get written against one sender's documentation, tested against that one sender, and shipped. The mismatch only shows up later, when a second integration reuses the same endpoint with a different Content-Type, or when a provider changes its default format during an API version bump and nobody re-reads the changelog. Nothing in a typical deploy pipeline catches this: the endpoint still returns 200, uptime monitoring stays green, and the only signal is a customer or a downstream system reporting that an event "never came through." By the time someone investigates, the request itself is long gone from application logs, which is exactly why capturing the raw request independently of your handler — headers, Content-Type, and unparsed body together — matters more than trying to reproduce the failure by guesswork. Treat a silent no-op the same way you'd treat a wrong status code: as a defect to catch with a real captured payload, not a hunch to debug by re-reading your own parsing code under pressure.

Frequently asked questions

Why does my webhook return a 200 status but my handler doesn't run any logic?

A 200 only confirms your server accepted the connection and returned a response — it says nothing about whether your handler parsed the body successfully. If the incoming Content-Type doesn't match what your code expects, the parsed body is often empty or null, so your logic silently no-ops while the sender still sees success.

What's the difference between application/json and application/x-www-form-urlencoded for webhooks?

JSON sends a single structured payload as the request body; x-www-form-urlencoded sends key=value pairs joined by ampersands, the same format an HTML form posts. Older webhook senders like PayPal IPN and some Twilio callbacks still use form-encoding, while most modern APIs like Stripe, GitHub, and Shopify send JSON.

Why is php://input empty when I read a webhook body in PHP?

PHP's built-in multipart/form-data parser consumes the raw request stream to populate $_POST and $_FILES, and php://input is unavailable for that content type. If a sender posts multipart data, read $_POST instead of php://input.

Why does req.body come back as an empty object in Express?

Express's express.json() and express.urlencoded() middleware only parse a request when its Content-Type header matches the type they're configured for. A payload with a Content-Type they don't recognize is skipped entirely, leaving req.body as an empty object.

How can I see the exact Content-Type and body a webhook sender used?

Point the sender at a CanHook catch URL instead of your handler. CanHook stores the method, headers, raw body, and Content-Type exactly as received, with no parsing applied, so you can compare what was actually sent against what your handler assumes.

Should I trust the Content-Type header or sniff the body instead?

Trust the header first since senders set it deliberately, but validate defensively: attempt the expected parse, and if it fails, log the raw body and headers rather than crashing. That combination catches both honest mismatches and malformed payloads.

Can a webhook sender change its Content-Type without notice?

Yes — providers occasionally change formats during API version migrations or when a customer reconfigures a legacy integration. Capturing and comparing headers over time, not just once, catches a mismatch introduced after your integration already worked.