HTTP headers are "key: value" pairs that travel with every request and response. They carry the data type, authorization tokens, request identifiers, and much more. Let's go over which headers exist, how to use them correctly, and what to avoid.
Standard headers
For most tasks there are already standard headers defined in the HTTP specifications. You should use them for their intended purpose rather than inventing your own equivalents.
The most common ones in a REST API:
// Content type of the request or response
w.Header().Set("Content-Type", "application/json")
// Authorization token — read from the incoming request
token := r.Header.Get("Authorization") // "Bearer <token>"
// Language preferred by the client — for localizing responses
lang := r.Header.Get("Accept-Language")
// Address of the new resource — returned on creation (status 201)
w.Header().Set("Location", "/api/v1/orders/"+orderID)
Authorization is better parsed in middleware, with the result placed into the request context — then handlers don't need to repeat the same check.
Custom headers
Sometimes standard headers aren't enough. For example, you need to pass an internal request identifier or the client's API version.
In the past, the X- prefix was used for such cases: X-Request-Id, X-Api-Version. In 2012, RFC 6648 deprecated this practice, because X- headers over time became standard, and the prefix only created confusion. Nowadays custom headers are named with a meaningful domain prefix: Shop-Request-Id, Shop-Api-Version.
The prefix is chosen once for the whole project and fixed in the team's conventions:
const headerRequestID = "Shop-Request-Id"
const headerVersion = "Shop-Api-Version"
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get(headerRequestID)
if requestID == "" {
requestID = uuid.New().String()
}
w.Header().Set(headerRequestID, requestID)
ctx := context.WithValue(r.Context(), ctxRequestID{}, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
The middleware generates an identifier if the client didn't send one, and places it in the response header — so that a request can be found in the logs by its ID.
Idempotency-Key: protection against duplicate operations
Imagine a client sent a "create order" request, the network dropped, and it doesn't know whether the order was created or not. If it retries the request, two identical orders may be created.
The Idempotency-Key header solves this problem. The client generates a unique key and sends it with every such request. The server remembers the result by this key: on a repeated request with the same key, it returns the saved response without performing the operation again.
This matters for operations with side effects: creating an order, processing a payment, sending a notification.
type cachedResponse struct {
status int
body any
}
func createOrder(w http.ResponseWriter, r *http.Request) {
idempotencyKey := r.Header.Get("Idempotency-Key")
if idempotencyKey == "" {
httperr.Write(w, r, apperr.NewValidation("Idempotency-Key header required"))
return
}
// Already handled this request — return the saved response
if cached, ok := idempotencyStore.Get(idempotencyKey); ok {
writeJSON(w, cached.status, cached.body)
return
}
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
order, err := svc.CreateOrder(r.Context(), toCreateOrderCommand(req))
if err != nil {
httperr.Write(w, r, err)
return
}
resp := toOrderResponse(order)
idempotencyStore.Set(idempotencyKey, cachedResponse{status: http.StatusCreated, body: resp}, 24*time.Hour)
w.Header().Set("Location", "/api/v1/orders/"+order.ID)
writeJSON(w, http.StatusCreated, resp)
}
The key store is Redis or in-memory with a TTL (usually 24 hours). If the client retries a request with the same key but a different body — that's a client error, return 409 Conflict.
Distributed tracing: traceparent
In a microservice architecture, a single user request passes through several services. When something goes wrong, you need to understand the whole path of the request — which services it went through, where it slowed down, where the error occurred.
For this the W3C Trace Context standard is used. Each request gets a unique traceId, which is passed between services via the traceparent header. All services record their spans into the tracing system — and you can see the full picture.
In Go this is handled by OpenTelemetry middleware:
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
func main() {
r := chi.NewRouter()
r.Use(otelhttp.NewMiddleware("order-service")) // reads traceparent from the incoming request
// ...
}
The middleware automatically reads traceparent from the incoming request and continues the trace. If the header is absent — it creates a new one.
To add traceId to the error body (for debugging on the client side):
import "go.opentelemetry.io/otel/trace"
func traceIDFromCtx(ctx context.Context) string {
span := trace.SpanFromContext(ctx)
if !span.SpanContext().IsValid() {
return ""
}
return span.SpanContext().TraceID().String()
}
Passing the trace in outgoing requests
For the trace to continue in the next service, you need to add traceparent to the outgoing request:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
func callDownstream(ctx context.Context, url string) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
return http.DefaultClient.Do(req)
}
Middleware order
Headers are better handled at the start of the middleware chain, before the business logic:
r := chi.NewRouter()
r.Use(
otelhttp.NewMiddleware("order-service"), // traceparent
requestIDMiddleware, // Shop-Request-Id
RateLimitMiddleware(globalLimiter), // Retry-After
Recoverer, // panic → 500
)
Common mistakes
X- in custom headers. Writing X-Request-Id or X-Trace-Id is an outdated practice. Use a meaningful prefix (Shop-Request-Id) or a standard header (traceparent).
Authorization without middleware. Parsing the token right in the handler and not checking that it's present is a common mistake. It's better to move this into middleware and put the result into the context.
Idempotency-Key without a store. Accepting the key but not remembering the operation's result means giving a false guarantee. A repeated request will create the order again.
Custom propagation instead of W3C. Sometimes people invent their own format for passing the tracing ID. This breaks compatibility with off-the-shelf tools — Jaeger, Zipkin, Grafana Tempo all understand traceparent.
In short
- Standard headers (
Content-Type,Authorization,Location) are used strictly for their intended purpose. - Custom headers are named with a domain prefix without
X-:Shop-Request-Id, notX-Request-Id. Idempotency-Keyprotects against duplicate operations: the server remembers the result and returns it on a repeated request with the same key.traceparentis the W3C standard for distributed tracing;otelhttp.NewMiddlewarewires it up automatically.- For outgoing requests you need to explicitly inject
traceparentviaotel.GetTextMapPropagator().Inject.
What to read next
- RFC 9457 errors in Go — how
traceIdends up in the error body. - Rate limiting in Go — the
Retry-AfterandRateLimit-*headers. - JSON and response format in Go — the
Locationheader when creating a resource.