HTTP Headers for API Testing: Content-Type, Accept, Caching and ETag

HTTP Headers for API Testing: Content-Type, Accept, Caching and ETag

Headers are the small key-value lines that travel with every request and response. They decide whether the server reads your body as JSON, who you are, which format comes back, whether a response may be cached, and whether two people editing the same record overwrite each other. Many testers ignore headers and miss whole categories of bugs. In this lesson you will learn the headers that matter for API testing, with real-life examples and live calls.

Quick answerHTTP headers carry metadata about a request or response. Key request headers are Content-Type, Accept, Authorization, If-None-Match and If-Match. Key response headers are Content-Type, Location, Cache-Control, ETag, Allow, Retry-After and WWW-Authenticate. Testers verify that the right headers are sent, the right ones come back, caching behaves correctly (304 Not Modified) and security headers are present.
Advertisement

What Are HTTP Headers? (The Parcel Label Example)

Real-life example: a courier parcel The body is what’s inside the box. The headers are the label on the outside: “Fragile” (how to handle it), “Contents: documents” (Content-Type), “Deliver only to Asha with ID” (Authorization), “Weight 2 kg” (Content-Length), “Return if not delivered by Friday” (expiry). The courier reads the label without opening the box.

Headers are written as Name: value, one per line. Names are case-insensitive: content-type and Content-Type are the same header (Playwright returns all names in lower case).

Request Headers Every Tester Should Know

HeaderExamplePurposeWhat to test
Content-Typeapplication/jsonFormat of the body you sendWrong type → 415
Acceptapplication/xmlFormat you want backUnsupported → 406
AuthorizationBearer eyJhbGci...Who you areMissing/expired/tampered → 401
X-API-Keysqa-key-12345API key auth (custom header)Missing → 401, wrong → 403
If-None-Match"c2aee9e2..."“Send only if changed”Unchanged → 304
If-Match"v3"“Update only if still this version”Stale → 412
User-AgentMyApp/5.2 (Android 15)Identifies the clientVersion-specific behaviour
Accept-Languagehi-INPreferred languageLocalised messages
X-Request-Id7f3c-91aaTrace id for logsEchoed back; appears in logs
Idempotency-Keyord-2026-0001Make POST retries safeSame key → no duplicate
Advertisement

Response Headers Every Tester Should Check

HeaderExampleWhy it matters
Content-Typeapplication/json; charset=UTF-8Client parses the body correctly; wrong type breaks apps
Location.../objects/obj_hoeovqif1vURL of the newly created resource (201)
ETag"v1"Version fingerprint for caching and locking
Cache-Controlno-store, max-age=0Whether and how long to cache
AllowGET, HEADMethods allowed (with 405 and OPTIONS)
Retry-After10When to retry after 429/503
WWW-AuthenticateBasic realm="SQA practice API"Which auth scheme to use (with 401)
Link<...page=2>; rel="next"Pagination navigation
X-Total-Count12Total records for paging UIs
X-RateLimit-Remaining4Calls left in the window

See them live. Press Send and look at link and x-total-count in the output:

GEThttps://sqatools.in/wp-json/sqa-api/v1/users?per_page=2
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/users?per_page=2"

Content Negotiation: Content-Type vs Accept

These two are confused all the time:

  • Content-Type describes the body being sent (in a request: what you send; in a response: what the server sends).
  • Accept is a request header saying what format you would like back.
Restaurant analogy Content-Type is the language your order slip is written in. Accept is “please reply in English”. If the kitchen only understands Hindi slips, an English slip gets 415; if the waiter can’t reply in French, you get 406.
GEThttps://sqatools.in/wp-json/sqa-api/v1/users/1
curl -i \
  -H "Accept: text/csv" \
  "https://sqatools.in/wp-json/sqa-api/v1/users/1"

The practice API answers 406 because it can return only JSON or XML. Change Accept to application/xml and you get XML.

Advertisement

Caching Headers: Cache-Control, ETag and 304

Caching saves bandwidth and makes apps fast. A product image or a catalogue page that hasn’t changed doesn’t need to be downloaded again.

Cache-Control

  • max-age=3600: the response can be reused for 1 hour.
  • no-cache: may be stored, but must be revalidated with the server before use.
  • no-store: never store it. Required for sensitive data such as bank balances, OTPs and personal details.
  • private vs public: whether shared caches (CDN, proxies) may store it.
Real bug: cached account page A banking API returned account details with Cache-Control: public, max-age=600. A shared corporate proxy cached it, and the next employee saw a colleague’s balance. Testers should verify that every personal-data response has no-store or private.

ETag and conditional requests

An ETag is a fingerprint of the current version of a resource. The client stores it and next time asks: “send it only if it changed” using If-None-Match.

  1. GET /products/101 → 200, ETag: "c2aee9e25c4daa9445361e1bb4a749f7".
  2. GET /products/101 with If-None-Match: "c2aee9e25c4daa9445361e1bb4a749f7" → 304 Not Modified, empty body.
GEThttps://sqatools.in/wp-json/sqa-api/v1/products/101
curl -i \
  -H "If-None-Match: \"c2aee9e25c4daa9445361e1bb4a749f7\"" \
  "https://sqatools.in/wp-json/sqa-api/v1/products/101"

If the browser shows 200 instead of 304 here, the product data has changed since this lesson was written; copy the new ETag from a normal GET and try again.

Advertisement

Optimistic Locking with If-Match (Prevent Lost Updates)

Real-life example: two support agents, one ticket Agent A and Agent B both open ticket #500 (version v3). A changes the priority and saves (now v4). B, still looking at the old screen, changes the status and saves. Without protection, B’s save overwrites A’s priority change: a lost update. With If-Match: "v3", B’s save is rejected with 412 Precondition Failed and B is told to reload.
# 1. Create an object, note ETag "v1"
curl -i -X PUT "https://sqatools.in/wp-json/sqa-api/v1/objects/ticket-500" \
  -H "Content-Type: application/json" -d '{"name":"Ticket 500","data":{"priority":"low"}}'

# 2. Update with the current version: 200, ETag becomes "v2"
curl -i -X PATCH ".../objects/ticket-500" -H "If-Match: \"v1\"" \
  -H "Content-Type: application/json" -d '{"data":{"priority":"high"}}'

# 3. Update again with the OLD version: 412 Precondition Failed
curl -i -X PATCH ".../objects/ticket-500" -H "If-Match: \"v1\"" \
  -H "Content-Type: application/json" -d '{"data":{"status":"closed"}}'

The practice API’s 412 message: If-Match “v1” does not match the current version “v2”. Someone changed the object; GET it again.

Security Headers to Verify

HeaderExpected valueProtects against
Strict-Transport-Securitymax-age=31536000; includeSubDomainsHTTPS downgrade attacks
X-Content-Type-OptionsnosniffBrowsers guessing the wrong content type
Cache-Controlno-store for personal dataData left in caches
Access-Control-Allow-OriginSpecific trusted origins, not * with credentialsCross-site data theft (CORS)
No Server / X-Powered-By versionsRemoved or genericAttackers targeting known versions

CORS is covered in detail in HTTPS, Cookies and CORS.

Advertisement

Testing Headers with Playwright

import { test, expect } from '@playwright/test';

const BASE = 'https://sqatools.in/wp-json/sqa-api/v1';

test('JSON content type and no caching of user data', async ({ request }) => {
  const res = await request.get(`${BASE}/users/1`);
  const h = res.headers();                       // all names are lower case
  expect(h['content-type']).toContain('application/json');
  expect(h['cache-control']).toContain('no-store');
  expect(h['x-content-type-options']).toBe('nosniff');
});

test('ETag + If-None-Match gives 304', async ({ request }) => {
  const first = await request.get(`${BASE}/products/101`);
  const etag = first.headers()['etag'];
  expect(etag).toBeTruthy();

  const second = await request.get(`${BASE}/products/101`, {
    headers: { 'If-None-Match': etag },
  });
  expect(second.status()).toBe(304);
  expect((await second.body()).length).toBe(0);
});

test('stale If-Match is rejected with 412', async ({ request }) => {
  const url = `${BASE}/objects/lock-${Date.now()}`;
  await request.put(url, { data: { name: 'Ticket', data: { priority: 'low' }, ttl: 60 } });  // v1
  await request.patch(url, { data: { data: { priority: 'high' } }, headers: { 'If-Match': '"v1"' } }); // v2
  const stale = await request.patch(url, { data: { data: { status: 'closed' } }, headers: { 'If-Match': '"v1"' } });
  expect(stale.status()).toBe(412);
});

test('pagination headers', async ({ request }) => {
  const res = await request.get(`${BASE}/users?per_page=5`);
  expect(res.headers()['x-total-count']).toBe('12');
  expect(res.headers()['link']).toContain('rel="next"');
});
Advertisement

Header Testing Checklist

  • Correct Content-Type on every response, including errors.
  • Missing/wrong Content-Type on requests gives 415, not 500.
  • Unsupported Accept gives 406.
  • Location on 201; Allow on 405; WWW-Authenticate on 401; Retry-After on 429/503.
  • Personal data is never publicly cacheable.
  • ETag changes when the resource changes; 304 when it doesn’t; 412 on stale If-Match.
  • No version-revealing headers; security headers present.
  • Header names treated case-insensitively.
Advertisement

Interview Questions: HTTP Headers

What is the difference between Content-Type and Accept headers?

Content-Type describes the format of the body being sent (by the client in a request or by the server in a response). Accept is sent by the client to say which response formats it can handle. A wrong Content-Type leads to 415; an Accept the server can’t satisfy leads to 406.

What is an ETag?

An ETag is an identifier for a specific version of a resource returned in a response header. Clients send it back with If-None-Match to get 304 Not Modified when nothing changed, or with If-Match to update only if the resource is still at that version.

What is optimistic locking in APIs?

A concurrency control technique where updates include the expected version (If-Match with the ETag). If someone else changed the resource first, the server returns 412 Precondition Failed instead of silently overwriting their change.

What is the difference between no-cache and no-store?

no-cache allows storing the response but requires revalidation with the server before each reuse. no-store forbids storing it anywhere and is required for sensitive data.

Which headers do you check in API testing?

Content-Type, Cache-Control, Location for creates, ETag, Allow for 405, WWW-Authenticate for 401, Retry-After for 429 and 503, pagination headers, rate limit headers, CORS headers and security headers such as Strict-Transport-Security and X-Content-Type-Options.

Are HTTP header names case-sensitive?

No. Header names are case-insensitive, so tests should not depend on case. Header values may be case-sensitive depending on the header.

Where do you send an authentication token?

Usually in the Authorization header, for example Authorization: Bearer token. Some APIs use custom headers like X-API-Key. Tokens should not be sent in query strings because URLs are logged.

How does a client know how long to wait after 429 or 503?

From the Retry-After header, which contains seconds or a date. Rate limit headers such as X-RateLimit-Reset may also be provided.

What is the purpose of a request id header?

A request or correlation id (for example X-Request-Id) uniquely identifies a call across all services and logs. Including it in bug reports lets developers find the exact failing request.

How do you test that a response was cached correctly?

Check Cache-Control and ETag on the first response, send a conditional request with If-None-Match and expect 304, change the resource and expect 200 with a new ETag, and confirm that personal data responses are not cacheable.

Advertisement
View the full API testing syllabus