http clients and apis

Headers

Headers are the named metadata fields sent with an HTTP request or response. The body carries the main data; headers describe how that data should be understood, whether the caller is authorised, how clients may cache it, how much quota remains, and how the request can be traced in logs.

They matter because many API bugs are not in the JSON body. A request may have the right URL and body but fail because the Authorization header is missing, the Content-Type is wrong, the client forgot to send Accept: application/json, or a proxy stripped a forwarding header.

Request headers

Request headers tell the server about the caller and the data being sent.

Common request headers include:

  • Authorization: credentials such as a bearer token or API key.
  • Content-Type: the format of the request body, such as application/json.
  • Accept: the response format the client wants.
  • User-Agent: the client application making the request.
  • Idempotency-Key: a unique key for safely retrying a create/payment-style operation.
  • X-Request-Id or Traceparent: a correlation value used in logs and distributed tracing.

In PHP, request headers often arrive through framework request objects. In plain PHP you may see them through getallheaders() on web SAPIs, or through server variables such as $_SERVER['HTTP_AUTHORIZATION']. For learning and tests, it is usually clearer to pass headers as arrays.

PHP example
<?php

declare(strict_types=1);

function normaliseHeaderName(string $name): string
{
    return strtolower($name);
}

function getHeader(array $headers, string $name): ?string
{
    $wanted = normaliseHeaderName($name);

    foreach ($headers as $headerName => $value) {
        if (normaliseHeaderName((string) $headerName) === $wanted) {
            return is_array($value) ? implode(', ', $value) : (string) $value;
        }
    }

    return null;
}

$headers = [
    'Content-Type' => 'application/json',
    'Authorization' => 'Bearer token_123',
];

var_dump(getHeader($headers, 'content-type'));
var_dump(getHeader($headers, 'Accept'));

// Prints:
// string(16) "application/json"
// NULL

Header names are case-insensitive, so Content-Type, content-type, and CONTENT-TYPE refer to the same header. Header values are not generally case-insensitive; treat them according to the specific header's rules.

Response headers

Response headers tell the client how to handle the response.

PHP example
<?php

declare(strict_types=1);

function jsonResponse(array $payload, int $status = 200): array
{
    return [
        'status' => $status,
        'headers' => [
            'Content-Type' => 'application/json; charset=utf-8',
            'Cache-Control' => 'no-store',
            'X-Request-Id' => 'req_7f3a',
        ],
        'body' => json_encode($payload, JSON_THROW_ON_ERROR),
    ];
}

$response = jsonResponse(['ok' => true]);

print_r($response);

// Prints:
// [status] => 200
// [Content-Type] => application/json; charset=utf-8
// [body] => {"ok":true}

Real applications may call header() directly, but returning a response object or array is easier to test. Frameworks such as Laravel, Symfony, Slim, and Laminas all provide response APIs so code can set status, headers, and body together.

Content-Type and Accept

Content-Type describes the request or response body that is actually present. Accept describes the response format the client would prefer.

For a JSON API:

  • A request with a JSON body should usually include Content-Type: application/json.
  • A client that expects JSON should usually send Accept: application/json.
  • A JSON response should include Content-Type: application/json; charset=utf-8.

Do not decode the body as JSON just because the URL belongs to an API. Check the content type first when the endpoint requires JSON.

PHP example
<?php

declare(strict_types=1);

function acceptsJson(array $headers): bool
{
    $accept = getHeader($headers, 'Accept');

    if ($accept === null || trim($accept) === '') {
        return true;
    }

    return str_contains(strtolower($accept), 'application/json')
        || str_contains($accept, '*/*');
}

var_dump(acceptsJson(['Accept' => 'application/json']));
var_dump(acceptsJson(['Accept' => 'text/html']));

// Prints:
// bool(true)
// bool(false)

In production code, content negotiation can become more detailed because Accept may contain multiple media types with quality values. For junior PHP work, the important skill is recognising that the header exists and not assuming every client wants the same format.

Authentication headers

APIs commonly use Authorization: Bearer <token> or a custom API key header. The important rule is to parse the header deliberately and avoid logging secrets.

PHP example
<?php

declare(strict_types=1);

function bearerTokenFromHeaders(array $headers): ?string
{
    $authorization = getHeader($headers, 'Authorization');

    if ($authorization === null) {
        return null;
    }

    if (!preg_match('/^Bearer\s+(.+)$/i', trim($authorization), $matches)) {
        return null;
    }

    return $matches[1];
}

var_dump(bearerTokenFromHeaders(['Authorization' => 'Bearer abc.def.ghi']));
var_dump(bearerTokenFromHeaders(['Authorization' => 'Basic abc123']));

// Prints:
// string(11) "abc.def.ghi"
// NULL

When an auth header is invalid, return a suitable error such as 401 Unauthorized; do not include the token in the response or logs.

Caching and rate limit headers

Headers also control operational behaviour.

Cache-Control: no-store tells clients and proxies not to store sensitive responses. Public, cacheable responses may use directives such as public, max-age=300, but user-specific data should not be cached publicly.

Rate limit headers vary between APIs, but you may see names like:

  • RateLimit-Limit: the total allowed requests in the window.
  • RateLimit-Remaining: how many requests are left.
  • RateLimit-Reset: when the window resets.
  • Retry-After: how long the client should wait before retrying.

Clients should treat these as hints for better behaviour, not as permission to ignore status codes.

Multiple values

Some headers can appear more than once. The common example is Set-Cookie, where joining values with commas is not safe. Many other repeated headers can be combined, but cookie handling is special enough that real applications should use the framework's cookie API instead of hand-building those strings.

PHP example
<?php

declare(strict_types=1);

$responseHeaders = [
    'Set-Cookie' => [
        'session=abc; HttpOnly; Secure; SameSite=Lax',
        'theme=dark; Max-Age=2592000; SameSite=Lax',
    ],
];

foreach ($responseHeaders['Set-Cookie'] as $cookieHeader) {
    echo $cookieHeader . PHP_EOL;
}

// Prints:
// session=abc; HttpOnly; Secure; SameSite=Lax
// theme=dark; Max-Age=2592000; SameSite=Lax

What to check

Before moving on, make sure you can:

  • Explain the difference between request headers and response headers.
  • Read a header without depending on exact casing.
  • Choose sensible JSON response headers.
  • Recognise Authorization, Content-Type, Accept, cache headers, rate limit headers, and request IDs.
  • Avoid logging sensitive header values.

Practice

Practice: Inspect API Headers

Write a small PHP function that receives request headers and returns a decision for a JSON-only API endpoint.

Requirements

  • Accept header names in any casing.
  • Require Content-Type: application/json for requests with a body.
  • Accept clients that send Accept: application/json or Accept: */*.
  • Extract a bearer token from Authorization: Bearer ....
  • Return a status code and message for missing or invalid headers.
  • Include at least three sample requests: valid, wrong content type, and missing bearer token.
Show solution

One good solution is to normalise names when reading headers, then make each API requirement explicit.

PHP example
<?php

declare(strict_types=1);

function headerValue(array $headers, string $name): ?string
{
    $wanted = strtolower($name);

    foreach ($headers as $headerName => $value) {
        if (strtolower((string) $headerName) === $wanted) {
            return trim((string) $value);
        }
    }

    return null;
}

function bearerToken(array $headers): ?string
{
    $authorization = headerValue($headers, 'Authorization');

    if ($authorization === null) {
        return null;
    }

    if (!preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
        return null;
    }

    return $matches[1];
}

function checkApiHeaders(array $headers, bool $hasBody): array
{
    if ($hasBody && headerValue($headers, 'Content-Type') !== 'application/json') {
        return ['status' => 415, 'message' => 'Request body must be JSON.'];
    }

    $accept = headerValue($headers, 'Accept') ?? '*/*';

    if (!str_contains(strtolower($accept), 'application/json') && !str_contains($accept, '*/*')) {
        return ['status' => 406, 'message' => 'Client must accept JSON responses.'];
    }

    if (bearerToken($headers) === null) {
        return ['status' => 401, 'message' => 'Bearer token is required.'];
    }

    return ['status' => 200, 'message' => 'Headers are valid.'];
}

$examples = [
    'valid' => [
        'Content-Type' => 'application/json',
        'Accept' => 'application/json',
        'Authorization' => 'Bearer token_123',
    ],
    'wrong content type' => [
        'Content-Type' => 'text/plain',
        'Accept' => 'application/json',
        'Authorization' => 'Bearer token_123',
    ],
    'missing bearer token' => [
        'content-type' => 'application/json',
        'accept' => '*/*',
    ],
];

foreach ($examples as $label => $headers) {
    $result = checkApiHeaders($headers, true);
    echo $label . ': ' . $result['status'] . ' ' . $result['message'] . PHP_EOL;
}

// Prints:
// valid: 200 Headers are valid.
// wrong content type: 415 Request body must be JSON.
// missing bearer token: 401 Bearer token is required.

This keeps the rules visible and testable. In a real controller, the same checks might be provided by framework middleware, but the underlying decisions are the same.