← Back to the section

When a client sends a request, it passes not only the body — but also headers: who it is, what it expects to receive, whether it has a token. The server answers the same way: what it returned, whether the response can be cached, where to find the created resource. Let's look at how FastAPI works with headers: both standard and custom ones.

What HTTP headers are

A header is a "name: value" pair at the very start of a request or response, before the body. For example:

GET /api/v1/orders/123
Authorization: Bearer eyJhbGci...
Accept: application/json

The request body is the data. Headers are meta-information: who is asking, in what format, whether there's a cache, whether tracing is needed.

In FastAPI headers are declared right in the function signature, and the framework automatically includes them in the OpenAPI documentation. That's more convenient than describing them by hand in YAML.

How to read a header in FastAPI

You use Header(...) from fastapi. FastAPI automatically turns a snake_case parameter into a kebab-case header: if_none_match reads the If-None-Match header.

from fastapi import APIRouter, Header, Response
from typing import Annotated

router = APIRouter(prefix="/api/v1", tags=["Orders"])


@router.get("/orders/{order_id}", operation_id="getOrder")
async def get_order(
    order_id: str,
    authorization: Annotated[str, Header()],
    if_none_match: Annotated[str | None, Header()] = None,
) -> Response:
    etag = '"33a64df5"'
    if if_none_match == etag:
        return Response(status_code=304)

    order = await order_service.get(order_id)
    return Response(
        content=order.model_dump_json(exclude_none=True),
        media_type="application/json",
        headers={"ETag": etag, "Cache-Control": "private, max-age=60"},
    )

FastAPI parses Content-Type itself — there's no need to declare it via a parameter.

Standard headers and their purpose

Some headers have a meaning fixed by the HTTP standard. You can't repurpose them for other uses:

HeaderWhat it does
Authorizationaccess token, always with a scheme: Bearer eyJ...
Acceptwhat response format the client expects
ETagthe version of the resource — the client saves it and sends it back on the next request
If-None-Match"return the data only if the version has changed"
If-Match"update only if the version matches mine" (protection against concurrent edits)
Cache-Controlwhether the response can be cached and for how long
Locationthe URL of the just-created resource in a 201 response

The Location header when creating a resource

When the server returns 201 Created, it's good form to specify in Location where to find the created object. FastAPI doesn't do this automatically — you need to add it explicitly:

@router.post("/orders", status_code=201, operation_id="createOrder")
async def create_order(
    body: CreateOrderRequest,
    response: Response,
) -> OrderResponse:
    order = await order_service.create(body)
    response.headers["Location"] = f"/api/v1/orders/{order.order_id}"
    return order

Injecting response: Response lets you add headers without replacing the body — the Pydantic model is serialized as usual.

Custom headers — with a domain prefix

Sometimes you need to pass something specific to your system: a request identifier, a client version, a tenant code. For that people invent custom headers.

It used to be common to add an X- prefix (for example, X-Request-Id). In 2012 RFC 6648 declared this practice deprecated: X- guarantees nothing, it only causes confusion.

The right approach is to choose a single domain prefix for all the project's services and use only it:

Sber-Request-Id: 550e8400-e29b-41d4-a716-446655440000
Sber-Client-Version: 3.2.1
Sber-Tenant-Id: corporate

In FastAPI such headers are declared in snake_case, and the framework converts them to kebab-case itself:

@router.get("/products/{product_id}", operation_id="getProduct")
async def get_product(
    product_id: UUID,
    sber_request_id: Annotated[str | None, Header()] = None,
    sber_client_version: Annotated[str | None, Header()] = None,
) -> ProductResponse:
    return await product_service.get(product_id)

sber_request_id → FastAPI reads the Sber-Request-Id header. No manual alias.

Idempotency-Key — a safe request retry

Imagine: the user clicked "Pay", the request went out, but the response got lost in the network. The application doesn't know — was the order created or not? If you simply retry the request, you can create the order twice.

The solution is Idempotency-Key. The client generates a unique key for each business operation (not for each HTTP request) and sends it in the header. The server remembers: "an operation has already been performed under this key, here's the result" — and on a repeated request returns the same result without performing the action again.

POST /api/v1/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{ "customerId": "cust-123", "items": [...] }

Rules for working with the key:

  • the client generates the key once per operation;
  • a repeated POST with the same key → the server returns the first result, the order is not created again;
  • the same key with a different body → 409 Conflict.

In FastAPI Idempotency-Key is declared as a required header:

@router.post("/orders", status_code=201, operation_id="createOrder")
async def create_order(
    body: CreateOrderRequest,
    response: Response,
    idempotency_key: Annotated[UUID, Header()],
) -> OrderResponse:
    order = await order_service.create(body, idempotency_key=str(idempotency_key))
    response.headers["Location"] = f"/api/v1/orders/{order.order_id}"
    return order

The UUID type — FastAPI will check the format before the function is called. If the header isn't passed, a validation error is returned.

Idempotency-Key applies only to POST and PATCH — operations that create or modify data. It isn't needed for GET and DELETE: a repeated GET doesn't create anything new.

traceparent — a thread through several services

When a request passes through several services in a row, it's hard to tell: where exactly did something go wrong? To see this, each service passes the next one a special traceparent header — it carries a single identifier for the whole chain of calls.

The format is standardized by W3C Trace Context:

traceparent: 00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01
             ─┘ └───────────────────────────────┘ └──────────────┘ └┘
           version        trace-id (32 hex)        parent-id (16) flags
  • trace-id — the unique ID of the whole chain. The same from the first service to the last.
  • parent-id — the ID of the current step (span). Changes at each service.
  • flags01 means "this request is being traced".

Wiring up OpenTelemetry

The most convenient way is the opentelemetry-instrumentation-fastapi library. It intercepts the incoming traceparent itself, creates a span, and passes the context into outgoing requests:

from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)

Once it's wired up:

  • if the client sent a traceparent — the service continues the same chain;
  • if there was no header — a new trace-id is generated.

The current trace-id is available in code:

from opentelemetry import trace

def get_trace_id() -> str | None:
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid:
        return format(ctx.trace_id, "032x")
    return None

This trace-id is worth including in the error body — the client will be able to pass it to support, and support will find the right request in the tracing system.

Reading traceparent manually

If OpenTelemetry isn't wired up, the header is read like any other:

@router.get("/customers/{customer_id}", operation_id="getCustomer")
async def get_customer(
    customer_id: str,
    traceparent: Annotated[str | None, Header()] = None,
) -> CustomerResponse:
    trace_id = None
    if traceparent:
        parts = traceparent.split("-")
        trace_id = parts[1] if len(parts) >= 2 else None

    return await customer_service.get(customer_id, trace_id=trace_id)

With OpenTelemetry there's no need to duplicate the parameter in the signature — the trace-id is taken through the tracer API.

Common mistakes

The X- prefix in custom headers. X-Request-Id looks familiar, but RFC 6648 declared this prefix deprecated. Use a domain prefix: Shop-Request-Id, Sber-Request-Id — one for all services.

Authorization without a scheme. Writing Authorization: eyJhbGci... is wrong. The scheme is mandatory: Authorization: Bearer eyJhbGci....

Idempotency-Key on GET. GET requests are idempotent by nature — repeating them changes nothing. The key is only needed for POST and PATCH.

A homemade header for tracing. Tracking-Id, X-Correlation-Id and similar inventions break compatibility with monitoring tools. traceparent is a W3C standard, supported by all tracing systems.

Location without the full path. On 201 Created, the Location header must contain the full relative path: /api/v1/orders/550e..., not just 550e....

In short

  • Headers are declared in the function signature via Header() — FastAPI reads them from the request itself and shows them in OpenAPI.
  • FastAPI converts snake_case parameters into kebab-case headers: if_none_matchIf-None-Match.
  • Custom headers — with a domain prefix (Shop-*, Sber-*); the X- prefix is deprecated.
  • Idempotency-Key protects against double creation on a repeated POST — the client generates the key once per operation.
  • traceparent (W3C) carries a single trace-id through the whole chain of services; OpenTelemetry picks it up automatically.
  • Location on 201 Created is added manually via response.headers["Location"].
  • Errors and response codes in FastAPI — traceId in the error body, overriding the validation handler.
  • JSON and response formats in FastAPI — camelCase, ISO 8601, exclude_none.
  • Versioning a REST API in FastAPI — the Deprecation and Sunset headers when retiring an endpoint.