In the previous lesson we kept dropping numbers along the way: 201 on creation with Location, 204 with no body, 404 versus 410, 409 when the transition is not possible, 415 when the Content-Type is not supported. Each of those codes is a contract decision: it is the first thing a client reads and what determines whether it retries, shows the user an error, follows a redirect or ends the session. Choosing the wrong code makes an otherwise well-designed API indecipherable. This lesson goes through the codes a REST API actually uses —not the complete list in the IANA registry—, builds a decision tree so you always get it right, and designs Aroma Store's error body, comparing it with the standard application/problem+json.
Contents
- The five families and why they matter
- The 2xx family: success
- The 3xx family: redirection
- The 4xx family: client error
- The 5xx family: server error
- Aroma Store's master table
- Decision tree: how to choose the right code
- The error body:
problem+jsonand Aroma Store's format - Catalogue of business error codes
- Antipatterns
- The five families and why they matter
In 01-03 we saw the map; now we go into detail. The first digit of the code classifies the response:
| Family | Meaning | Whose problem is it? | Should the client retry? |
|---|---|---|---|
1xx |
Informational | Nobody's, it is protocol | — |
2xx |
Success | — | No |
3xx |
Redirection | The client's, it must follow another path | Yes, to another URI |
4xx |
Client error | The client's | No, not without changing the request |
5xx |
Server error | The server's | Yes, with spaced-out retries |
This classification is not decorative: it is logic that real software depends on. A generic HTTP client, a load balancer or a partner's gateway decide based on the first digit alone. If you return 200 for an error, no automatic retry will fire and no alert will go off. If you return 500 because the client mistyped a value, the system will retry a request that will never work, and your on-call team will get a page at three in the morning for a failure that is not theirs.
Of the 1xx family only 100 Continue deserves a mention, and HTTP clients handle it transparently when uploading large bodies. You will not use it explicitly.
- The 2xx family: success
200 OK
Generic success, with a body. It is used by correct GET, PUT and PATCH requests and by action POSTs that do not create a new resource.
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": "cof_001", "name": "Ethiopia Yirgacheffe", "priceEuros": 14.50 }Remember from 02-03: a collection GET with no results is 200 with {"data": [], "total": 0}, never 404.
201 Created
A resource has been created. The Location header is mandatory, carrying its URI.
HTTP/1.1 201 Created
Content-Type: application/json
Location: https://api.aromastore.example/v1/orders/ord_5001
{
"id": "ord_5001",
"customerId": "cus_842",
"status": "pending_payment",
"totalEuros": 29.00,
"createdAt": "2026-03-14T10:30:00Z",
"_links": {
"self": { "href": "/v1/orders/ord_5001" },
"pay": { "href": "/v1/orders/ord_5001/payment", "method": "POST" }
}
}Location is what lets the client chain operations without building URLs by hand, and it is the minimum of hypermedia every API should provide. In Aroma Store it is returned by POST /orders, POST /coffees, POST /coffees/{id}/reviews, POST /orders/{id}/payment and every action sub-resource.
202 Accepted
"I have accepted the request, but I have not processed it yet." It is the code of asynchronous processing, and its contract includes telling the client where to check the progress.
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: https://api.aromastore.example/v1/orders/ord_5001/return
{
"status": "under_review",
"message": "Your return request will be reviewed within 24 hours.",
"_links": { "status": { "href": "/v1/orders/ord_5001/return" } }
}Beware the trap of 202: when you return 202 you are saying that it may fail later, so you need a resource where the client can see the final outcome. A 202 with nowhere to look is a black hole.
204 No Content
Success with no body. The client must not try to parse anything. Uses in Aroma Store: a successful DELETE, PUT/PATCH when the client does not need the representation, and OPTIONS.
Strict rule: 204 means a body of zero length. Returning 204 with JSON inside breaks clients that, quite correctly, do not even read the stream.
206 Partial Content
A partial response to a request with Range. In Aroma Store it appears in exactly one place: the resumable download of the PDF invoice.
HTTP/1.1 206 Partial Content
Content-Type: application/pdf
Content-Range: bytes 24000-48212/48213
Content-Length: 24213It is not used to paginate JSON: the mechanisms in 02-06 are there for that.
- The 3xx family: redirection
301 Moved Permanently
The resource has changed URI for good. Aroma Store uses it to normalise the trailing slash (/coffees/ → /coffees) and for URIs inherited from the old API.
304 Not Modified
"What you have in your cache is still valid." It answers conditional requests carrying If-None-Match or If-Modified-Since, and it goes without a body, which is precisely the saving.
Conditional caching in full —ETags, Last-Modified, validation, revalidation— is developed in 04-06. Here it is enough to know that 304 is not an error but a very cheap success.
307 and 308 versus 302
The historical problem: faced with a 301 or a 302, many clients turned a POST into a GET when following the redirect, something the standard never intended but which became established practice. To remove the ambiguity, two codes were created that guarantee the method and the body are preserved:
| Code | Permanence | Preserves method and body? | Recommended use |
|---|---|---|---|
301 |
Permanent | In practice, no (POST → GET) | Moved resources, with GET only |
302 Found |
Temporary | Ambiguous | Avoid in APIs |
307 Temporary Redirect |
Temporary | Yes | Maintenance, redirection to another region |
308 Permanent Redirect |
Permanent | Yes | A definitive URI change that preserves the method |
Aroma Store's decision: 302 is not used under any circumstances. For permanent moves, 301 if only reads are affected and 308 if writes may be affected; for temporary ones, 307.
- The 4xx family: client error
It is the richest family and the one people get wrong most often. In all of them the body carries the error format from section 8.
400 Bad Request
The request is malformed or the data is not valid: JSON with a syntax error, a wrong type, a missing mandatory field, an unknown field (remember: Aroma Store is strict on input), an invalid query parameter.
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "invalid_data",
"message": "The request body contains validation errors.",
"details": [
{ "field": "priceEuros", "problem": "It must be a number greater than 0.", "receivedValue": -3 },
{ "field": "roast", "problem": "Value not allowed. Valid values: light, medium, dark.", "receivedValue": "super-roasted" }
]
}
}An important design note: all validation errors are returned at once, not just the first one. A form that fails field by field, across successive requests, is torture for the user.
401 Unauthorized versus 403 Forbidden
The most misused distinction in the whole HTTP world. The way to remember it:
401= "I do not know who you are". TheAuthorizationheader is missing, or the token is invalid or has expired. The response must includeWWW-Authenticate. The client can fix it by authenticating.403= "I know who you are and you cannot do this". The identity is valid, but it lacks permissions. Authenticating again is no use at all.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.aromastore.example", error="invalid_token"
Content-Type: application/json
{ "error": { "code": "token_expired", "message": "The access token has expired.", "details": [] } }HTTP/1.1 403 Forbidden
Content-Type: application/json
{ "error": { "code": "insufficient_permissions", "message": "The 'moderator' role is required in order to approve reviews.", "details": [] } }The name 401 Unauthorized is a historical mistake in the standard: it should have been called Unauthenticated. The practical consequence in the Aroma Store SPA: on a 401 it tries to refresh the token and retries once; on a 403 it shows "you do not have permission" straight away and does not retry.
There is a third case, awkward but important: when an authenticated client requests somebody else's resource (GET /v1/orders/ord_9999, which belongs to another person), responding 403 confirms that the order exists. To avoid that leak, Aroma Store responds 404 for other customers' private resources. It is a deliberate security decision, it is called masking and it is covered in 04-02.
404 Not Found versus 410 Gone
404 Not Found |
410 Gone |
|
|---|---|---|
| Meaning | There is nothing here (maybe there never was, maybe you cannot see it) | It existed and has been permanently removed |
| Can it come back? | It might | No |
| What a crawler does | Retries later | Removes the URL from its index |
| Use in Aroma Store | Non-existent id, somebody else's resource | Discontinued coffee, retired API version |
410 is more informative when you know for certain: it tells the client to stop asking for it. It is especially useful when retiring old versions (02-07).
405 Method Not Allowed
The resource exists, but it does not support that method. The Allow header is mandatory.
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
{ "error": { "code": "method_not_allowed", "message": "DELETE is not allowed on /v1/coffees.", "details": [] } }Distinguish it from 404: if DELETE /v1/coffees returned 404, the developer would go hunting for a typo in the URL instead of realising that the method is the wrong one.
406 Not Acceptable and 415 Unsupported Media Type
They get confused because both are about formats, but they point in opposite directions:
406→ the server cannot produce what the client asks for inAccept(output).415→ the server does not understand what the client sends inContent-Type(input).
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{ "error": { "code": "format_not_available", "message": "Only application/json is supported.", "details": [] } }HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Content-Type: application/json
{ "error": { "code": "unsupported_format", "message": "This resource only supports application/merge-patch+json.", "details": [] } }409 Conflict
The request is valid but clashes with the resource's current state. It is the business logic code, and in Aroma Store it comes up often:
POST /v1/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 5f3b9c2a-1d7e-4a44-9f30-8b1c2d3e4f50
{ "customerId": "cus_842", "items": [{ "coffeeId": "cof_002", "quantity": 100 }] }HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": {
"code": "insufficient_stock",
"message": "There are not enough units of 'Colombia Huila'.",
"details": [
{ "coffeeId": "cof_002", "requested": 100, "available": 80 }
]
}
}Other conflicts in the catalogue: order_already_paid, order_already_cancelled, review_already_moderated, operation_in_progress. The rule for telling it apart from 400: if the data is correct and what prevents the operation is the state of the resource, it is 409.
412 Precondition Failed
A precondition sent by the client failed, typically If-Match with an old ETag. It is the optimistic concurrency mechanism that prevents the lost update:
PATCH /v1/coffees/cof_001 HTTP/1.1
If-Match: "a1b2c3d4"
Content-Type: application/merge-patch+json
{ "stock": 95 }HTTP/1.1 412 Precondition Failed
Content-Type: application/json
{ "error": { "code": "version_conflict", "message": "The resource has changed since you last read it.", "details": [] } }ETags and conditional requests are developed in 04-06.
422 Unprocessable Content and the 400 vs 422 debate
422 (renamed Unprocessable Content in RFC 9110) means: the syntax is correct, I understand the document, but its contents cannot be processed. The canonical example: a perfectly formed JSON asking for a delivery date in the past.
The debate has been open for years and these are the two positions:
| Position | Rule | For | Against |
|---|---|---|---|
400 only |
Every client error is 400; the detail goes in the body |
Simple, nothing to decide; 422 comes from WebDAV |
You lose a distinction that is useful for automated clients |
400 + 422 |
400 for syntax/format errors; 422 for semantic errors |
Distinguishes "I do not understand you" from "I understand you and I cannot" | Fuzzy boundaries: is an invalid enumeration syntax or semantics? |
Aroma Store's decision: 400 for all input validation errors (syntax, types, mandatory fields, enumerations, ranges), with the field-by-field detail in details. 422 is reserved for one very specific, well-delimited case: the improper reuse of an Idempotency-Key with a different body (02-03), where the request is impeccable but cannot be processed for a reason that is neither about format nor about the resource's state.
What matters is not which of the two positions you choose, but writing it into the style guide and not deviating from it: what breaks clients is the same kind of failure returning 400 on one endpoint and 422 on another.
429 Too Many Requests
The request limit has been exceeded. It must be accompanied by Retry-After and, in Aroma Store, by the quota headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Aroma-RateLimit-Limit: 1000
Aroma-RateLimit-Remaining: 0
Content-Type: application/json
{ "error": { "code": "rate_limit_exceeded", "message": "You have exceeded the limit of 1000 requests per hour.", "details": [] } }The policies, windows and throttling algorithms are lesson 04-04.
- The 5xx family: server error
Here the client has done nothing wrong. Golden rule: never leak internal details —stack traces, SQL queries, file paths, server names— because they are gold dust for an attacker.
500 Internal Server Error
The catch-all: an unhandled exception. It must carry a trace identifier so that the client can quote it when raising an incident:
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"error": {
"code": "internal_error",
"message": "An unexpected error has occurred. Contact support quoting the trace identifier.",
"details": [],
"traceId": "trz_8f4a1c92"
}
}That traceId is the seam between the response and your logs, and it is the foundation of observability (04-07).
502, 503 and 504
| Code | Meaning | Typical cause in Aroma Store | Retry? |
|---|---|---|---|
502 Bad Gateway |
An invalid response from an upstream service | The payment gateway returns rubbish | Yes, after a wait |
503 Service Unavailable |
Service temporarily unavailable | Maintenance, saturation, start-up | Yes, according to Retry-After |
504 Gateway Timeout |
An upstream service did not respond in time | The gRPC stock service exceeds its deadline | Yes, carefully |
503 is the only one that can be planned, and that is why it carries Retry-After, which accepts either seconds or an HTTP date:
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: application/json
{ "error": { "code": "service_unavailable", "message": "Scheduled maintenance. Try again in 2 minutes.", "details": [] } }Careful with 504: the wait timed out, but the operation may have executed anyway upstream. It is exactly the scenario that justifies the idempotency keys from 02-03.
- Aroma Store's master table
| Code | When it is used | Concrete example |
|---|---|---|
200 |
A successful read or update | GET /v1/coffees/cof_001 |
201 |
Resource created (+ Location) |
POST /v1/orders |
202 |
Accepted for later processing | POST /v1/orders/ord_5001/return |
204 |
Success with no body | DELETE /v1/carts/crt_77/items/cof_002 |
206 |
Partial download with Range |
The PDF from /v1/orders/ord_5001/invoice |
301 |
URI moved permanently | /v1/coffees/ → /v1/coffees |
304 |
The client's cache is still valid | GET /v1/coffees/cof_001 with If-None-Match |
307 |
Temporary redirect preserving the method | Diversion during maintenance |
308 |
Permanent redirect preserving the method | Relocation of a write endpoint |
400 |
Invalid request or data | priceEuros: -3 |
401 |
Authentication missing or token expired | No Authorization header |
403 |
Authenticated but without permissions | A customer trying to approve a review |
404 |
It does not exist (or you cannot see it) | GET /v1/coffees/cof_999 |
405 |
Method not allowed (+ Allow) |
DELETE /v1/coffees |
406 |
The requested Accept cannot be served |
Accept: application/xml |
409 |
Conflict with the current state | insufficient_stock, order_already_paid |
410 |
It existed and was removed for good | Discontinued coffee; /v0 API retired |
412 |
If-Match failed (old ETag) |
Two simultaneous stock edits |
413 |
Body too large | A 20 MB coffee image |
415 |
Content-Type not supported |
PATCH with application/json-patch+json |
422 |
Correct but not processable | An Idempotency-Key reused with a different body |
429 |
Request limit exceeded | 1001 requests in one hour |
500 |
Unexpected server error | An unhandled exception |
502 |
An upstream service responds badly | The payment gateway is down |
503 |
Temporarily unavailable (+ Retry-After) |
Scheduled maintenance |
504 |
Upstream timeout | A slow stock service |
- Decision tree: how to choose the right code
graph TD
A{"Was it processed<br/>correctly?"} -->|No| B{"Whose fault<br/>is it?"}
A -->|"Yes, but it is not<br/>finished yet"| ACC["202 Accepted"]
A -->|Yes| C{"Has a resource<br/>been created?"}
C -->|Yes| CRE["201 Created<br/>+ Location"]
C -->|No| D{"Is there a body<br/>to return?"}
D -->|Yes| OK["200 OK"]
D -->|No| NC["204 No Content"]
B -->|"The server's"| E{"Is it temporary?"}
E -->|Yes| SRV["503 + Retry-After<br/>502 / 504 if upstream"]
E -->|No| ERR["500 + traceId"]
B -->|"The client's"| F{"Do we know<br/>who they are?"}
F -->|"Not authenticated"| U401["401 + WWW-Authenticate"]
F -->|"No permissions"| U403["403 Forbidden"]
F -->|Yes| G{"Does the resource<br/>exist?"}
G -->|"No, and it will not return"| G410["410 Gone"]
G -->|No| G404["404 Not Found"]
G -->|Yes| H{"Is the method<br/>allowed?"}
H -->|No| H405["405 + Allow"]
H -->|Yes| I{"Is the data<br/>valid?"}
I -->|No| I400["400 invalid_data"]
I -->|Yes| J{"Does the resource's state<br/>allow the operation?"}
J -->|No| J409["409 Conflict"]
J -->|Yes| OK
Walk this tree for every new endpoint and you will have half the documentation written.
- The error body:
problem+json and Aroma Store's format
problem+json and Aroma Store's formatThe status code says which category of failure occurred; the body says exactly what happened. Without a body, a 400 forces the developer to guess.
8.1. The standard: application/problem+json (RFC 9457)
There is a standard error body format, originally defined in RFC 7807 and updated by RFC 9457:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.aromastore.example/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Only 80 units of 'Colombia Huila' are left and 100 were requested.",
"instance": "/v1/orders",
"coffeeId": "cof_002",
"requested": 100,
"available": 80
}Fields defined by the standard:
| Field | What it is |
|---|---|
type |
A URI identifying the type of problem; ideally it points at documentation |
title |
A readable summary, stable for a given type |
status |
The HTTP code, repeated in the body |
detail |
An explanation of this particular occurrence |
instance |
The URI of the occurrence |
| extensions | Your own fields at the same level (coffeeId, available…) |
Advantages: it is standard, there are libraries that generate and consume it, and type as a URI guarantees global uniqueness. Practical drawbacks: the names are cryptic for anyone who does not know the RFC, type as a URL invites you to invent URLs that nobody maintains, extensions at the same level as the standard fields can collide, and the different Content-Type forces clients to handle two kinds of response.
8.2. Aroma Store's format
{
"error": {
"code": "insufficient_stock",
"message": "There are not enough units of 'Colombia Huila'.",
"details": [
{ "coffeeId": "cof_002", "requested": 100, "available": 80 }
]
}
}| Aspect | problem+json (RFC 9457) |
Aroma Store's format |
|---|---|---|
Content-Type |
application/problem+json |
application/json |
| Type identifier | type (a URI) |
code (snake_case) |
| Text for humans | title + detail |
message |
| Multiple errors | Not catered for out of the box | details as an array |
| Standardisation | High | In-house |
| Readability for the consumer | Medium | High |
| Envelope | Fields at the root | Everything under error |
Aroma Store's decision: an in-house format, for three reasons: (1) the error envelope makes it impossible to mistake a successful response for a failed one, even ignoring the status code; (2) details as an array naturally solves form validation, which is the most frequent case; (3) keeping application/json throughout the API simplifies clients. The decision is documented explicitly alongside the standard alternative, and in 02-08 it will be captured as a reusable schema in OpenAPI.
8.3. Rules of the error contract
codeis contract. Insnake_case, stable, unique, and never translated. It is what software compares.messageis for humans. Its wording can change and it can be translated (Accept-Language, 02-05). Never compare it in code.detailsis an array, always present even when empty, so that clients do not have to check whether it exists.- No sensitive data: no queries, no stack traces, and no telling whether an email address exists in the database.
5xxresponses carrytraceId;4xxresponses do not need it.
Implementing all of this as Express middleware is lesson 03-07; here we have only fixed the contract.
- Catalogue of business error codes
The catalogue is as much a part of the contract as the URIs. This is Aroma Store's initial one:
code |
HTTP | When |
|---|---|---|
invalid_data |
400 | Body or parameter validation |
invalid_parameter |
400 | Malformed query param (limit=abc) |
idempotency_key_required |
400 | Idempotency-Key missing where it is mandatory |
not_authenticated |
401 | The token is missing or invalid |
token_expired |
401 | Expired token |
insufficient_permissions |
403 | Valid identity without permissions |
coffee_not_found |
404 | The coffee does not exist |
customer_not_found |
404 | The customer does not exist |
order_not_found |
404 | The order does not exist |
review_not_found |
404 | The review does not exist |
cart_not_found |
404 | The cart does not exist or has expired |
method_not_allowed |
405 | Method not supported by the resource |
format_not_available |
406 | Accept cannot be satisfied |
insufficient_stock |
409 | There are not enough units |
order_already_paid |
409 | A second payment for the same order |
order_already_cancelled |
409 | A second cancellation |
order_not_shipped |
409 | A return for an order that has not shipped |
review_already_moderated |
409 | A second moderation of the same review |
empty_cart |
409 | Confirming a cart with no items |
operation_in_progress |
409 | An identical request is still being processed |
coffee_discontinued |
410 | Coffee permanently withdrawn |
version_conflict |
412 | If-Match with an old ETag |
body_too_large |
413 | The size limit is exceeded |
unsupported_format |
415 | Content-Type not supported |
idempotency_key_reused |
422 | Same key, different body |
rate_limit_exceeded |
429 | Limit exceeded |
internal_error |
500 | Unhandled exception |
service_unavailable |
503 | Maintenance or saturation |
Naming convention: <entity>_<problem> for the specific ones (coffee_not_found) and just <problem> for the cross-cutting ones (invalid_data). The catalogue only grows: withdrawing a code is a breaking change (02-07).
- Antipatterns
10.1. Always returning 200 with success: false
It is the most widespread and most damaging antipattern. Consequences: HTTP clients do not detect the error, automatic retries do not fire, monitoring reports 100% success, proxies cache the error as though it were a good response, and every consumer has to invent its own detection logic. It gives up Richardson level 2 entirely.
10.2. Using 500 for client errors
If a POST /v1/coffees with priceEuros: "free" causes a 500, the client's system will retry a doomed request and your team will get an alert for somebody else's mistake. Rule: if the request cannot work as it stands, it is a 4xx.
10.3. Inventing codes
299 Almost OK, 450 Business Error, 600 Failure. Intermediaries interpret unknown codes by their first digit in the best case, and reject them in the worst. Use only registered codes; for the business detail you already have the body's code field.
10.4. Others you see every day
404for every client error, hiding400,403and409behind the same number.401when permissions are missing: it sends the user off to authenticate again, which is no use and often causes token-refresh loops.- Useless messages:
"Error","Something went wrong","Check the log". - Leaking the stack trace in production.
- A body in a
204: some clients do not read the stream and will leave it half-consumed.
Common Mistakes and Tips
- Confusing 401 and 403. I do not know who you are versus I know who you are and you cannot. Memorise it that way.
- Forgetting
Locationon a201. The client is left without the URI of the created resource. - Forgetting
Allowon a405orWWW-Authenticateon a401: they are mandatory by standard and some clients depend on them. - Using
409for validation errors.409is about the resource's state; bad data is400. - Returning
200with an empty list… for an element. An empty collection is200; a non-existent element is404. - Changing the code of an already published endpoint. Going from
200to204breaks clients that read the body: it is a breaking change (02-07). - Tip: write out each endpoint's table of codes before implementing it. It is a mandatory column of the reference documentation (02-08).
- Tip: check your errors with
curl -i. Seeing the raw response uncovers wrongContent-Types and empty bodies that a polished client hides from you.
Exercises
Exercise 1: assign the correct code
State the status code, the error code and the relevant headers for each situation:
POST /v1/coffeeswith noAuthorizationheader.POST /v1/reviews/rev_101/approvalwith an ordinary customer's token.POST /v1/orderswith 100 units ofcof_002, which has stock 80.GET /v1/coffees/cof_999.PUT /v1/orders/ord_5001(Aroma Store only supportsPATCHthere).POST /v1/orders/ord_5001/paymenton an order that has already been paid.PATCH /v1/coffees/cof_001withContent-Type: application/xml.POST /v1/coffeeswith{"name": "", "priceEuros": -3}.DELETE /v1/carts/crt_77/items/cof_002, successful.- The internal stock service does not respond within 5 seconds.
Exercise 2: redesign the responses of a badly built API
A legacy API responds like this. Rewrite each response with the correct code, headers and body according to Aroma Store's contract.
HTTP/1.1 500 Internal Server Error
{ "message": "ValidationError: priceEuros must be positive\n at validate (/app/src/coffees.js:42:11)" }Exercise 3: translate into problem+json
Translate this Aroma Store error into the application/problem+json format of RFC 9457, and explain what is gained and what is lost in the translation:
{
"error": {
"code": "invalid_data",
"message": "The request body contains validation errors.",
"details": [
{ "field": "priceEuros", "problem": "It must be greater than 0.", "receivedValue": -3 },
{ "field": "roast", "problem": "Value not allowed.", "receivedValue": "super-roasted" }
]
}
}Solutions
Solution 1
| # | Code | code |
Headers |
|---|---|---|---|
| 1 | 401 |
not_authenticated |
WWW-Authenticate: Bearer realm="..." |
| 2 | 403 |
insufficient_permissions |
— |
| 3 | 409 |
insufficient_stock |
— (with details: requested 100, available 80) |
| 4 | 404 |
coffee_not_found |
— |
| 5 | 405 |
method_not_allowed |
Allow: GET, PATCH, HEAD, OPTIONS |
| 6 | 409 |
order_already_paid |
— |
| 7 | 415 |
unsupported_format |
Accept-Patch: application/merge-patch+json |
| 8 | 400 |
invalid_data |
— (two entries in details, not one) |
| 9 | 204 |
— | No body |
| 10 | 504 |
service_unavailable |
— (and traceId in the body) |
Solution 2
HTTP/1.1 404 Not Found
Content-Type: application/json
{ "error": { "code": "coffee_not_found", "message": "No coffee exists with the identifier 'cof_999'.", "details": [] } }HTTP/1.1 201 Created
Content-Type: application/json
Location: https://api.aromastore.example/v1/orders/ord_5001
{
"id": "ord_5001",
"customerId": "cus_842",
"status": "pending_payment",
"totalEuros": 29.00,
"createdAt": "2026-03-14T10:30:00Z",
"_links": {
"self": { "href": "/v1/orders/ord_5001" },
"pay": { "href": "/v1/orders/ord_5001/payment", "method": "POST" }
}
}HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "invalid_data",
"message": "The request body contains validation errors.",
"details": [ { "field": "priceEuros", "problem": "It must be a number greater than 0.", "receivedValue": -3 } ]
}
}There were two faults here: the code (a validation error belongs to the client, 400, not 500) and the leaking of the stack trace with the server's internal paths.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.aromastore.example"
Content-Type: application/json
{ "error": { "code": "not_authenticated", "message": "Authentication is required in order to access this resource.", "details": [] } }The message "you must sign in" gives away that the problem is one of authentication, so the correct code is 401, not 403, and the WWW-Authenticate header is missing.
Solution 3
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.aromastore.example/errors/invalid-data",
"title": "Invalid data",
"status": 400,
"detail": "The request body contains validation errors.",
"instance": "/v1/coffees",
"errors": [
{ "field": "priceEuros", "problem": "It must be greater than 0.", "receivedValue": -3 },
{ "field": "roast", "problem": "Value not allowed.", "receivedValue": "super-roasted" }
]
}What is gained: a standard format that tools and libraries recognise without configuration; a type as a globally unique URI that can also be a link to the error's documentation; and instance, which identifies the specific occurrence and helps to correlate with the logs.
What is lost: the error envelope, which let you tell a successful response from a failed one at a glance without looking at the code; the uniformity of the Content-Type across the whole API; and the clarity of the names —code/message/details are more direct for the consumer than type/title/detail—. On top of that, the list of validation errors (errors) is an in-house extension in both cases: the RFC does not standardise it, so that part has to be documented either way.
Conclusion
Status codes are the first line of the contract: they say whether the operation went well, whose problem it is and whether retrying makes sense. You now know when to return 201 with Location and when 204 with no body, how to tell 401 from 403 and 404 from 410, to reserve 409 for state conflicts such as insufficient_stock, not to confuse 406 with 415, and to accompany 5xx responses with Retry-After and a traceId without leaking anything internal. You also have a reusable decision tree, Aroma Store's catalogue of business error codes and a reasoned position on application/problem+json. And you know what not to do: 200 with success: false, 500 for data the client mistyped, and invented codes.
So far we have designed the envelope: where the request goes, with which verb and with what outcome. The contents are still missing. In the next lesson, 02-05 Representations, headers and content negotiation, we will design the body of Aroma Store's responses: field names and types, dates, monetary amounts, nulls versus absent fields, the data/total envelope, when to embed and when to link with _links, expansion and sparse fieldsets, and all of content negotiation with Accept, Content-Type, Accept-Language and Accept-Encoding, including the PDF invoice and coffee image uploads.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
