What is an API, really?
Not the definition. The four things you actually send, what each one is for, and how to find the one that's wrong.
Every definition of “API” you’ve read is technically correct and completely useless. “Application Programming Interface” tells you nothing you can act on at 2am with a red build and a 403 you don’t understand.
Here’s the version that helps.
An API call is four decisions
When you call an HTTP API, you are filling in four blanks. Every single time.
GET https://api.example.com/products/42
Authorization: Bearer sk_live_7f3a...
| Part | What it answers |
|---|---|
GET | What do you want done to it? |
https://api.example.com | Whose computer? |
/products/42 | Which thing? |
Authorization | Who’s asking? |
That’s it. Query strings, bodies, content types, pagination cursors — all of it is detail hung off one of those four blanks. Learn to see a request as four answers and most “weird API behaviour” resolves into “blank three was wrong”.
The verb is a promise
The method isn’t a label. It’s a promise about what happens if the request runs more than once.
GETpromises it changes nothing. That promise is why a browser can prefetch it, why a CDN can cache it, and why retrying after a timeout is free.PUTandDELETEpromise the same end state every time. SendingPUT /users/42twice leaves you with the same user, not two.POSTpromises nothing. Which is exactly why it’s the one that hurts.
Why your payment provider wants an idempotency key
The network cannot tell these two failures apart:
- The request never arrived.
- The request arrived, ran, and the response was lost coming back.
From the client, both look like a timeout. If you retry case 1, you’re correct. If you retry case 2, you charged the customer twice.
Since POST makes no promise, something else has to. That something is a key you
generate and send:
POST /v1/charges
Idempotency-Key: 7f3a91c2-4e11-4b0a-9a6d-2c8f0b1e5d33
{ "amount": 2999, "currency": "usd" }
The server stores the result against that key. Second request, same key: it replays the stored response instead of charging again. The retry became safe because you made it safe — not because HTTP did.
If a call can cost money or send an email, decide before you write the retry how the second attempt will be recognised.
Which thing: path, query, or body?
Three places to put data, and people put it in the wrong one constantly.
| Put it in | When it is | Example |
|---|---|---|
| The path | Identity — which resource | /products/42 |
| The query string | A filter, sort or page over a set | /products?status=live&page=2 |
| The body | The new state you’re sending | {"price": 2999} |
The test: could you bookmark it? If the answer is naturally “yes, and it means the same thing tomorrow”, it belongs in the URL. A page of search results, yes. A password, absolutely not — URLs end up in browser history, proxy logs, and error trackers.
The one exception people trip on
GET with a body is legal and almost universally ignored — proxies, caches and some
HTTP clients will silently drop it. If your query is genuinely too big for a URL, use
POST /search and accept that you’ve given up caching. That’s a real trade, not a
workaround.
Who’s asking
The fourth blank is the one that fails most, and it fails in a way that looks like something else. Three headers cause most of it:
Authorization: Bearer eyJhbGciOi... ← who you are
Content-Type: application/json ← what you're sending
Accept: application/json ← what you want back
Miss Content-Type and a server that would happily accept your JSON returns 400 on
a body it never even parsed. Miss Accept and you get HTML — an error page you then
try to JSON.parse, producing a stack trace that mentions neither auth nor content
type.
The useful reflex: when a call fails in a confusing way, print the request you actually sent, not the one you meant to send.
curl -v https://api.example.com/products/42 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
-v prints the real headers. Nine times out of ten the bug is visible right there:
an empty $TOKEN, a stale key, a trailing newline pasted in from a terminal.
What comes back
A response is a status code and a body. The code is for machines; the body is for you.
{
"id": 42,
"name": "Wireless Mouse",
"price": 2999,
"currency": "usd",
"stock": 17
}
The mistake juniors make is reading the body and ignoring the code. 200 with an error
message inside is a badly designed API — annoying, but survivable. 500 with a
perfectly good-looking body is a trap: you’ll parse it, store it, and find out
three days later when the numbers don’t reconcile.
The codes worth knowing by heart
| Code | It means | Retry? |
|---|---|---|
400 | Your request is malformed | No — fix it |
401 | Not authenticated. No credential, or a bad one | No |
403 | Authenticated, not allowed | No |
404 | No such thing — or you can’t see it | No |
409 | Conflict; something changed under you | Re-read, then retry |
422 | Well-formed, but semantically wrong | No |
429 | Too many requests | Yes — after Retry-After |
500 | They broke | Yes, with backoff |
503 | They’re down or overloaded | Yes, with backoff |
The 401 vs 403 split is the one interviewers ask about, and it’s genuinely useful:
401 means try again with a credential, 403 means stop, a different credential
won’t help.
Notice which rows say “Yes”. A retry policy that retries 400 is a policy that hammers
a server with a request that can never succeed.
Errors are part of the contract
An API that returns a bare 500 and the string "error" has documented nothing.
A useful error is machine-readable and human-readable at once:
{
"error": {
"code": "insufficient_stock",
"message": "Only 3 units of SKU-9931 remain.",
"field": "items[0].quantity",
"request_id": "req_01HQ8Z3K"
}
}
codeis stable, so your client can branch on it. Never branch onmessage.messageis for a human reading a log or a toast.fieldlets a form highlight the right input.request_idis what you paste into a support ticket.
If you’re designing the API: pick your error shape on day one. Changing it later breaks every client that ever handled an error correctly.
Versioning is a promise you can’t take back
The moment someone else calls your API, its shape is a commitment. Two rules keep that survivable:
- Adding is safe. Removing and renaming are not. A new optional field breaks
nobody. Renaming
pricetoamountbreaks everyone, silently, asundefined. - Version when you break, not when you change.
/v2for a genuinely different contract; a new field doesn’t need one.
The corollary for clients: ignore fields you don’t know about. A client that throws on an unexpected key turns the provider’s safest kind of change into your outage.
Debugging a failing call, in order
Work the four blanks. It takes two minutes and beats guessing every time.
- Whose computer? Is the host right — staging vs production, the classic. Does DNS resolve? Is it the URL your config actually loaded, or the default?
- Which thing? Print the final URL after every bit of interpolation. Half of all
404s are/products/undefined. - Who’s asking? Print the credential’s length, never the credential. Empty string
and expired token look identical in code and different in
curl -v. - What did you ask for? Check
Content-TypeandAcceptagainst what the docs say.
Then read the status code before the body. Always.
The one thing to remember
An API is a contract about four blanks and what comes back. When a call fails, the fault is almost always in one blank you didn’t think about — and it’s usually the fourth.