ScreenshotNeo

BlogHow-to

How to Send Custom HTTP Headers in Go

Set request and response headers correctly in Go with runnable examples, context, auth, trailers, troubleshooting, and a ScreenshotNeo shortcut.

By the ScreenshotNeo team29 September 20261 min read

How to Send Custom HTTP Headers in Go

Go’s standard library makes custom HTTP headers explicit: build an http.Request, set its headers, and send it with http.Client.Do. For a server response, set headers on http.ResponseWriter.Header() before writing the status or body. This distinction prevents the most common mistakes.

The examples below cover authentication, content negotiation, tracing, cookies, repeated headers, context cancellation, response headers, trailers, testing, performance, and failure handling. The patterns use only Go’s standard net/http package.

Set custom headers on an outgoing request

Use http.NewRequest (or http.NewRequestWithContext) when you control the client request. Set one value with Header.Set, append another value with Header.Add, then call Client.Do.

A Go request carries custom headers through the client before the server commits its response.
A Go request carries custom headers through the client before the server commits its response.
package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/v1/orders", nil)
    if err != nil {
        panic(err)
    }

    req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
    req.Header.Set("Accept", "application/json")
    req.Header.Set("X-Request-ID", "order-list-123")

    client := &http.Client{Timeout: 15 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        panic(fmt.Sprintf("API returned %s: %s", resp.Status, body))
    }
    fmt.Println(string(body))
}

The Go documentation recommends this request-construction workflow for custom headers: use NewRequest and Client.Do. A successful Do call only means the exchange completed; it does not mean the server returned a 2xx status. Always inspect resp.StatusCode and close resp.Body after reading it.

Set versus Add

Method Behavior Use it when
Set(name, value) Replaces all existing values for the field There should be one current value, such as an authorization token
Add(name, value) Appends another value The protocol permits multiple field values or you intentionally send more than one
req.Header.Set("Accept", "application/json")
req.Header.Add("Accept", "application/problem+json") // sends two values

Header names are case insensitive on the wire. Go canonicalizes names when you use the Header methods, so prefer conventional spellings such as X-Request-ID and Content-Type rather than manipulating map keys directly.

Send headers with a request body

For JSON or form requests, create the body first and set the media type explicitly. The convenience functions such as http.Post do not provide a general way to attach arbitrary fields; use a request and Client.Do.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "time"
)

func main() {
    payload := map[string]any{"name": "Ada", "active": true}
    data, err := json.Marshal(payload)
    if err != nil {
        panic(err)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    req, err := http.NewRequestWithContext(ctx, http.MethodPost,
        "https://api.example.com/v1/users", bytes.NewReader(data))
    if err != nil {
        panic(err)
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Accept", "application/json")
    req.Header.Set("Authorization", "Bearer YOUR_TOKEN")

    resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
    fmt.Println(resp.Status)
}

Do not set transport-controlled fields casually. The HTTP transport determines values such as Content-Length, connection behavior, and some protocol details. Set application headers that the server’s API documents; let the transport manage its own fields.

Reusable helper functions and typed options

Centralizing header construction keeps authentication and tracing consistent across requests. Avoid mutating a shared http.Request concurrently; create a new request for each operation.

type Client struct {
    HTTP      *http.Client
    BaseURL   string
    Token     string
    UserAgent string
}

func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
    req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Accept", "application/json")
    req.Header.Set("Authorization", "Bearer "+c.Token)
    if c.UserAgent != "" {
        req.Header.Set("User-Agent", c.UserAgent)
    }
    return req, nil
}

For optional values, set only when present. An empty authorization header is usually worse than omitting it because it can hide configuration errors.

Context, timeouts, and cancellation

http.NewRequestWithContext ties DNS lookup, connection setup, redirects, and response reading to a deadline or cancellation signal. Use a client timeout as a safety limit and a context deadline for each operation.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { return err }
req.Header.Set("X-Correlation-ID", correlationID)
resp, err := client.Do(req)

When a request fails, inspect ctx.Err() to distinguish cancellation or deadline expiry from a connection failure. Do not retry every error: retry only idempotent operations or requests with an idempotency key, and use backoff for transient 5xx and network errors.

Read and validate response headers

Response headers are available through resp.Header. Use Get for the first value and Values when multiple values matter.

contentType := resp.Header.Get("Content-Type")
requestID := resp.Header.Get("X-Request-ID")
setCookies := resp.Header.Values("Set-Cookie")
fmt.Println(contentType, requestID, setCookies)

Validate the status before decoding a successful response. Error responses often use a different content type and schema.

Set custom headers in a Go HTTP server

For server output, call w.Header().Set before w.WriteHeader or the first w.Write. If you omit WriteHeader, the first write commits an implicit 200 response. After that point, ordinary header changes have no effect.

func handler(w http.ResponseWriter, r *http.Request) {
    requestID := r.Header.Get("X-Request-ID")
    if requestID == "" {
        requestID = "generated-id"
    }

    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Cache-Control", "no-store")
    w.Header().Set("X-Request-ID", requestID)
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte(`{"ok":true}`))
}

func main() {
    http.HandleFunc("/health", handler)
    http.ListenAndServe(":8080", nil)
}

The ResponseWriter documentation states that changing the header map after WriteHeader or Write has no effect, except for 1xx headers and trailers. This timing rule also applies when a helper function writes the body before your code adds a header.

Incoming request headers

Read client-supplied fields from r.Header. Treat them as untrusted input: validate length and allowed characters before using a value in logs, authorization decisions, or another outbound request.

func handler(w http.ResponseWriter, r *http.Request) {
    traceID := r.Header.Get("Traceparent")
    if len(traceID) > 200 {
        http.Error(w, "invalid trace header", http.StatusBadRequest)
        return
    }
    _ = traceID
}

Trailers: values known only after the body

A trailer is different from an ordinary response header because its value is available only after the response body has been generated. If trailer names are known in advance, declare them before writing the response, then assign values later.

func stream(w http.ResponseWriter, r *http.Request) {
    w.Header().Add("Trailer", "X-Checksum")
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusOK)

    _, _ = w.Write([]byte("streamed data\n"))
    w.Header().Set("X-Checksum", "computed-after-stream")
}

Use trailers only when a value truly cannot be known before the response starts. Many clients, proxies, and caches handle ordinary headers more predictably.

Other languages and a screenshot API example

The same header principle applies when an API needs authentication or metadata. ScreenshotNeo is a website screenshot API and MCP server. Its API accepts custom headers, cookies, user agents, authorization, and other capture settings. See the ScreenshotNeo API documentation for the complete option list.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Go

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "time"
)

func main() {
    endpoint, _ := url.Parse("https://api.screenshotneo.com/v1/shot")
    q := endpoint.Query()
    q.Set("access_key", "YOUR_API_KEY")
    q.Set("url", "https://stripe.com")
    endpoint.RawQuery = q.Encode()

    ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
    defer cancel()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
    if err != nil { panic(err) }
    req.Header.Set("Accept", "image/webp")

    resp, err := (&http.Client{Timeout: 95 * time.Second}).Do(req)
    if err != nil { panic(err) }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        b, _ := io.ReadAll(resp.Body)
        panic(fmt.Sprintf("screenshot failed: %s: %s", resp.Status, b))
    }
    f, err := os.Create("shot.webp")
    if err != nil { panic(err) }
    defer f.Close()
    if _, err := io.Copy(f, resp.Body); err != nil { panic(err) }
    fmt.Println("saved shot.webp", resp.Header.Get("X-Page-Verdict"), resp.Header.Get("X-Billed"))
}

Or skip the browser setup

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and each response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with the API or MCP server.

Relevant request options and edge cases

  • Authentication: use the scheme required by the API, commonly Authorization: Bearer .... Never log the token.
  • Content negotiation: send Accept for the response format and Content-Type for the body you are sending.
  • Cookies: use req.AddCookie or the Cookie header for a client request; do not concatenate unvalidated input.
  • Repeated fields: use Add only when the server defines multiple values. Some fields are comma-separated lists; others must be separate lines.
  • Redirects: http.Client follows redirects by default. Header forwarding across hosts has security implications; configure CheckRedirect when needed.
  • Large bodies: stream with an io.Reader instead of loading the entire payload into memory. Limit response sizes with io.LimitReader when the endpoint is untrusted.
  • Proxies and TLS: custom headers do not replace certificate validation. Keep normal TLS verification enabled unless you have a controlled reason to configure a transport.
ScreenshotNeo removes common overlays before capture and reports whether a clean shot was billable.
ScreenshotNeo removes common overlays before capture and reports whether a clean shot was billable.

Troubleshooting checklist

Symptom Cause Fix
Header is missing Used http.Get/http.Post, or wrote the response first Create a request with NewRequest and use Client.Do; set server headers before output
Old value remains Called Add when replacement was intended Use Set before sending
401 or 403 Wrong scheme, token, spelling, or target host after redirect Check the API contract, inspect the final URL and status, and avoid logging secrets
Unexpected 200 handling Do returned nil error but status was an error Check the status code before decoding the success schema
Context deadline exceeded Timeout is shorter than DNS, connection, server, or body-read time Set a realistic context deadline and client timeout; investigate slow upstream work
Response header change has no effect WriteHeader or Write already committed the response Move the assignment earlier, or use a declared trailer
Duplicate authorization values Repeated calls to Add Use Set for singleton headers and inspect req.Header.Values in tests

Performance, reliability, and cost considerations

  • Reuse clients: keep one configured http.Client per transport policy so connections can be reused. Do not create a new client for every request.
  • Bound work: combine context deadlines with Client.Timeout; otherwise a stalled server can consume a goroutine indefinitely.
  • Read efficiently: stream large request and response bodies and close every response body. Connection reuse is reduced when bodies are abandoned.
  • Retry carefully: use bounded exponential backoff for transient failures, respect server retry guidance, and avoid replaying non-idempotent requests without an idempotency strategy.
  • Observe safely: log method, host, status, duration, and a correlation ID, while redacting authorization, cookies, and other secrets.
  • Screenshot cost: with ScreenshotNeo, only clean shots are billed. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing; inspect X-Page-Verdict and X-Billed in your pipeline.

Testing custom headers

Use httptest.NewServer to assert exactly what your client sends without relying on an external service.

func TestHeader(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if got := r.Header.Get("Authorization"); got != "Bearer test" {
            t.Errorf("authorization = %q", got)
        }
        w.WriteHeader(http.StatusNoContent)
    }))
    defer server.Close()

    req, err := http.NewRequest(http.MethodGet, server.URL, nil)
    if err != nil { t.Fatal(err) }
    req.Header.Set("Authorization", "Bearer test")
    resp, err := http.DefaultClient.Do(req)
    if err != nil { t.Fatal(err) }
    resp.Body.Close()
}

FAQ

Can I set a header directly on http.Request.Header?

Yes. Prefer Set and Add because they handle canonicalization and make replacement versus append explicit.

Why does http.Get not accept custom headers?

It is a convenience function for simple requests. Build a request with NewRequest, set its headers, and send it with Client.Do.

When should I use a trailer?

Use one only when its value is unavailable until after the body starts streaming. Declare the trailer name before writing the response.

Are header names case sensitive?

HTTP field names are case insensitive. Go’s Header methods canonicalize names, so use conventional spelling for readability.

How can I avoid leaking credentials?

Keep tokens in environment or secret storage, redact authorization and cookies from logs, and avoid putting secrets in URLs because URLs are commonly recorded by proxies and access logs.