HTTP status codes used in API responses
HTTP status codes used in API responses

HTTP Status Codes Explained: A Practical Guide for API Developers

If you build or consume APIs, HTTP status codes are the vocabulary you use to say what happened. Get them right and your API is a pleasure to integrate with; get them wrong and every client ends up writing fragile guesswork around your responses. Yet a surprising number of APIs still return 200 OK with an error buried in the body. Let us fix that habit. (One framework does this deliberately: GraphQL returns 200 with an errors array by design — a real point of contrast with REST.)

The five families

Every status code falls into one of five ranges, and knowing the range tells you almost everything:

  • 1xx Informational — rare; the request was received and processing continues.
  • 2xx Success — the request worked.
  • 3xx Redirection — more action is needed, usually following a new location.
  • 4xx Client errors — the caller did something wrong.
  • 5xx Server errors — you did something wrong.

That 4xx-versus-5xx split matters: it tells the client whether retrying could ever help. Blur it and you break every sensible retry policy on the internet.

The codes you will actually use

You do not need all sixty-odd codes. A great API leans on a small, honest set — here’s the curated cheat sheet of the ones that actually earn their keep:

Code Name Meaning When to use
200 OK Success Standard success for reads and updates
201 Created A resource was created After a POST creates something — include its Location header
204 No Content Success, nothing to return Perfect for DELETE
400 Bad Request Malformed or invalid Broken JSON or failed validation
401 Unauthorized Not authenticated Missing/expired credentials — e.g. an expired OAuth token
403 Forbidden Authenticated but not allowed Known user, denied this resource
404 Not Found Resource does not exist Unknown URL or id
409 Conflict Clash with current state Duplicate signup, edit conflict
422 Unprocessable Entity Well-formed but semantically invalid A favorite for validation errors
429 Too Many Requests Rate limited Slow down — pair with a Retry-After header
500 Internal Server Error Something broke on your side An unhandled exception
503 Service Unavailable Temporarily down or overloaded Maintenance or overload — pair with Retry-After

Which status code should I return for…?

The single most-searched status-code question is “which one do I use for this?” Here’s the whole decision compressed into a lookup:

  • Created a resource201 Created (+ a Location header pointing to it)
  • Deleted a resource204 No Content
  • Malformed request body400 Bad Request
  • Validation failed400 or 422 (pick one and be consistent)
  • Not logged in / bad token401 Unauthorized
  • Logged in but not allowed403 Forbidden
  • Rate limited429 Too Many Requests (+ Retry-After)
  • Async job accepted, still running202 Accepted (+ a status URL to poll)
  • Acknowledging a webhook you received → any 2xx (usually 200)
  • Your server crashed500 Internal Server Error

401 vs 403: the one everyone confuses

Short answer: use 401 when the request isn’t authenticated; use 403 when it is authenticated but not permitted.

Code Question it answers Use when
401 Unauthorized “Who are you?” No token, or an invalid/expired one — the client hasn’t proven who they are
403 Forbidden “I know who you are, and no.” Valid identity, but they lack permission for this resource

The trap is the naming: 401 literally says “Unauthorized” but actually means unauthenticated. Getting this pair right removes a whole category of confusing support tickets.

400 vs 422: which for a validation error?

Short answer: use 400 for a malformed request the server can’t parse; use 422 for a well-formed request whose data is invalid.

Purists reserve 400 Bad Request for broken JSON or a missing required parameter, and 422 Unprocessable Entity for syntactically fine data that fails business rules — an email without an @, a date in the past where a future one is required. That said, plenty of respected APIs just use 400 for both, and that’s fine — as long as you’re consistent and the error body says exactly which field failed. The status code narrows it down; the body pins it down.

404 vs 403: hiding resources safely

Short answer: use 403 when it’s fine for the user to know the resource exists; return 404 to hide its existence entirely from someone not allowed to see it.

If an unauthorized user requests /admin/reports/7, returning 403 confirms the report exists — a small information leak. Security-conscious APIs (GitHub famously does this) return 404 for anything you can’t see, trading strict accuracy for not leaking resource existence. For anything sensitive, the 404 approach is the safer default. And a related edge case — deleting something already deleted — is a genuine judgment call: 404 is technically true, but some APIs return 204 to keep DELETE idempotent-friendly. Either is defensible; document your choice.

Status codes drive retry logic — design accordingly

Here’s why lying about codes hurts more than aesthetics: automated clients decide what to do next based on the range. Standard retry logic treats 429, 502, 503, and 504 as “try again with backoff,” other 5xx as “maybe retry,” and almost all 4xx as “don’t bother — the request itself is wrong.” Return 500 for a validation error and well-behaved clients will retry a request that can never succeed, hammering you for nothing. Return 200 with an error in the body and their monitoring sees a healthy API while their integration silently fails.

The supporting headers matter too: pair 429 and 503 with Retry-After so clients know how long to wait, and include a stable machine-readable error code in the body so they can distinguish “rate limited” from “quota exhausted” without parsing prose. There’s even a standard shape for this — RFC 9457 “Problem Details” — worth adopting instead of inventing your own error format. (The same header-driven signalling extends to retiring endpoints: Deprecation and Sunset headers are part of good API versioning.)

Here’s what returning honest codes looks like in practice — an Express handler that uses the right code and the right supporting header for each outcome:

app.post('/articles', async (req, res) => {
  if (!req.body.title)                       // malformed / missing field
    return res.status(400).json({ error: 'title is required' });

  if (await overRateLimit(req.ip))           // too many requests
    return res.set('Retry-After', '60').status(429)
              .json({ error: 'rate limit exceeded' });

  const article = await createArticle(req.body);
  res.set('Location', `/articles/${article.id}`)   // where to find it
     .status(201).json(article);            // created
});

Codes worth knowing that you’ll meet in the wild

Beyond the core set, a handful appear regularly enough in debugging to memorize. 301 vs 302: permanent vs temporary redirect — browsers cache permanent ones aggressively, which is why a wrong 301 haunts you long after you fix it. 304 Not Modified: the caching workhorse — the server (or a CDN) saying “your cached copy is still good,” saving the transfer. 405 Method Not Allowed: right verb-shaped request, wrong verb — you POSTed to a GET-only route. 502 Bad Gateway and 504 Gateway Timeout: a proxy (nginx, a load balancer) reporting that the app behind it crashed or hung — the distinction from 500 tells you which layer to investigate first. And yes, 418 I'm a teapot is real, an April Fools’ RFC that outlived the joke — don’t ship it, but enjoy knowing it exists.

Frequently asked questions

What should a login failure return? 401 with a generic “invalid credentials” message — never reveal whether the username or the password was wrong, and don’t use 404 for “user not found” on login (that’s the same leak wearing a different hat). The response time should be constant too, since a fast “no such user” versus a slow “wrong password” leaks the same information through timing.

Can I invent custom status codes? Technically the space allows it; practically, don’t. Proxies, CDNs, and client libraries only understand the standard ones — a custom 452 will be mangled or mishandled somewhere between you and the caller. Express your specifics in the response body instead.

Which code for a request that’s valid but takes a long time? 202 Accepted — “received, processing hasn’t finished.” Return it with a URL where the client can poll for status. It’s the underused, correct answer for kicking off async jobs like report generation.

What status code should I return after creating a resource? 201 Created, together with a Location header holding the URL of the new resource (and usually the created object in the body). Plain 200 works but tells the client less; 201 explicitly signals that something now exists at a new address.

Which status code is used for rate limiting? 429 Too Many Requests. Always pair it with a Retry-After header telling the client how many seconds to wait, so well-behaved clients back off instead of hammering you harder.

Be consistent, and mean it

The worst thing an API can do with HTTP status codes is lie. Do not return 200 with {"error": "not found"}; return 404. Do not return 500 for a validation failure the user caused. When the status line tells the truth, clients can handle errors generically and your logs and dashboards suddenly become meaningful. Consistency here is worth more than cleverness anywhere else in your API — it’s one of the pillars of solid REST API design.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *