What is a content-type?
Content-Type is the HTTP header that tells the receiver how to interpret the request body — usually `application/json` for webhooks. Modern providers (Stripe, GitHub, Shopify) almost universally send JSON; legacy ones (Slack's older webhooks, Twilio status callbacks) send `application/x-www-form-urlencoded`. Match the parser to what the source actually sends — `express.json()` on a form-encoded route silently produces an empty `req.body`. Be lenient with charset suffixes (Stripe sometimes sends `application/json; charset=utf-8`). Webhooks are universally UTF-8 in practice; configure parsers accordingly. The signature is computed over raw bytes in the as-sent encoding — read raw, verify, then decode for parsing.
Content-Type is the header that announces the body's MIME type. For webhooks, the value is almost always one of two things: application/json (the modern default — Stripe, GitHub, Shopify) or application/x-www-form-urlencoded (legacy — Slack's older webhooks, Twilio's status callbacks). A handful of providers send application/xml for backward compatibility with older customers; nobody designs new APIs that way.
Why it matters in webhook handling:
- Wrong content-type on the route breaks parsing. If you mount express.json() on a route that's actually receiving application/x-www-form-urlencoded, the body is unparsed and req.body is empty. Match the parser to what the source actually sends.
- Some sources send unexpected content-types under specific conditions. Stripe may send application/json; charset=utf-8 (with the charset suffix); your content-type matcher should be lenient (startsWith('application/json'), not strict equality).
- Charset matters for non-ASCII payloads. A Shopify order with a customer name containing emoji or non-Latin characters arrives as UTF-8 bytes; if your parser assumes Latin-1, you get mojibake. Webhooks are universally UTF-8 in practice; configure parsers accordingly.
- The signature is computed over the raw body in the bytes-as-sent encoding. If you re-encode the body (e.g., latin1 → utf8 conversion), the HMAC changes. Read raw bytes, verify, then decode for parsing.
The form-encoded-vs-JSON distinction has practical consequences for HMAC verification. JSON webhooks sign the body verbatim — HMAC(secret, raw_json_bytes). Form-encoded webhooks sometimes sign a normalized representation (Twilio sorts form params alphabetically and concatenates them with the URL). Provider-specific spec — read the docs for the exact format your provider uses.
When you're debugging "my body is empty," the order of checks: (1) is the request actually arriving (server logs), (2) what's the actual Content-Type header (curl -v your endpoint), (3) is your framework's body parser configured for that content-type, (4) is some upstream middleware (logging, auth) consuming the body before your handler. Most "empty body" bugs are step 3 or 4.
For multipart webhooks (rare but exist — some legacy providers send file attachments inline), you need a multipart parser, and signature verification gets harder because each part is its own boundary-delimited blob. Most providers have moved to URL-referencing the file in the JSON payload and letting the receiver fetch separately, which is operationally simpler.
See Content-Type in real traffic
WebhookWhisper captures every webhook with full headers, body, signature, and timing — so concepts like content-type stop being abstract and become something you can inspect.
Start Free