Why is $_POST empty when a payment provider posts a JSON webhook to a PHP endpoint, and how do you read that body?
answer
- $_POST parses two content types only
- the raw body is php://input
- file_get_contents('php://input')
- verify the signature on raw bytes
- CONTENT_TYPE has no HTTP_ prefix
basics
~20 sPHP fills $_POST only for form-encoded and multipart bodies, so a JSON body is left unparsed. Read the raw bytes with file_get_contents('php://input'), verify the provider's signature on those exact bytes, then decode them with json_decode().
solid answer
~40 sPHP's body parser handles only `application/x-www-form-urlencoded` and `multipart/form-data`, and only for `POST`. An `application/json` body is not parsed, so `$_POST` is an empty array — but the body is not lost: `php://input` is a read-only stream of the raw request body, and `file_get_contents('php://input')` returns it as a string. For a webhook the order matters: check `$_SERVER['REQUEST_METHOD']` and `$_SERVER['CONTENT_TYPE']`, read the raw body, verify the provider's signature header with `hash_hmac()` and `hash_equals()` over those exact bytes, and only then call `json_decode($raw, true, 512, JSON_THROW_ON_ERROR)`. Re-encoding a decoded array would not reproduce the signed bytes. `php://input` can be opened more than once in a request; the exception is a `multipart/form-data` POST while `enable_post_data_reading` is On, where PHP consumes the body itself.
code
php · 25 lines<?php
declare(strict_types=1);
$type = strtolower(trim(explode(';', $_SERVER['CONTENT_TYPE'] ?? '')[0]));
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || $type !== 'application/json') {
http_response_code(400);
exit;
}
$secret = getenv('PAYMENT_WEBHOOK_SECRET');
$raw = (string) file_get_contents('php://input');
$given = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = is_string($secret) && $secret !== '' ? hash_hmac('sha256', $raw, $secret) : null;
if ($expected === null || !is_string($given) || !hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
try {
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
http_response_code(400);
exit;
}
http_response_code(204); // acknowledge; queue $event for processinggo deeper
Recall that $_POST is empty for JSON bodies and that file_get_contents('php://input') returns the raw body.
Explain the conditions under which PHP parses a body, how php://input behaves, and why CONTENT_TYPE has no HTTP_ prefix.
Build a webhook endpoint that checks method and content type, verifies the signature on raw bytes with hash_equals(), decodes with exceptions on, and acknowledges quickly.
Standardise how JSON and webhook bodies enter services — shared verification and decoding at the edge — so each endpoint does not reinvent the order of checks.
## Why $_POST is empty Before a script runs, PHP decides whether to parse the request body. It does so only when: 1. `enable_post_data_reading` is On (the default); 2. the request has a `Content-Type`; 3. the method is exactly `POST`. Even then, only two content types are turned into `$_POST`: `application/x-www-form-urlencoded` and `multipart/form-data`. A payment provider's webhook arrives as `POST` with `Content-Type: application/json`, so PHP reads the body but has no parser for it: `$_POST` is `[]`. Nothing is wrong with the request; PHP simply does not decode JSON automatically. ## php://input `php://input` is a **read-only stream** that yields the raw request body exactly as the client sent it. - `file_get_contents('php://input')` reads it all into a string, which suits bodies of a few kilobytes such as webhooks. - `fopen('php://input', 'rb')` gives a stream you can read in chunks for large bodies. - PHP keeps the body in a temporary stream, so opening `php://input` a second time in the same request starts again from the beginning. - It is **not available** for a `multipart/form-data` POST while `enable_post_data_reading` is On, because PHP has already consumed that body to build `$_POST` and `$_FILES`. ## A webhook handler step by step 1. **Method**: accept only `$_SERVER['REQUEST_METHOD'] === 'POST'`; answer anything else with 405. 2. **Content type**: check `$_SERVER['CONTENT_TYPE']`. It has no `HTTP_` prefix, because the CGI convention passes the body's type and length as `CONTENT_TYPE` and `CONTENT_LENGTH`. It may carry parameters, such as `application/json; charset=utf-8`, so compare the part before `;`. 3. **Raw body**: `$raw = file_get_contents('php://input');`. 4. **Signature**: providers typically sign the raw body with a shared secret and send the result in a header. Recompute it with `hash_hmac('sha256', $raw, $secret)` and compare with `hash_equals()`, which takes time independent of where the strings differ. 5. **Decode**: only after the signature matches, `json_decode($raw, true, 512, JSON_THROW_ON_ERROR)`. 6. **Respond quickly**: acknowledge with a 2xx status and do slow work later, since providers retry deliveries that time out. The signature must be checked on the **raw string**. Decoding and re-encoding changes whitespace, key order and number formatting, so a signature computed over `json_encode(json_decode($raw))` will not match what the provider signed. ## Pitfalls | Mistake | Effect | |---|---| | Reading `$_POST['event']` | Always `null` for a JSON body | | Decoding before verifying | Parsing attacker-controlled input before knowing who sent it | | Comparing signatures with `==` or `===` | Comparison time can leak how many leading characters matched | | Looking for `$_SERVER['HTTP_CONTENT_TYPE']` | Not reliably set; `CONTENT_TYPE` is the dependable key | | Treating an empty body as a JSON error | `file_get_contents()` returns `''`, which `json_decode()` with `JSON_THROW_ON_ERROR` rejects — reply 400, not 500 | ## Large bodies `file_get_contents('php://input')` loads the whole body into memory, which is fine for webhooks of a few kilobytes. For large uploads of raw data, open the stream and process it in chunks instead: ```php $in = fopen('php://input', 'rb'); while (!feof($in)) { $chunk = fread($in, 8192); // hash, forward or write the chunk } fclose($in); ``` Incremental hashing with `hash_init()` and `hash_update()` lets a signature be computed over a body that is never held in memory at once. ## Trying it locally PHP's built-in development server is enough to exercise a webhook endpoint: start it with `php -S localhost:8000` in the project directory, then post a JSON body with any HTTP client, setting the `Content-Type` and signature headers by hand. Test the failure paths too — a wrong signature, an empty body and a non-JSON content type should each produce a 4xx response, not a 500. ## Where header values come from The provider's signature header arrives in `$_SERVER` like any other header: `HTTP_` followed by its name in upper case with hyphens as underscores, so a header named `X-Signature` is `$_SERVER['HTTP_X_SIGNATURE']`. Like every `HTTP_*` value it is client-supplied — which is exactly why it is compared against a value you compute yourself rather than believed.
- Can you read php://input twice in the same request?Yes, in current PHP. The body is kept in a temporary stream and each open of `php://input` rewinds it, so a framework and your own code can both read it. The exception is a `multipart/form-data` POST while `enable_post_data_reading` is On: PHP consumes that body to build `$_POST` and `$_FILES`, and `php://input` is not available.
- Why must the webhook signature be computed before json_decode(), over the raw string?The provider signed the exact bytes it sent. Decoding and re-encoding can change whitespace, key order, escaping and number formats, so a signature computed over re-encoded JSON will not match. Verifying first also means you only parse input once you know it came from the provider.
- What is in $_POST if the provider sends its webhook as application/x-www-form-urlencoded instead?Then PHP parses it: the fields appear in `$_POST` as strings, and nested brackets become arrays. The raw body is still available from `php://input`, which you still need for signature verification, because the signature covers the raw bytes, not PHP's parsed array.
saying these in an interview costs you the question
- PHP decodes JSON bodies into $_POST automatically
- php://input can only be read once per request
- Verify the webhook signature over json_encode() of the decoded body
- The body's type is always in $_SERVER['HTTP_CONTENT_TYPE']
- Comparing signatures with === is as safe as hash_equals()