TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Headers, caching and cookies

Content negotiation, ETags, 304 responses and session cookies.

Earlier lessons treated headers as metadata you read off a response. This one is about headers that change what the server does: which format it sends, whether it sends a body at all, whether it accepts your update, and who it thinks you are. Bugs here are easy to miss because the body usually looks fine.

Content negotiation

The Accept request header lists the formats the client can handle. Some APIs can answer in more than one, and pick based on this header. Try it:

GET/api/formats/negotiate
This request uses an endpoint for signed-in members.Sign inCreate a free account
Terminal
$ curl -i -H "Accept: application/xml" https://api.testingapis.com/api/formats/negotiate

HTTP/1.1 200 OK
Content-Type: application/xml; charset=utf-8
vary: Accept

<?xml version="1.0" encoding="UTF-8"?><user><id>1</id><name>Alice Johnson</name><email>alice@example.com</email><active>true</active></user>
This example uses endpoints for signed-in members.Sign inCreate a free account

Without an Accept header, the same endpoint returns JSON:

JSON
{"id":1,"name":"Alice Johnson","email":"alice@example.com","roles":["admin","editor"],"active":true,"score":98.5,"manager":null}

Look at the two bodies side by side. The JSON has roles, score and manager. The XML doesn't. The same user, two representations, and one of them is missing three fields. The CSV version is missing them too. Nobody notices this kind of bug by testing one format. When an API supports several, compare them field by field.

Also ask for something the server can't produce. The correct answer is 406 Not Acceptable, and this API gets it right, including a list of what it does support:

Terminal
$ curl -i -H "Accept: image/png" https://api.testingapis.com/api/formats/negotiate

HTTP/1.1 406 Not Acceptable
Content-Type: application/json; charset=utf-8
vary: Accept

{"status":406,"error":"Not Acceptable","message":"None of the requested media types are supported: 'image/png'.","supported":["application/json","application/xml","text/xml","text/html","text/plain","text/csv"]}
This example uses endpoints for signed-in members.Sign inCreate a free account

Many APIs ignore Acceptand return JSON regardless. That is allowed, and usually harmless, but if the documentation promises XML, it's a bug.

Accept-Language and Vary

Accept-Language works the same way for human languages. The header can list several, with weights:

Terminal
$ curl -i -H "Accept-Language: de-DE,de;q=0.9,en;q=0.5" https://api.testingapis.com/api/cache/vary

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=60
content-language: de
vary: Accept-Language

{"language":"de","greeting":"Hallo, willkommen bei TestingAPIs!","supported":["en","es","fr","de","hi"]}
This example uses endpoints for signed-in members.Sign inCreate a free account

The response says which language it chose in Content-Language. It also has Vary: Accept-Language, which tells caches that this URL has different versions depending on that header. Without Vary, a shared cache could store the German response and hand it to the next person, who asked for French. If a response changes based on a request header, check that the header is listed in Vary. Also test a language the server doesn't support: Accept-Language: xx here falls back to English with a 200, which is the usual choice.

Caching

HTTP caching lets a client, a CDN or a proxy reuse a response instead of asking again. The server controls it with the Cache-Control header. Two values cover most cases. max-age=N says the response can be reused for N seconds. no-store says it must never be stored.

GET/api/cache/max-age/60
This request uses an endpoint for signed-in members.Sign inCreate a free account

Press Send, note generatedAt, and press it again within a minute. Your browser should reuse the stored response, so the timestamp stays the same. With curl you would see a new timestamp on every request, because curl has no cache. That difference trips up people who test in one tool and debug in another.

Terminal
$ curl -i https://api.testingapis.com/api/cache/no-store

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
expires: 0
pragma: no-cache

{"requestId":"efc17de6-279c-4b43-9bd6-cca7f7eee569","generatedAt":"2026-09-30T18:31:59.755Z","hint":"Never cached: every request returns a new requestId."}
This example uses endpoints for signed-in members.Sign inCreate a free account

What to test depends on the data. Anything personal (a profile, an account balance, a list of orders) should have no-store or private, and never public. A public, max-age=3600 on /api/mesign in to seemeans a CDN may serve one user's profile to another user for an hour. That has happened in production at real companies. The opposite mistake is less dangerous but still costs money: static data like a country list with no-store, so every page load hits the server.

ETags and 304

An ETag is a version label for a response. The client stores it and sends it back later in If-None-Match. If the resource hasn't changed, the server answers 304 Not Modified with no body, and the client uses its stored copy. This API has a document built for trying it. Reset it first, since other people use it too:

Terminal
$ curl -X POST https://api.testingapis.com/api/cache/etag/reset
{"message":"Document reset to version 1"}

$ curl -i https://api.testingapis.com/api/cache/etag

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-cache
etag: "doc-v1"

{"id":1,"version":1,"title":"TestingAPIs cached document","content":"Send If-None-Match to get a 304, and If-Match on PUT to make a conditional update.","updatedAt":"2025-01-01T00:00:00Z","etag":"\"doc-v1\""}
This example uses endpoints for signed-in members.Sign inCreate a free account
Terminal
$ curl -i -H 'If-None-Match: "doc-v1"' https://api.testingapis.com/api/cache/etag

HTTP/1.1 304 Not Modified
Cache-Control: no-cache
etag: "doc-v1"
This example uses endpoints for signed-in members.Sign inCreate a free account

A 304 and no body. Send a different value, say "doc-v9", and you get the full 200 response again. Those are the two tests: matching tag gives 304, anything else gives 200 with the current tag.

Browsers do the If-None-Match dance on their own and show your code a 200 even when the network response was a 304. Test conditional requests with curl or a test script, where you control the headers.

If-Match and lost updates

ETags also prevent lost updates. Two people load the same document. Both edit it. The second save silently overwrites the first. With If-Match, each save says "only apply this if the document is still the version I loaded":

Terminal
$ curl -i -X PUT https://api.testingapis.com/api/cache/etag \
    -H "Content-Type: application/json" \
    -d '{"title":"Edited","content":"Changed by a test"}'

HTTP/1.1 428 Precondition Required
{"status":428,"error":"Precondition Required","message":"If-Match header is required. GET /api/cache/etag and send its ETag (currently \"doc-v1\")."}

$ curl -i -X PUT https://api.testingapis.com/api/cache/etag \
    -H 'If-Match: "doc-v1"' \
    -H "Content-Type: application/json" \
    -d '{"title":"Edited","content":"Changed by a test"}'

HTTP/1.1 200 OK
etag: "doc-v2"
{"message":"Document updated","data":{"id":1,"version":2,"title":"Edited","content":"Changed by a test","updatedAt":"2026-09-30T18:32:10Z","etag":"\"doc-v2\""}}

$ curl -i -X PUT https://api.testingapis.com/api/cache/etag \
    -H 'If-Match: "doc-v1"' \
    -H "Content-Type: application/json" \
    -d '{"title":"Edited again","content":"x"}'

HTTP/1.1 412 Precondition Failed
{"status":412,"error":"Precondition Failed","message":"ETag mismatch: document has changed. Current ETag is \"doc-v2\"."}
This example uses endpoints for signed-in members.Sign inCreate a free account

Three different outcomes. No If-Match at all gives 428 Precondition Required, because this endpoint insists on it. The current tag gives 200 and a new version. The old tag, sent a second time, gives 412 Precondition Failed. That last one is the second person's save, correctly refused. Reset the document when you're done.

If an API documents optimistic locking like this, test the stale write every time. It is the whole point of the feature, and it is rarely covered by the developer's own tests.

Last-Modified

Last-Modified and If-Modified-Since do the same job with a date instead of a tag:

Terminal
$ curl -i https://api.testingapis.com/api/cache/last-modified

HTTP/1.1 200 OK
Cache-Control: no-cache
last-modified: Wed, 01 Jan 2025 00:00:00 GMT

$ curl -o /dev/null -w "%{http_code}\n" -H "If-Modified-Since: Wed, 01 Jan 2025 00:00:00 GMT" https://api.testingapis.com/api/cache/last-modified
304

$ curl -o /dev/null -w "%{http_code}\n" -H "If-Modified-Since: Tue, 31 Dec 2024 00:00:00 GMT" https://api.testingapis.com/api/cache/last-modified
200

$ curl -o /dev/null -w "%{http_code}\n" -H "If-Modified-Since: not a date" https://api.testingapis.com/api/cache/last-modified
200
This example uses endpoints for signed-in members.Sign inCreate a free account

The same date and any later date give 304. An earlier date gives 200. An invalid date is ignored and gives 200, which is what the HTTP spec requires. Dates have only one-second precision, so this scheme can't tell apart two changes in the same second. ETags can.

Cookies

A cookie is a value the server asks the client to store and send back. The server sets it with Set-Cookie, and the client returns it in a Cookie header on later requests to the same site. Cookies carry sessions, preferences and CSRF tokens.

The attributes after the value decide how safe the cookie is. You can set each one on this endpoint:

Terminal
$ curl -i "https://api.testingapis.com/api/cookies/set/pref/compact?sameSite=strict&secure=true&httpOnly=true&maxAge=600"

HTTP/1.1 200 OK
Set-Cookie: pref=compact; max-age=600; path=/; secure; samesite=strict; httponly
This example uses endpoints for signed-in members.Sign inCreate a free account
AttributeWhat it doesWhat to check
HttpOnlyJavaScript on the page can't read the cookie.Session cookies must have it, so an XSS bug can't steal the session.
SecureOnly sent over HTTPS.Session cookies on an HTTPS site must have it.
SameSiteControls whether the cookie is sent on requests from other sites. Strict, Lax or None.Lax or Strict for sessions. None requires Secure.
Max-Age / ExpiresWhen the cookie is deleted. Without either, it lasts until the browser closes.Session lifetime matches the requirements.
Path / DomainWhich URLs receive the cookie.Not wider than needed, especially Domain.

This API warns you when a combination won't work in browsers:

Terminal
$ curl -s "https://api.testingapis.com/api/cookies/set/pref/compact?sameSite=none&maxAge=600"

{"message":"Cookie 'pref' set", ... ,"setCookieHeader":"pref=compact; max-age=600; path=/; samesite=none","warning":"Browsers reject SameSite=None cookies without Secure."}
This example uses endpoints for signed-in members.Sign inCreate a free account

A login session

Cookie-based login is a sequence of requests, and curl can follow it with a cookie jar: -c jar.txt saves cookies from the response, -b jar.txt sends them. Log in:

Terminal
$ curl -i -c jar.txt -X POST https://api.testingapis.com/api/cookies/login \
    -H "Content-Type: application/json" \
    -d '{"username":"test","password":"test123"}'

HTTP/1.1 200 OK
Set-Cookie: apibee_session=283afe92b86a96f3f5648a62add23aa2; max-age=3600; path=/; samesite=lax; httponly

{"message":"Logged in. The session cookie is sent automatically on later requests.","username":"test","cookie":"apibee_session","expiresAt":"2026-09-30T19:32:41Z"}
This example uses endpoints for signed-in members.Sign inCreate a free account

Then ask who you are, sending the jar:

Terminal
$ curl -b jar.txt https://api.testingapis.com/api/cookies/me

{"user":{"id":1,"username":"test","name":"Test User","email":"test@example.com","role":"user"},"session":{"expiresAt":"2026-09-30T19:32:41Z"}}
This example uses endpoints for signed-in members.Sign inCreate a free account

Look back at the Set-Cookie on the login response. It has HttpOnly and SameSite=Lax, but no Secure, and the request went over HTTPS. The reference says the cookie is "Secure only over HTTPS". The likely cause is that HTTPS ends at a proxy in front of the app, so the app thinks the request arrived over plain HTTP. It is a very common bug in real deployments and one you only find by reading the actual header, not the documentation.

Now log out:

Terminal
$ curl -i -b jar.txt -c jar.txt -X POST https://api.testingapis.com/api/cookies/logout

HTTP/1.1 200 OK
Set-Cookie: apibee_session=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/

{"message":"Logged out"}
This example uses endpoints for signed-in members.Sign inCreate a free account

The server expires the cookie by setting a date in 1970. But deleting the cookie on the client isn't enough. The session also has to end on the server, or anyone who copied the cookie value can keep using it. Test that by sending the old value by hand after logout:

Terminal
$ curl -i -b "apibee_session=5bc348fd740edcc67d70a851005e7547" https://api.testingapis.com/api/cookies/me

HTTP/1.1 401 Unauthorized
{"status":401,"error":"Unauthorized","message":"Not logged in. POST /api/cookies/login first to get the 'apibee_session' cookie."}
This example uses endpoints for signed-in members.Sign inCreate a free account

401, so the session really ended. Keep this test in your checklist. It fails more often than you'd expect.

Why it works in curl and fails in the browser

Sooner or later you'll get a bug report that says the API is broken, and every request you send with curl works. Often the cause is CORS. Browsers stop a page on one site from reading responses from another site unless the server allows it with Access-Control-Allow-*headers. curl and Postman don't enforce CORS at all, so they can't reproduce the problem.

For requests with custom headers or methods like PUT, the browser first sends an OPTIONS request, called a preflight, and only sends the real request if the answer allows it. You can send the preflight yourself:

Terminal
$ curl -i -X OPTIONS https://api.testingapis.com/api/cache/etag \
    -H "Origin: https://example.com" \
    -H "Access-Control-Request-Method: PUT" \
    -H "Access-Control-Request-Headers: If-Match, Content-Type"

HTTP/1.1 204 No Content
access-control-allow-headers: If-Match,Content-Type
access-control-allow-methods: PUT
access-control-allow-origin: *
This example uses endpoints for signed-in members.Sign inCreate a free account

This API allows any origin, which is why the Send buttons on these pages work. When you test an API used by a web app, check the preflight for each method and custom header the app uses, and check which response headers are listed in Access-Control-Expose-Headers: a browser can't read a header like X-Total-Count unless it is listed there, even when the response contains it.

  • Access-Control-Allow-Origin: *is fine for public data. It can't be combined with cookies, and an API that echoes back any Origin together with Access-Control-Allow-Credentials: true lets any website make logged-in requests as your users.
  • Browsers refuse to let scripts set some headers, including Cookie and Host. That's why the cookie examples in this lesson use curl.

Exercise

Write expected results first, then check them with curl.

  • Request /api/formats/negotiatesign in to see with Accept: text/csv, then with Accept: text/csv;q=0.5, application/xml. Which format should the second one return, and does it?
  • Log in at /api/cookies/loginsign in to see as admin / admin123 using a cookie jar. Call /api/cookies/mesign in to see, then log out, then call /api/cookies/mesign in to see again with the session value you saved before logging out.
  • Reset the ETag document, then play both editors in a lost-update scenario: two GETs, two PUTs with the same If-Match. Write down the exact status and body of each request, and reset the document at the end.

Practise and go further