Set Custom Headers
Telling the Service More About the Request
Most of what a request says is in its body: the model, the messages, the tools, and the limits. Some information belongs in headers instead. Headers describe the request rather than its content, and the API itself uses several of them. The anthropic-version header selects the API version, and the anthropic-beta header opts a request into features that are not yet part of the standard interface. Applications can add their own headers too, for tracing, routing, or identifying the calling service.
This lesson covers how headers are set at two levels, how per-request values override defaults, which headers the API defines and what they control, and how to validate header values so a mistake does not produce a malformed request. It ends with the rule that matters most: headers are sent over the network, and they must never carry secrets that the application would not want seen.
Two Levels: Client and Request
Headers can be set once for every request, or for a single call:
from anthropic import Anthropic
client = Anthropic(default_headers={"x-service-name": "course-assistant"})
response = client.with_options(
default_headers={"x-job-id": "job-7781"},
).messages.create(...)
The client-level headers apply to every call made with that client, and the request-level headers are added for one call. When the same name appears at both levels, the request-level value wins for that call. This is the same override rule the SDK uses for timeouts (Lesson 144): defaults give the baseline, and the specific call gives the exception.
The request-level headers can also be passed directly to a call, using the SDK's extra_headers argument on the method. The reference for the version in use shows the exact argument names, and the pattern is the same as with_options.
Headers the API Defines
Some headers change how the API treats a request, and they should be set deliberately:
| Header | Purpose |
|---|---|
anthropic-version | Selects the version of the API the request uses. The SDK sets it, and changing it changes behavior. |
anthropic-beta | Opts into beta features. Several betas are given as a comma-separated list. |
anthropic-workspace-id | Chooses a workspace when a key can act in more than one (Lesson 141's workspace notes) |
The beta header is the one most often set by hand. Some features, such as the extended output option for batches (Lesson 140), are available only when their beta identifier is included, and the identifier is given in the feature's documentation. Lists of betas are joined with commas, which is the format the documentation specifies:
headers = {"anthropic-beta": "feature-one-2026-01-01,feature-two-2026-02-01"}
The beta names here are placeholders, to show the format. The real identifiers come from the documentation of each feature, and they change, so they should be copied from there rather than remembered.
Setting anthropic-version by hand is rarely needed, and changing it can change the response format, so it should be done only when a specific version is deliberately required and tested.
Custom Headers for Tracing
Applications often add headers that the API ignores but that other systems read: a service name, a job identifier, or a trace identifier. A proxy, gateway, or log pipeline in front of the API can use them to group traffic. Custom header names conventionally start with x-, and the values should be short, plain text. They identify the caller, not the content, which keeps them safe to log.
The trace identifiers from Lesson 148 are a natural fit. Sending the application's trace identifier as a header lets infrastructure between the application and the API correlate its records with the same operation.
Validating Header Values
Header values are plain text sent over HTTP, and some characters are not allowed. A newline inside a value can split a request into two, a bug that becomes a security issue when the value comes from user input. The validator below enforces a simple rule set, and it also refuses headers that look like credentials:
"""headers_check.py: validate and merge request headers before sending."""
import re
NAME_PATTERN = re.compile(r"^[a-z0-9-]+$")
FORBIDDEN_MARKERS = ("api-key", "authorization", "cookie", "secret", "token")
def problems_with(name: str, value: str) -> list[str]:
problems = []
lowered = name.lower()
if not NAME_PATTERN.match(lowered):
problems.append(f"header name {name!r} may contain only letters, digits, and hyphens")
if any(marker in lowered for marker in FORBIDDEN_MARKERS):
problems.append(f"header {name!r} looks like a credential and must not be set here")
if "\n" in value or "\r" in value:
problems.append(f"header {name!r} must not contain line breaks")
if len(value) > 200:
problems.append(f"header {name!r} is longer than 200 characters")
return problems
def merge_headers(defaults: dict, per_request: dict) -> dict:
merged = dict(defaults)
merged.update(per_request)
return merged
if __name__ == "__main__":
defaults = {"x-service-name": "course-assistant", "x-environment": "staging"}
per_request = {"x-job-id": "job-7781", "x-environment": "production"}
print("merged:", merge_headers(defaults, per_request))
tests = [
("x-job-id", "job-7781"),
("x-job id", "bad name"),
("x-api-key", "placeholder"),
("x-note", "line one\nline two"),
]
for name, value in tests:
print(f"{name!r}:", problems_with(name, value) or "ok")
Output:
merged: {'x-service-name': 'course-assistant', 'x-environment': 'production', 'x-job-id': 'job-7781'}
'x-job-id': ok
'x-job id': ["header name 'x-job id' may contain only letters, digits, and hyphens"]
'x-api-key': ["header 'x-api-key' looks like a credential and must not be set here"]
'x-note': ["header 'x-note' must not contain line breaks"]
How It Works
- Per-request values override defaults. The merged result keeps
x-service-namefrom the defaults, and takesx-environmentfrom the per-request value, which isproduction, as the rule says. - Names are restricted to a safe set, so a name containing a space is rejected before it can cause a malformed request.
- Credential-like names are refused. A header named
x-api-keyis rejected even though its name is well formed, because the API key belongs in the client configuration and the environment, never in a per-request header that is easy to log. - Line breaks are refused, since a newline in a value can split the request, which is a known way to inject headers.
- Length is bounded, so an unexpectedly large value, perhaps from user input, cannot inflate every request.
- The message quotes are printed by Python's list display. Each message contains single quotes around the header name, so the list shows the message in double quotes. The text is the same either way; only the display differs.
Headers Are Not Secret
Headers are visible to every system between the application and the API. Proxies, logging middleware, and debugging tools can all read them, and some of them record headers by default. A header is therefore a poor place for anything confidential. The API key is set on the client, and it is handled by the SDK. Custom headers should carry identifiers and labels, and anything that could identify a person should be left out unless the destination is trusted with it.
Summary
Headers describe a request, separately from its body, and they can be set on a client for every call or on a single call, with the single call winning. The API defines a few headers that change its behavior, including the beta opt-in, and custom headers carry tracing and routing information. Values must be validated, because line breaks and credential-like names are both avoidable mistakes. And since headers travel openly, they must carry labels and identifiers, never secrets. This completes the Advanced Features section, and the next section covers cloud platforms.