Reading a response
Status codes, response headers and the body.
Every request gets exactly one response (unless the connection breaks, which is its own kind of answer and comes up in a later lesson). A response has three parts: a status line, headers and a body. New testers tend to look only at the body, because that is where the data is. Most of the bugs that slip past them are in the other two parts.
What a response looks like
Like the request, an HTTP/1.1 response is plain text. This is the response to GET /api/health, as curl shows it with the -i option (which prints the headers along with the body). A few infrastructure headers are removed.
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
x-powered-by: TestingAPIs
{"status":"healthy","uptimeSeconds":3542,"timestamp":"2026-09-30T18:28:12Z"}The first line is the status line: protocol version, a three-digit status code and a short reason phrase. Then come headers, one per line, then an empty line, then the body. The reason phrase is only for humans. Clients and tests should look at the number. HTTP/2 drops the reason phrase entirely, so a test that checks for the text "OK" can break when the server upgrades.
Status code classes
The first digit of the status code tells you the class of the response. You can often judge a result from that digit alone.
| Class | Meaning | Who is at fault |
|---|---|---|
| 1xx | Informational. The request is still in progress. | Nobody. You rarely see these directly. |
| 2xx | Success. The server did what you asked. | Nobody. |
| 3xx | Redirection. Look somewhere else. | Nobody. The client has to follow up. |
| 4xx | Client error. The request was wrong. | The client: bad input, no permission, wrong URL. |
| 5xx | Server error. The request may have been fine. | The server or something behind it. |
That last column is the reason status codes matter so much in testing. A 4xx says "fix your request". A 5xx says "something broke on our side". If you send garbage and get a 500, that is a bug even though the request was bad, because the server crashed where it should have rejected the input politely with a 400. Finding those 500s is one of the most productive things a new tester can do.
The codes you will see most
| Code | Name | Typical use |
|---|---|---|
| 200 | OK | The request worked and the body has the result. |
| 201 | Created | A POST created a new record. |
| 204 | No Content | It worked and there is deliberately no body. Common after DELETE. |
| 301 / 302 | Moved Permanently / Found | The resource is at the URL in the Location header. |
| 304 | Not Modified | Your cached copy is still current. Covered with caching. |
| 400 | Bad Request | The request is malformed or a value is invalid. |
| 401 | Unauthorized | No credentials, or wrong ones. Despite the name, it means unauthenticated. |
| 403 | Forbidden | Credentials are fine, but this user may not do this. |
| 404 | Not Found | Nothing at this URL, or no record with this ID. |
| 405 | Method Not Allowed | The URL exists but not with this method. |
| 409 | Conflict | The request clashes with the current state, such as a duplicate email. |
| 422 | Unprocessable Content | The syntax is fine but the data breaks a rule. |
| 429 | Too Many Requests | You hit a rate limit. Slow down. |
| 500 | Internal Server Error | An unhandled error on the server. |
| 502 | Bad Gateway | A proxy got a bad answer from the server behind it. |
| 503 | Service Unavailable | The server is overloaded or down for maintenance. |
APIs disagree about some of these. One team returns 400 for every validation error and another uses 422. Some return 200 for a delete with a message in the body, others 204 with nothing. Neither is wrong. What you check is that the API matches its own documentation and is consistent from one endpoint to the next.
Trying status codes
/api/status/{code} answers with whatever code you put in the path. It is useful for seeing how a tool or a client library behaves when it gets a particular status. Try a few:
/api/status/201/api/status/503From curl, the 503 looks like this:
HTTP/1.1 503 Service Unavailable
Content-Type: application/json; charset=utf-8
retry-after: 5
{
"status": 503,
"statusText": "Service Unavailable",
"category": "server-error",
"message": "This is a 503 response. Use /api/status/{code} to request any status."
}Notice the retry-after: 5 header. A 503 or 429 often tells the client how many seconds to wait before trying again, and a well behaved client respects it. You check that the header is there, not only the status.
Codes outside the valid range are rejected. /api/status/999 comes back as a 400 with the message Status code must be between 200 and 599 (1xx cannot be sent as a final response). And /api/status/abc returns a 404 with an empty body, because abcdoesn't match the route at all. Two bad inputs, two different kinds of failure. You will see this pattern often: a value of the wrong type tends to fail in the routing layer, before the application code ever runs.
Redirects
A 3xx response usually has no useful body. The important part is the Location header, which says where to go next:
HTTP/1.1 302 Found
Content-Type: application/json; charset=utf-8
location: /api/echoBrowsers and most HTTP libraries follow redirects automatically and show you only the final response. That is convenient until you need to test the redirect itself. curl doesn't follow them unless you add -L, which makes it the better tool for this.
Response headers
Response headers describe the body and give the client instructions. Ask for page 3 of the product list, two products per page:
/api/Products?limit=2&page=3HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-page: 3
x-per-page: 2
x-total-count: 20
x-total-pages: 10Content-Type says the body is JSON in UTF-8. The four x-page and x-total headers carry the pagination details: there are 20 products, 10 pages, and this is page 3. The body is only an array of two products, so a client that ignores these headers has no way of knowing there is more. Headers that start with X- are custom ones the API invented. They are documented by the API, not by the HTTP standard.
Some headers hold times as Unix timestamps: the number of seconds since 1 January 1970, UTC. Rate-limited APIs often send one in x-ratelimit-reset to say when your allowance resets. Convert it before trusting it: a reset time in the past, or years away, is a bug you only catch by reading headers instead of skipping them. (The timestamp converter does the conversion.)
You may expect a Content-Length header. This API mostly sends Transfer-Encoding: chunkedinstead, which means the body is sent in pieces and the total size isn't announced up front. Both are valid. A response has one or the other, not both.
The body
The body is the data: a product, a list, an error message, an image. Content-Type says how to read it. Most of your checks will be on the body, and Reading JSON covers that in detail. Two special cases come first.
No body at all
A 204 means success with no body, on purpose. The response has no Content-Type either, because there is nothing to describe:
/api/formats/no-contentHTTP/1.1 204 No Content
x-powered-by: TestingAPIsClient code that always calls response.json()will crash on this, because an empty string isn't valid JSON. If you write test helpers that parse every response, they need to handle an empty body.
When the headers lie
Status, headers and body are produced by different parts of the server, and nothing forces them to agree. Here is a response that claims to be HTML:
/api/formats/wrong-content-typeHTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
{"id":1,"name":"Alice Johnson","email":"alice@example.com","roles":["admin","editor"],"active":true,"score":98.5,"manager":null}The body is perfectly good JSON. A person reading it would never notice a problem. A browser would try to render it as a web page, and some HTTP libraries will refuse to parse it as JSON or return it as a string. This endpoint is broken on purpose. In real systems the same bug usually comes from an error page generated by a proxy or a framework default. Your test should check the Content-Type explicitly.
The mirror image happens too: a 200 status with a body that says {"success": false}. Some APIs report every error that way. Monitoring tools that count 5xx responses never see those failures, and neither does a test that checks only the status code. If an API does this, your tests have to read the body on every response.
What to check on every response
Before you look at a single field of data, confirm the basics. It takes a few seconds and catches a surprising number of bugs.
- The status code is the one the docs promise for this situation.
Content-Typematches the body you actually got.- Headers the docs mention (pagination,
Location,Retry-After) are present and make sense. - The body parses, and has the shape you expect.
- Error responses follow the same format as other errors from this API.
Exercise
For each request, write down the status code, the Content-Type and one sentence about the body.
/api/status/418. Look up why this code exists./api/formats/emptysign in to see. How does it differ from/api/formats/no-contentsign in to see? One returns 200 and one returns 204. Which do you think a client handles better?/api/Products?limit=2&page=99. There is no page 99. What status and body did you get, and what do the pagination headers say? Would you have designed it the same way?/api/status/list. Find a code in the list marked"supported": falseand try requesting it.