Good REST API error handling comes down to two decisions made in order. First, pick the HTTP status code that matches what went wrong, because clients, proxies, and retry logic read the status line before anything else. Second, return the details in one predictable body format, and the format with a standard behind it is problem details from RFC 9457, sent as application/problem+json. A client should be able to decide what to do from the status code alone, then use the body's type URI to tell one 422 from another and show the detail text to a person.
Choose the status code first
The status code is the part of an error that every piece of HTTP software understands. A load balancer counts 5xx responses, a browser surfaces a 401 challenge, and an HTTP client library retries some failures and not others. If the code is wrong, a perfect error body cannot fix it. The table covers the client and server errors a typical JSON API returns. For the full list of codes and their names, see the HTTP status codes reference and the status code glossary entry.
| Situation | Status | What the spec requires or suggests |
|---|---|---|
| Body is not valid JSON, or a required query parameter is missing | 400 Bad Request | Generic client error; the server will not process the request |
| No credentials, or the token is expired or invalid | 401 Unauthorized | RFC 9110: the server MUST send a WWW-Authenticate header with at least one challenge |
| Valid credentials, but this caller may not do this | 403 Forbidden | The client should not retry with the same credentials |
| The resource does not exist, or you do not want to reveal that it exists | 404 Not Found | RFC 9110 allows 404 instead of 403 to hide a forbidden resource |
| The path exists but not for this method | 405 Method Not Allowed | The server MUST send an Allow header listing the supported methods |
| Duplicate unique value, or the resource changed state underneath the request | 409 Conflict | The body SHOULD explain enough for the user to resolve the conflict |
| If-Match or another precondition failed | 412 Precondition Failed | Used with ETags to stop lost updates |
| Content-Type the endpoint does not accept | 415 Unsupported Media Type | Send this before trying to parse the body |
| Well-formed JSON that breaks a business or validation rule | 422 Unprocessable Content | Syntax and media type are fine; the instructions cannot be processed |
| Rate limit exceeded | 429 Too Many Requests | RFC 6585: MAY include Retry-After; the response MUST NOT be cached |
| Unhandled exception in your code | 500 Internal Server Error | Log the cause; return a generic message |
| Overload or planned maintenance | 503 Service Unavailable | MAY include Retry-After |
400 or 422 for validation failures
RFC 9110 defines 422 as the case where the server understands the content type and the syntax is correct, but it cannot process the instructions inside. That gives a clean split. Send 400 when the server cannot read the request at all: broken JSON, a string where the parser needs an object, a missing required query parameter. Send 422 when the JSON parses and matches the schema's shape, but a value breaks a rule, such as a quantity of zero, an end date before the start date, or a coupon that expired yesterday. Some teams send 400 for everything and that works too. Pick one rule, write it down, and apply it on every endpoint, because clients write code against the pattern.
401 or 403 for access failures
401 means the server does not know who is calling: the Authorization header is missing, the bearer token expired, or the signature is wrong. A 401 must carry a WWW-Authenticate header, for example WWW-Authenticate: Bearer error="invalid_token", and the client's usual reaction is to refresh the token and try once more. 403 means the server knows who is calling and refuses anyway. Retrying with the same token will fail again, so the client should show a message or ask for a different account. When the existence of the resource is itself private, such as another tenant's invoice, return 404 so a caller cannot probe IDs to learn which ones exist.
Return problem details in the body
RFC 9457 defines a small JSON object for HTTP errors and the media type application/problem+json. It replaced RFC 7807 in July 2023. The member names are the same, so a body written for RFC 7807 is still valid. RFC 9457 added a registry for common problem types, guidance on reporting multiple problems, and guidance for type URIs that cannot be dereferenced. Every member is optional, which means a client must handle a body that has only some of them.
| Member | Type | How to use it |
|---|---|---|
| type | URI reference | The problem type's primary identifier and the field clients switch on. Defaults to about:blank when absent. |
| title | string | Short summary of the problem type. It SHOULD NOT change between occurrences, except for localization. |
| status | integer | Copy of the HTTP status code, for convenience only. It must match the real status line. |
| detail | string | Explanation of this occurrence, written for a person. Clients SHOULD NOT parse it. |
| instance | URI reference | Identifies this occurrence, for example a path that support staff can look up in logs. |
Anything else in the object is an extension member. RFC 9457 says clients MUST ignore extensions they do not recognize, which is what lets you add fields such as balance or errors later without breaking older clients. Put machine-readable data in extensions, never only in detail. If a client has to run a regex over detail to find which field failed, the error format has already failed.
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/order-already-paid",
"title": "Order is already paid",
"status": 409,
"detail": "Order ord_7731 was paid on 2026-10-01 and cannot be edited.",
"instance": "/v1/orders/ord_7731",
"orderId": "ord_7731"
}Design type URIs clients can switch on
The type URI is the contract. Client code reads it the way it would read an error code enum: if type ends in order-already-paid, show the receipt; if it ends in insufficient-stock, offer a smaller quantity. That only works when type values are stable. Treat a type URI like a public field name: never rename one, and add new ones instead of changing what an old one means. Pair each type with one fixed title, and change titles only to localize them.
- Use about:blank, or leave type out, when the problem adds nothing beyond the status code. A plain 404 for an unknown ID does not need a custom type. RFC 9457 says the title should then match the status phrase, such as Not Found.
- Use an https URI you control for every problem type a client might handle differently. If the URI resolves, it SHOULD point to documentation for that problem type.
- Clients SHOULD NOT fetch the type URI at runtime. It is an identifier, and the documentation behind it is for developers.
- Keep one problem type per decision a client has to make. Separate types for card-declined and card-expired are useful if the UI says different things; ten types that all lead to the same message are noise.
Report many validation errors in one response
A form submission with three bad fields should get one response listing all three, not three round trips. RFC 9457 shows how: define a validation problem type with an errors extension, where each entry has a detail string and a pointer to the field. The pointer is a JSON Pointer into the request body, which lets a frontend put the message next to the right input without guessing from field names.
{
"type": "https://api.example.com/problems/validation-error",
"title": "Your request is not valid",
"status": 422,
"detail": "2 fields failed validation.",
"errors": [
{
"pointer": "#/quantity",
"detail": "must be 1 or more"
},
{
"pointer": "#/shippingAddress/postalCode",
"detail": "is not a valid postal code for country DE"
}
]
}The errors array is for many occurrences of the same problem type. When one request hits problems of different types, say a validation failure and an exhausted quota, RFC 9457 recommends returning the most relevant or urgent one. A generic batch type that wraps unrelated problems does not map well to a single status code, and the client would have to inspect every entry to know whether it can retry.
Document every error response in OpenAPI
Error responses belong in the spec as much as success responses do. Clients generated from a spec with no error schemas end up typing every failure as unknown. The pattern below defines one Problem schema, one ValidationProblem that extends it with allOf, and reusable responses under components so each operation lists its error codes in one line each. For a smaller standalone version, see the OpenAPI error response example.
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/v1/orders:
post:
summary: Create an order
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewOrder"
responses:
"201":
description: Order created
"400":
$ref: "#/components/responses/Problem"
"401":
$ref: "#/components/responses/Problem"
"409":
$ref: "#/components/responses/Problem"
"422":
$ref: "#/components/responses/ValidationProblem"
default:
$ref: "#/components/responses/Problem"
/v1/orders/{id}:
get:
summary: Get one order
operationId: getOrder
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The order
"404":
$ref: "#/components/responses/Problem"
default:
$ref: "#/components/responses/Problem"
components:
schemas:
NewOrder:
type: object
required: [sku, quantity]
properties:
sku:
type: string
quantity:
type: integer
minimum: 1
Problem:
type: object
properties:
type:
type: string
format: uri-reference
default: about:blank
title:
type: string
status:
type: integer
minimum: 400
maximum: 599
detail:
type: string
instance:
type: string
format: uri-reference
ValidationProblem:
allOf:
- $ref: "#/components/schemas/Problem"
- type: object
required: [errors]
properties:
errors:
type: array
items:
type: object
required: [pointer, detail]
properties:
pointer:
type: string
detail:
type: string
responses:
Problem:
description: Problem details (RFC 9457)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
ValidationProblem:
description: One or more request fields failed validation
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ValidationProblem"We scored two versions of this spec with APIForge's design score. The first version declared only a 200 response on each operation, with descriptions saying the body might contain an error object with success set to false. It scored 90 out of 100: full marks in six categories and 0 out of 10 for error handling, with one issue per operation saying no 4xx or 5xx response was documented. The version above, with the error responses added, scored 100. The two paths are identical, so the whole 10-point gap comes from documenting errors.
Failure modes to test for
- 200 OK with an error body. Monitoring counts it as success, HTTP caches may store it, and clients have to parse every body to find out whether the call worked.
- HTML error pages from the edge. A gateway timeout or a framework's default 404 page often returns text/html, and a client calling response.json() crashes. Configure the gateway and the framework's fallback handlers to emit problem+json too.
- Status line and status member disagree. RFC 9457 calls the status member advisory, and a proxy can rewrite the status line, so clients should trust the real status code.
- Stack traces, SQL fragments, or internal hostnames in detail. RFC 9457 asks generators to vet what goes into a problem. Log the internals with a request ID and return the ID in instance or an extension.
- Localized titles used as keys. If a client branches on title and you translate it, the client breaks. Branch on type.
- A 429 or 503 with no Retry-After. Clients then retry on their own schedule, often immediately. The rate limiting guide covers the headers to send.
Handle problem details on the client
On the client, branch in three steps. Check the status code first: retry 503 and 429 after the Retry-After delay, refresh credentials on 401, and stop on other 4xx codes. Then check whether Content-Type is application/problem+json; if it is not, treat the body as opaque text. Finally, switch on type for the cases your UI handles specially, show detail to the user for the rest, and ignore extension members you do not know. A client written this way keeps working when the server adds new problem types.
Check your error responses with APIForge
Paste your spec into the API quality score to list every operation that has no error response documented. To see what the server really returns, send a failing request from the API testing workbench and validate the body against the declared error schema; the guide to validating responses against an OpenAPI schema explains how that check picks the schema for each status code.