Chapter 3 · HTTP and Web APIs
HTTP Methods, Status Codes, Headers and JSON
- Page 12 of 22
- 16 min read
On the previous page you saw the shape of an HTTP message: a start line, headers, a blank line, a body. This page fills in the four things that carry almost all the meaning in an API call: the method (what you want to do), the status code (how it went), the headers (the details around the message) and the body, which for APIs is nearly always JSON.
These are exactly the facts you read when an LLM call fails. A 401 means your key is wrong; a 400 often means the prompt was too long or a parameter was invalid; a 429 means "slow down" and should be retried later; a 503 means the provider is overloaded and a retry may work. Code that treats all of these the same either gives up too early or hammers a service that is asking it to stop.
What you will learn
- The HTTP methods GET, POST, PUT, PATCH and DELETE, and which ones are safe to repeat.
- The five families of status codes, and the 15 codes you will meet most in APIs.
- The headers that matter most:
Content-Type,Accept,Authorization,User-Agent. - JSON: its six value types, how they map to Python, and the mistakes that make it invalid.
- Path parameters vs query parameters vs the request body, and common content types.
Every example uses the Practice API on http://localhost:8800 (set up on the page Web APIs and REST: Reading Docs and Testing with curl). Two of its endpoints exist just for learning: /v1/echo sends back what it received (method, query, a few headers and the JSON body), and /v1/status/{code} answers with whatever status code you ask for.
Methods: what do you want to do?
The method is the verb of the request. The path names a thing (a resource); the method says what to do with it.
| Method | Meaning | Example | Body? | Safe | Idempotent |
|---|---|---|---|---|---|
GET | Read | GET /v1/courses/2 | No | Yes | Yes |
POST | Create, or "do this action" | POST /v1/reviews, POST /v1/chat | Yes | No | No |
PUT | Replace the whole resource | PUT /v1/courses/2 with all fields | Yes | No | Yes |
PATCH | Change some fields | PATCH /v1/courses/2 with {"hours": 14} | Yes | No | Usually not |
DELETE | Remove | DELETE /v1/reviews/7 | Rarely | No | Yes |
- Safe means "only reads, changes nothing on the server". You can send a GET as often as you like.
- Idempotent means "sending it twice has the same effect as sending it once". Deleting review 7 twice still leaves it deleted; PUT-ting the same full record twice leaves the same record. POST is neither: sending "create a review" twice creates two reviews, and sending a chat request twice is charged twice.
This is why the difference matters in practice: when a request times out, you do not know whether the server did the work. Repeating a GET is harmless; repeating a POST may double a payment or an LLM bill. Retries (three pages from now) are safe by default only for safe and idempotent requests. You will also see HEAD (like GET, headers only) and OPTIONS (what is allowed here?), mostly used by tools and browsers.
Methods with curl
curl sends GET unless you say otherwise. -X chooses the method; --json sends a JSON body (and implies POST). Ask the echo endpoint what it received:
curl -s -X DELETE "http://localhost:8800/v1/echo?id=7"
echo
curl -s -X PUT http://localhost:8800/v1/echo --json '{"title": "Python for AI", "hours": 12}'
echo
curl -s -X PATCH http://localhost:8800/v1/echo --json '{"hours": 14}'{"method":"DELETE","query":{"id":"7"},"headers":{"user-agent":"curl/8.5.0","accept":"*/*"},"json":null}
{"method":"PUT","query":{},"headers":{"user-agent":"curl/8.5.0","content-type":"application/json","accept":"application/json"},"json":{"title":"Python for AI","hours":12}}
{"method":"PATCH","query":{},"headers":{"user-agent":"curl/8.5.0","content-type":"application/json","accept":"application/json"},"json":{"hours":14}}Notice that --json also set two headers for you, content-type: application/json and accept: application/json. (echo just adds a line break between the outputs. The user-agent shows your curl version, which may differ from 8.5.0.)
A server decides which methods each path accepts. Ask for one it does not allow and you get 405 Method Not Allowed, with an allow header listing what is allowed:
curl -si -X DELETE http://localhost:8800/v1/courses/2 | grep -v '^date' | tr -d '\r'HTTP/1.1 405 Method Not Allowed
server: uvicorn
allow: GET
content-length: 31
content-type: application/json
{"detail":"Method Not Allowed"}Status codes: how did it go?
The first digit tells you the family. Read it before anything else:
| Family | Meaning | Whose problem? |
|---|---|---|
1xx | Informational (rare in APIs) | Nobody's |
2xx | Success | Nobody's |
3xx | Redirection: look elsewhere, or use your cached copy | Nobody's; follow it |
4xx | Client error: the request was wrong | Yours: fix the request. Retrying unchanged will fail again (429 is the exception) |
5xx | Server error: the request may be fine, the server failed | Theirs: often temporary, a later retry may work |
The Practice API's /v1/status/{code} returns any code you ask for. -o /dev/null throws the body away and -w '%{http_code}' prints just the code, a combination you will use often in scripts:
for code in 200 201 204 404 429 503; do
curl -s -o /dev/null -w "asked for $code, got %{http_code}\n" "http://localhost:8800/v1/status/$code"
doneasked for 200, got 200
asked for 201, got 201
asked for 204, got 204
asked for 404, got 404
asked for 429, got 429
asked for 503, got 503Here are the codes you will meet most, with what they usually mean in an AI or backend project:
| Code | Name | Typical meaning |
|---|---|---|
200 | OK | It worked; the answer is in the body. |
201 | Created | A POST created something (a review, a user). Often has a Location header pointing to it. |
204 | No Content | It worked and there is deliberately no body (typical for DELETE). |
301 | Moved Permanently | The resource lives at another URL now (in the Location header). curl -L follows it. |
304 | Not Modified | "Your cached copy is still valid", no body. Answers a conditional request (advanced tutorial). |
400 | Bad Request | The request is malformed or invalid. LLM APIs use it for unknown parameters or a prompt that is too long for the model. |
401 | Unauthorized | Not authenticated: missing, wrong or expired API key or token. (The name is historical; it means "who are you?") |
403 | Forbidden | We know who you are, and you are not allowed to do this. |
404 | Not Found | No such resource (or a typo in the path, or a wrong model name). |
409 | Conflict | Clashes with the current state: the username is taken, the record changed meanwhile. |
422 | Unprocessable Content | Well-formed JSON, but the values fail validation (rating 9 out of 5, missing field). FastAPI uses this a lot. |
429 | Too Many Requests | Rate limit hit. Wait (see the Retry-After header) and retry. OpenAI also answers 429 when your account is out of credit; the body says which. |
500 | Internal Server Error | A bug or crash on the server. Not your fault; report it if it keeps happening. |
502 | Bad Gateway | A proxy or load balancer in front got a bad answer from the real server behind it. |
503 | Service Unavailable | Overloaded or under maintenance; temporary. Retry later. (Anthropic uses a non-standard 529 for "overloaded".) |
Retry with a wait: 429, 500, 502, 503, 504 (gateway timeout). Do not retry unchanged: 400, 401, 403, 404, 409, 422; fix the request instead.
Status code and body work together
A good API returns a clear status code and a body that explains it. FastAPI, which the Practice API is built with (and which you will use yourself from Your First FastAPI App on), puts the explanation under detail:
curl -s -w " -> %{http_code}\n" http://localhost:8800/v1/courses/99
curl -s -w " -> %{http_code}\n" http://localhost:8800/v1/courses/abc{"detail":"Course 99 not found"} -> 404
{"detail":[{"type":"int_parsing","loc":["path","course_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]} -> 422The first is a 404: the path was valid but course 99 does not exist. The second is a 422: course ids are integers, and abc is not one. The body says exactly where (loc: the path parameter course_id) and why. Always read the body of an error response; it usually tells you the fix.
And a 204 really has nothing after the headers:
curl -si http://localhost:8800/v1/status/204 | grep -v '^date' | tr -d '\r'HTTP/1.1 204 No Content
server: uvicornHeaders that matter
Headers are metadata: information about the message. Their names are case-insensitive. These are the ones you will set and read most:
| Header | Sent by | Meaning | Example |
|---|---|---|---|
Content-Type | Whoever sends a body | The format of this body | application/json |
Accept | Client | Which formats the client wants back | application/json, text/event-stream |
Authorization | Client | Credentials, usually a bearer token | Bearer token-nadia-001 |
X-API-Key (and similar) | Client | An API key, for APIs that use their own header | practice-key-123; Anthropic uses x-api-key |
User-Agent | Client | Which program is asking (and its version) | curl/8.5.0, python-requests/2.34.2 |
Content-Length | Whoever sends a body | Body size in bytes | 75 |
Location | Server | Where the new or moved resource is | /v1/reviews/1 |
Retry-After | Server | How many seconds to wait before trying again (with 429/503) | 10 |
Headers starting with X- are custom ones an API invented. You add any header with -H "Name: value"; -A is a shortcut for User-Agent. The echo endpoint shows what arrived (pretty-printed by Python's json.tool):
curl -s http://localhost:8800/v1/echo \
-H "Authorization: Bearer test-token" \
-H "Accept: application/json" \
-A "nadia-notes/0.1" | python -m json.tool{
"method": "GET",
"query": {},
"headers": {
"user-agent": "nadia-notes/0.1",
"authorization": "Bearer test-token",
"accept": "application/json"
},
"json": null
}Two habits worth building now. First, give your scripts a meaningful User-Agent (my-app/1.2 (contact@example.com)); API owners use it to contact you instead of blocking you. Second, secrets go in headers, never in the URL: URLs are written to server logs, browser history and proxies; headers mostly are not.
JSON fundamentals
JSON (JavaScript Object Notation) is the text format of almost every API body. It has six kinds of value, and each maps to a Python type:
| JSON | Example | Python |
|---|---|---|
| object | {"id": 2, "title": "RAG"} | dict |
| array | ["python", "ai"] | list |
| string | "Python for AI" (double quotes only) | str |
| number | 12, 4.8, -1e3 | int or float |
| boolean | true, false (lower case) | True, False |
| null | null | None |
Objects and arrays nest to any depth: a chat API's response is an object containing an array of messages, each an object. In Python, json.dumps turns Python values into JSON text and json.loads turns JSON text back into Python values (you used both in Python for AI):
import json
course = {"id": 2, "title": "Python for AI", "free": False,
"tags": ["python", "ai"], "discount": None, "rating": 4.8}
text = json.dumps(course)
print(text)
print(type(text).__name__, len(text))
back = json.loads(text)
print(back == course, back["tags"][1])
print(json.dumps({"city": "ঢাকা"}))
print(json.dumps({"city": "ঢাকা"}, ensure_ascii=False)){"id": 2, "title": "Python for AI", "free": false, "tags": ["python", "ai"], "discount": null, "rating": 4.8}
str 109
True ai
{"city": "\u09a2\u09be\u0995\u09be"}
{"city": "ঢাকা"}What to notice: False became false and None became null. JSON is just a str until you parse it. By default json.dumps escapes non-English letters as \uXXXX; both forms are valid JSON and mean the same, but ensure_ascii=False keeps Bangla readable when you save files.
What makes JSON invalid
JSON is stricter than Python. These three look fine to a Python programmer and are all rejected:
bad_examples = [
"{'title': 'RAG'}",
'{"id": 2 "title": "RAG"}',
'{"free": True}',
]
for text in bad_examples:
try:
json.loads(text)
except json.JSONDecodeError as exc:
print(f"{text:26} -> {exc}"){'title': 'RAG'} -> Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{"id": 2 "title": "RAG"} -> Expecting ',' delimiter: line 1 column 10 (char 9)
{"free": True} -> Expecting value: line 1 column 10 (char 9)Single quotes, a missing comma, and Python's True instead of true. A trailing comma after the last item ({"title": "RAG",}) is rejected too. The fix is the same every time: never write JSON by hand inside Python strings; build a dict and let json.dumps (or requests' json=) produce the text. LLM output that "should be JSON" fails in exactly these ways too, which is why you always parse it inside try/except.
Where data goes: path, query or body
A request can carry data in three places. Each has a job:
| Place | Example | Use it for |
|---|---|---|
| Path parameter | /v1/courses/2 | Which resource: an id that identifies one thing. |
| Query parameter | /v1/courses?level=advanced&per_page=2 | How to show a collection: filters, sorting, paging, options. Short, non-secret values. |
| Body | {"course_id": 2, "rating": 5, "comment": "..."} | The data you send: new records, a chat conversation, anything large, nested or private. |
A filter is a query parameter:
curl -s "http://localhost:8800/v1/courses?level=advanced&per_page=2" | python -m json.tool{
"page": 1,
"per_page": 2,
"total": 3,
"next_page": 2,
"items": [
{
"id": 8,
"title": "RAG Systems",
"level": "advanced",
"hours": 10,
"rating": 4.9
},
{
"id": 9,
"title": "AI Agents",
"level": "advanced",
"hours": 12,
"rating": 4.8
}
]
}When a query value contains spaces or symbols, let curl encode it. -G tells curl to put the -d/--data-urlencode values into the query string of a GET instead of the body:
curl -s -G http://localhost:8800/v1/echo --data-urlencode "q=what is RAG?" -d lang=bn{"method":"GET","query":{"q":"what is RAG?","lang":"bn"},"headers":{"user-agent":"curl/8.5.0","accept":"*/*"},"json":null}The server received the question exactly as typed, with its space and ?, because curl sent it as q=what%20is%20RAG%3F.
Request bodies and content types
A body is just bytes; the Content-Type header tells the receiver how to read them. Sending JSON without saying so is a classic mistake. Plain -d sends the body labelled as a web form:
curl -s http://localhost:8800/v1/echo -d '{"name": "Nadia"}'
echo
curl -s http://localhost:8800/v1/echo -H "Content-Type: application/json" -d '{"name": "Nadia"}'{"method":"POST","query":{},"headers":{"user-agent":"curl/8.5.0","accept":"*/*","content-type":"application/x-www-form-urlencoded"},"json":{"name":"Nadia"}}
{"method":"POST","query":{},"headers":{"user-agent":"curl/8.5.0","accept":"*/*","content-type":"application/json"},"json":{"name":"Nadia"}}The echo endpoint is forgiving and parsed the JSON both times, but look at the first content-type: application/x-www-form-urlencoded. A real API (FastAPI included) would reject that request with a 422 because it was told the body is a form. Use --json, or -H "Content-Type: application/json" -d …. Also notice that -d switched the method to POST by itself.
| Content type | What the body is | Where you meet it |
|---|---|---|
application/json | JSON text | Almost every API, including all LLM APIs |
application/x-www-form-urlencoded | name=Nadia&city=Dhaka | Classic HTML forms, OAuth token endpoints |
multipart/form-data | Several parts, each with its own headers; can hold files | File uploads: a PDF for RAG, an audio file for speech-to-text (curl -F "file=@notes.pdf") |
text/plain | Plain text | Simple tools, logs |
text/event-stream | A stream of data: … events (Server-Sent Events) | Streaming LLM answers token by token (advanced tutorial) |
For text formats, the content type may include a character set, as in application/json; charset=utf-8. JSON is UTF-8 by definition, so Bangla text travels fine.
Where you will use this
- Every LLM API call is a
POSTwithContent-Type: application/json, anAuthorization(orx-api-key) header, and a JSON body containing the model name and the messages. The answer is JSON with the text and a token usage count. - Error handling is a switch on status families: 2xx use it; 4xx fix the request or the key (log the body); 429 and 5xx wait and retry. You will write exactly that in Python three pages from now.
- Designing your own API (from What a Backend Does: Requests, Routes and Responses on): choosing GET vs POST, path vs query, and 201/204/404/422 correctly is most of what makes an API pleasant to use.
- Uploads for AI: sending a document to be embedded or an audio clip to be transcribed is a
multipart/form-dataPOST.
Common mistakes
- Sending JSON without
Content-Type: application/json.curl -d '{…}'labels it as a form and the server answers 415 or 422. Usecurl --json, or in Pythonrequests.post(url, json=data), which sets the header for you. - Retrying every error. Retrying a 400, 401 or 422 just fails again (and may count against your rate limit). Retry 429 and 5xx, with waits; fix 4xx.
- Using GET for actions that change things. Crawlers, link previews and browsers send GETs freely and repeat them. Anything that creates, changes, deletes or costs money must be POST/PUT/PATCH/DELETE.
- Putting secrets or big data in the URL. Query strings end up in logs and have length limits. Keys go in headers; large or private data goes in the body.
- Writing JSON by hand. Single quotes, trailing commas and
True/Nonemake invalid JSON. Build Python objects and serialise them withjson.dumps.
Try it yourself
- Easy: With one curl command, print only the status code for
/v1/courses/5, then for/v1/courses/50. Which family is each? - Medium: Send a POST to
/v1/echowith the JSON body{"course_id": 2, "rating": 5}, a headerX-API-Key: practice-key-123, and the query parameterdry_run=true. Check in the echo that all three arrived. - Hard: Write a Python function
advice(code)that returns"success"for 2xx,"retry later"for 429 and 5xx,"fix the request"for other 4xx, and"follow or use cache"for 3xx. Print it for 200, 204, 304, 401, 422, 429 and 503.
Answers
# 1
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8800/v1/courses/5
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8800/v1/courses/50200
404200 is a 2xx success. 404 is a 4xx client error: you asked for something that does not exist.
# 2
curl -s "http://localhost:8800/v1/echo?dry_run=true" \
-H "X-API-Key: practice-key-123" \
--json '{"course_id": 2, "rating": 5}' | python -m json.tool{
"method": "POST",
"query": {
"dry_run": "true"
},
"headers": {
"user-agent": "curl/8.5.0",
"x-api-key": "practice-key-123",
"content-type": "application/json",
"accept": "application/json"
},
"json": {
"course_id": 2,
"rating": 5
}
}Notice "dry_run": "true" is a string: query values are always text, while the body kept 2 and 5 as numbers.
# 3
def advice(code):
if 200 <= code < 300:
return "success"
if code == 429 or 500 <= code < 600:
return "retry later"
if 400 <= code < 500:
return "fix the request"
if 300 <= code < 400:
return "follow or use cache"
return "unexpected"
for code in (200, 204, 304, 401, 422, 429, 503):
print(code, advice(code))200 success
204 success
304 follow or use cache
401 fix the request
422 fix the request
429 retry later
503 retry laterThe order of the checks matters: 429 must be tested before the general 4xx rule, or it would be classed as "fix the request".
Summary
- The method says what to do: GET reads, POST creates or acts, PUT replaces, PATCH changes part, DELETE removes. GET is safe; GET, PUT and DELETE are idempotent; POST is neither, so repeat it with care.
- Status families: 2xx success, 3xx look elsewhere, 4xx fix your request, 5xx the server failed. Retry 429 and 5xx after a wait; do not retry other 4xx unchanged. Always read the error body.
Content-Typedescribes the body you send;Acceptsays what you want back;Authorization/API-key headers carry credentials;User-Agentnames your program. Secrets belong in headers.- JSON has objects, arrays, strings, numbers, booleans and null, mapping to dict, list, str, int/float, True/False and None. Generate it with
json.dumps; never by hand. - Path parameters pick a resource, query parameters filter or page a collection, and the body carries the data. Use
curl --jsonfor JSON bodies and-G --data-urlencodefor tricky query values.
Next: Web APIs and REST: Reading Docs and Testing with curl. You set up the Practice API properly, read its documentation, and use API keys and bearer tokens.