Status codes and the Problem Details body
How HTTP status codes and the RFC 9457 Problem Details body declare an endpoint's outcome to every layer of the stack.
A status code is not a label your server attaches to the response on the way out. It declares which category the outcome falls into, and every layer downstream reads it to decide what to do next. The first digit, the class, is what the alerting rule, the CDN, and the load balancer key off. The specific code is what the client codes against, and the body fills in the detail. If you pick the wrong class, you page the wrong team in the middle of the night. If you pick the wrong code within a class, you confuse the retry policies throughout the stack.
This lesson covers five things. First, the five status classes (1xx to 5xx), which give you a handful of semantic buckets to reason about instead of a 60-code list to memorise. Second, the senior subset inside each class: the codes a SaaS engineer actually sends. Third, the three 4xx distinctions that catch every junior at least once: parse versus validation, identity versus permission, and tenancy hiding. Fourth, the 4xx/5xx split, which acts as the on-call paging contract. Fifth, RFC 9457 Problem Details, the JSON error-body shape that every modern SaaS API converges on, so you stop inventing a new dialect for each endpoint that the next one will ignore.
Status codes are outcome categories
Section titled “Status codes are outcome categories”The previous chapter named the shape of an HTTP response in one line: a status line, then headers, then a body. The status line looks like HTTP/3 200 OK: a protocol version, a three-digit number, and a human-readable phrase. The number is what the rest of the stack reads. The phrase (“OK”, “Not Found”, “Internal Server Error”) is for humans skimming logs at 3am, and nothing automated cares about it.
The number’s first digit is its class, the mental scaffold the rest of this lesson builds on. There are five classes, each with a one-line meaning:
- 1xx informational: “hold on, the request is in progress.”
- 2xx success: “done, here’s the result.”
- 3xx redirection: “go look at this other URL instead.”
- 4xx client fault: “your bad, the request itself was wrong.”
- 5xx server fault: “our bad, something blew up on this end.”
The class is what infrastructure layers act on, and the specific code is what application clients code against. Two consumers downstream make that division concrete.
The first is alerting and paging. Modern observability platforms key error budgets and on-call rules off the 4xx/5xx split. A 5xx spike pages someone; a 4xx spike does not. The reasoning is direct: 5xx says the server broke, which is the on-call engineer’s job to fix, while 4xx says the client sent something bad, which is a client bug or a product issue, not an emergency. If you return 500 Internal Server Error every time a user mistypes their email, your team gets paged for client-side noise.
The second is retry policies. HTTP clients, meaning the browser’s fetch, server-side SDKs, and load balancers, have built-in retry rules that read the class. Most retry the 502/503/504 trio and 429, but they don’t retry 4xx by default. Pick the wrong code and the client either retries forever on what should have been a hard fail, or never retries on what should have been a transient blip.
A class-by-class tour of the senior subset
Section titled “A class-by-class tour of the senior subset”Here is the catalog, organised by class rather than as a flat 30-row table, so you reason from the class down to the code. For each class, only the codes a SaaS engineer actually sends are listed. The historical codes stay out, so what you see below is the working set.
1xx: informational
Section titled “1xx: informational”You’ll rarely send a 1xx yourself, but three are worth knowing on sight.
100 Continue: the client asks “may I send the body?” before sending it, usually because the body is large and it’s cheaper to learn about a rejection without first uploading megabytes. Rare in modern stacks.101 Switching Protocols: the WebSocket upgrade handshake. The first time you open a WebSocket and look in DevTools, this is what you see on the upgrade response.103 Early Hints: the server tells the browser “while I work on the real response, here are resources to preload.” It’s the CDN preload signal. Cloudflare and Fastly already cache and replay these, and Next.js 16 has built-in support for emittingLinkheaders under 103.
2xx: success
Section titled “2xx: success”The four codes you’ll send constantly:
200 OK: a read with a body, or an update with a body. The default for almost every successful response.201 Created: resource creation. Pair it with aLocationheader pointing at the newly-created resource, so the client has the URL to GET that resource without parsing the body to fish out its ID. Headers come up properly in the next lesson; for now, know thatLocationand201travel together.202 Accepted: fire-and-forget enqueue. The work hasn’t happened yet; the request was accepted and queued for asynchronous processing. Use it for “trigger an export” or “send the email later,” anything that hands off to a background job.204 No Content: a successful mutation the client doesn’t need a response body for. Common for DELETE and for PATCHes whose result the client doesn’t display.
3xx: redirection
Section titled “3xx: redirection”The 3xx class has a quirk worth pinning down. A redirect can be permanent or temporary, and it can either preserve the request method or invite the client to change it. Those two axes give you the four codes below.
301 Moved Permanentlyand308 Permanent Redirect: both permanent. The difference is method-preservation.308preserves the request method, so a POST stays a POST after the redirect, while301historically allowed clients to rewrite POST to GET. The senior default for new APIs is308.302 Foundand307 Temporary Redirect: both temporary, with the same split.307preserves the method, while302does not, because browsers historically rewrite POST to GET on302. The senior default is307.303 See Other: the code that explicitly tells the client to use GET on the next request. This is the classic Post-Redirect-Get pattern: a form POST returns303with aLocationheader, the browser follows with a GET, and the result page is reachable on refresh without resubmitting the form.304 Not Modified: the response to a conditional request when the resource hasn’t changed. The body is omitted, and the client uses its cached copy. Conditional requests (ETag,If-None-Match) live in the next lesson.
The two redirect scenarios you’ll actually code for look like this on the wire.
HTTP/3 303 See OtherLocation: /invoices/42After a form submission. The client follows with GET /invoices/42, so the method is not preserved. Reach for this when a POST should land the user on a detail or confirmation page that’s safe to refresh.
HTTP/3 307 Temporary RedirectLocation: /v2/invoices/42The client follows with POST /v2/invoices/42, same method, same body. Use this when the URL moved but the operation didn’t change. Pair 307 (temporary) with 308 (permanent) for method-preserving redirects.
4xx: client fault
Section titled “4xx: client fault”The 4xx class is the longest in this catalog and the place where most production confusion lives: nine codes plus three distinctions.
The senior 4xx subset:
400 Bad Request: the request is malformed and the server couldn’t parse it. Wrong JSON, wrongContent-Type, or missing required headers. The request never made it past the parsing step.401 Unauthorized: unauthenticated. No credentials, expired credentials, or a bad token. The name is a misnomer: “Unauthorized” really means “Unauthenticated.” Pair it with the WWW-Authenticate header to tell the client which auth scheme you expect.403 Forbidden: authenticated but not allowed. The credentials are valid; the action is not. Wrong role, paywall, or missing permission.404 Not Found: resource not found. Either it doesn’t exist, or it exists but the current user isn’t allowed to know that. (More on this tenancy nuance in a minute.)405 Method Not Allowed: the path exists but the method doesn’t.GET /api/widgetsworks butDELETE /api/widgetsdoesn’t. Next.js route handlers send this automatically when a method isn’t exported.409 Conflict: a state conflict. A unique-constraint violation, an optimistic-concurrency mismatch (If-Matchfailed), or any case where the resource is not in the state your request assumes.410 Gone: the resource was here and is permanently gone. Use sparingly;404usually suffices.422 Unprocessable Content: parsed cleanly but failed validation. The body is well-formed JSON and the fields are the right types, but a business rule rejected it (endDate < startDate, an invalid email format, a slug already taken). This is the validation-error code.429 Too Many Requests: rate limited. Pair withRetry-Afterto tell the client when to come back. Lesson 3 covers the rate-limit header surface.
With the catalog in place, here are the three distinctions that decide which code to pick when the situation is ambiguous.
400 vs 422, parse versus validation. This is the mistake juniors ship most often in this chapter. 400 means the server couldn’t parse the request: malformed JSON, wrong content type, broken structure. 422 means the request parsed cleanly, but a business rule rejected the parsed value. The test is whether safeParse got to run. If parsing the JSON failed before Zod ever saw it, it’s 400. If safeParse ran and returned { success: false }, it’s 422. A missing field is 400 if the JSON itself was malformed, but 422 if the JSON parsed fine and the field was simply absent.
401 vs 403, identity versus permission. 401 says “I don’t know who you are.” 403 says “I know who you are, and you can’t do this.” The simplest test is whether re-signing-in would fix it: if yes, it’s 401; if no, it’s 403. An expired session token is 401. A signed-in Viewer trying to call DELETE /invoices/42 when only Admins can delete is 403.
403 vs 404, the tenancy-hiding rule. When a signed-in user asks for a resource that belongs to a different organization, the senior default is to return 404 rather than 403. The reason is that 403 confirms “this resource exists, you can’t see it,” which leaks the resource’s existence to someone who shouldn’t know it is there. 404 says “no such resource for you,” which gives away nothing either way. The multi-tenancy unit later in the course builds a tenantDb factory that enforces this at the query layer, so you can’t leak across orgs by accident.
Here is a short drill to make these three stick.
Sort each request into the status code the server should return. Drag each item into the bucket it belongs to, then press Check.
not even json {{.Content-Type is text/plain but the route expects JSON.endDate is before startDate.'not-an-email'.DELETE /invoices/42; only Admins can delete.5xx: server fault
Section titled “5xx: server fault”The 5xx subset is smaller: five codes, with one trio that travels together.
500 Internal Server Error: the catch-all bug. An unhandled exception bubbled up to the framework boundary. This is the one your error tracker (Sentry) groups by stack trace.502 Bad Gateway: an upstream service returned garbage. Often the proxy or load balancer reporting that the origin app crashed.503 Service Unavailable: the server is overloaded or down for maintenance. Pair withRetry-Afterif you can.504 Gateway Timeout: an upstream took too long to respond. The classic timeout-at-the-CDN response.507 Insufficient Storage: niche but specific. The write didn’t go through because the storage backend is full. Worth naming once for completeness, but not core.
The 502/503/504 trio is the load-balancer trio. When you see them, the bug is usually not in your application code but in the layer in front of it: the CDN, the proxy, or origin reachability. They’re useful diagnostic markers even when they don’t tell you exactly what to fix.
That brings the 4xx/5xx split together into a single contract: 4xx is the client’s fault and does not page on-call, while 5xx is the server’s fault and does. If your team’s dashboard pages on 4xx spikes, the rules are miscalibrated, because those spikes are client bugs or product issues rather than emergencies. If it stays silent on 5xx spikes, it is missing real outages. Pick the class correctly and the alerting tier downstream routes the signal to the right team on its own.
A POST handler hits an unhandled TypeError from a missing optional chain. The exception bubbles to the framework boundary. What status code should the client see?
400 Bad Request422 Unprocessable Content500 Internal Server Error503 Service Unavailable500 is the catch-all for unhandled server-side bugs. 503 is for capacity or availability — the server is up and refusing work. The class boundary matters first (this is a server fault, 5xx), and within that class it’s a code bug, not a load issue. Sentry groups 500s by stack trace; 503s are what your load balancer emits during a deploy.Problem Details: the body shape that ships in 2026
Section titled “Problem Details: the body shape that ships in 2026”The status code is the first signal and the body is the second. Every API that doesn’t pick a standard body shape ends up inventing its own: { error: "..." } here, { message: "...", code: 42 } there, { errors: [{ field, msg }] } somewhere else. Clients have to special-case each one, SDK generators can’t infer the shape, and switching vendors means rewriting every error-handling branch. That’s the cost of inventing a dialect, and it’s avoidable.
The 2026 SaaS default is to converge on RFC 9457 Problem Details . It’s a short IETF specification that defines two things: a JSON shape with five named fields, and a content type, application/problem+json, that signals the body follows the standard. RFC 9457 supersedes the earlier RFC 7807 (2016) and is backward-compatible with it, so nothing you read about RFC 7807 has gone stale. Use 9457 as the reference for any new surface.
A real Problem Details response on the wire looks like this, with the status line, the content type, and the JSON body each playing their part.
HTTP/3 422 Unprocessable ContentContent-Type: application/problem+json
{ "type": "https://api.example.com/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "The invoice could not be created. See errors for details.", "instance": "/api/invoices", "errors": [ { "path": "dueDate", "message": "Date must be after today." }, { "path": "lines.0.amount", "message": "Must be positive." } ]}There are five core fields plus one extension, and the annotations below walk through them one at a time.
{ "type": "https://api.example.com/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "The invoice could not be created. See errors for details.", "instance": "/api/invoices", "errors": [ { "path": "dueDate", "message": "Date must be after today." }, { "path": "lines.0.amount", "message": "Must be positive." } ]}type is a URI that identifies the kind of problem. This is the field the client switches on, not the human-readable title and not status. URIs are version-stable: you can change the title or detail text without breaking clients, but the type URI stays put. It’s the contract.
{ "type": "https://api.example.com/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "The invoice could not be created. See errors for details.", "instance": "/api/invoices", "errors": [ { "path": "dueDate", "message": "Date must be after today." }, { "path": "lines.0.amount", "message": "Must be positive." } ]}title is a short, human-readable summary of the problem type. It does not change between occurrences of the same type: 'Validation failed', not 'Validation failed for invoice 42'. The per-occurrence detail goes in the next field.
{ "type": "https://api.example.com/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "The invoice could not be created. See errors for details.", "instance": "/api/invoices", "errors": [ { "path": "dueDate", "message": "Date must be after today." }, { "path": "lines.0.amount", "message": "Must be positive." } ]}status mirrors the HTTP status code on the response line, and the two must match. The RFC marks the field as advisory, but mismatching it confuses middleware that re-emits the body and any tool that reads one without the other. Set them in one place server-side so they can’t drift.
{ "type": "https://api.example.com/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "The invoice could not be created. See errors for details.", "instance": "/api/invoices", "errors": [ { "path": "dueDate", "message": "Date must be after today." }, { "path": "lines.0.amount", "message": "Must be positive." } ]}detail is a human-readable, occurrence-specific explanation. It’s safe to include user-supplied identifiers here, since this is the string a generic error UI may surface verbatim. Don’t put stack traces or internal IDs in it.
{ "type": "https://api.example.com/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "The invoice could not be created. See errors for details.", "instance": "/api/invoices", "errors": [ { "path": "dueDate", "message": "Date must be after today." }, { "path": "lines.0.amount", "message": "Must be positive." } ]}instance is a URI identifying this specific occurrence, usually the request path. Useful for log correlation and for matching a support ticket to a server-side trace.
The five core fields aren’t the whole story. RFC 9457 explicitly allows, and encourages, additional fields beyond the core five. The convention is that any field outside the core set is a problem-type-specific extension. Once you pick an extension shape for a given type URI, you’re committed, because clients code against it. The canonical extension in this course is errors, the array of { path, message } you saw above. It carries the same shape as a Zod issue array, so the server-side transformation from a safeParse failure to a Problem Details body is a one-line map. You’ll meet the helper that does this in the route-handler chapter later in the course.
One detail matters: the content type on the response is application/problem+json, not the generic application/json. Middleware, observability tools, and language SDK generators key off this content type to know the body follows RFC 9457. Get the content type wrong and the body is still readable JSON, but tooling won’t know it’s structured this way.
One closing rule is small but easy to get wrong in production: the status code on the response line and the status field inside the Problem Details body must match. The RFC marks the body field as advisory, but mismatching them breaks middleware that re-emits the body (proxies, error pages) and confuses clients that read one without the other. Set both in one place, usually a helper that takes a status code as its argument and emits the matching pair, so they cannot drift.
Where this lands in code
Section titled “Where this lands in code”You won’t write a route handler in this lesson. That’s the App Router unit’s territory, and the helper that actually builds a Problem Details response is owned by the public route-handler chapter later in the course. But it’s worth seeing the call shape now, so that when you meet the helper, the contract is already familiar.
export async function POST(request: NextRequest) { const parsed = createInvoiceSchema.safeParse(await request.json()); if (!parsed.success) { return problemResponse({ type: 'https://api.example.com/problems/validation-failed', title: 'Validation failed', status: 422, detail: 'The invoice could not be created.', errors: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message, })), }); }
// …happy path: insert, return 201 with Location.}The pattern is one safeParse at the boundary and one problemResponse for the failure branch. The status code (422) and the status field inside the Problem Details body are passed once and used twice, because the helper takes a single argument and so can’t let them drift. The route-handler chapter builds the problemResponse helper. Later in the course, the organizations and RBAC unit’s authedRoute wrapper rolls both the parse and the response into a single decorator, so individual handlers stay focused on the happy path. The happy path’s 201 Created plus Location shape lands in the next lesson, when headers get their full treatment.
Closing recall
Section titled “Closing recall”These five true/false statements exercise the distinctions and the paging contract rather than catalog memorisation.
Each claim exercises a discrimination from this lesson — the 4xx confusion pairs, the body-line coherence rule, or the on-call paging contract. Mark each statement True or False.
A request body that fails Zod validation should return 422 Unprocessable Content, not 400 Bad Request.
400 means the server couldn’t parse the request — malformed JSON, wrong content type. 422 is for a body that parsed cleanly but failed business validation. The mental test: did safeParse get to run? If yes and it returned { success: false }, the code is 422.A signed-in user requesting a resource that belongs to another organization should receive 403 Forbidden so they know the resource exists but isn’t theirs.
403 leaks existence — it confirms the resource is real. The senior default for cross-tenant access is 404, indistinguishable from a missing resource. The multi-tenancy unit later in the course’s tenantDb factory enforces this at the query layer so you can’t leak by accident.When the server crashes from a bug in your route handler, the right status code is 503 Service Unavailable.
503 is for capacity or availability problems — the server is healthy enough to respond “I’m overloaded.” An unhandled exception is 500, the catch-all server bug. Sentry groups 500s by stack trace; 503s are what the load balancer emits during a deploy.The status field inside a Problem Details body and the HTTP status code on the response line should always match.
If your team’s alerting rules page on-call for 4xx spikes, the rules are miscalibrated.
Reveal card-by-card review
What stays out of this lesson
Section titled “What stays out of this lesson”A few items live nearby and get named once so you know where they end up:
- The
problemResponse(...)helper itself: the route-handler chapter later in the course. - The
authedRoutewrapper that maps 401, 403, 422, and 404 to Problem Details bodies: the organizations and RBAC unit. - Internationalising
titleanddetailstrings across locales: the time and i18n unit. - 5xx alerting rules and Sentry grouping: the observability unit.
- The full header surface (
Location,Retry-After,Cache-Control, conditional-request headers,WWW-Authenticate): the next lesson in this chapter. - CORS preflight, where
204reappears in a different role: the next chapter.
External resources
Section titled “External resources”The IETF specification itself — short, readable, and the authoritative reference for the five core fields and the application/problem+json media type.
The canonical per-code reference. Each status code has its own page with usage notes, browser-compat, and links to the defining RFC.
A clean, browsable index of every HTTP status code with one-line definitions — handy when you need to look one up fast.
Redocly's practical write-up on RFC 9457 — what changed from RFC 7807, why it matters for SDK generators, and how OpenAPI tooling consumes it.