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

  1. The five families and why they matter
  2. The 2xx family: success
  3. The 3xx family: redirection
  4. The 4xx family: client error
  5. The 5xx family: server error
  6. Aroma Store's master table
  7. Decision tree: how to choose the right code
  8. The error body: problem+json and Aroma Store's format
  9. Catalogue of business error codes
  10. Antipatterns

  1. 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.

  1. 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.

HTTP/1.1 204 No Content

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.

GET /v1/orders/ord_5001/invoice HTTP/1.1
Accept: application/pdf
Range: bytes=24000-48212
HTTP/1.1 206 Partial Content
Content-Type: application/pdf
Content-Range: bytes 24000-48212/48213
Content-Length: 24213

It is not used to paginate JSON: the mechanisms in 02-06 are there for that.

  1. 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.

HTTP/1.1 301 Moved Permanently
Location: https://api.aromastore.example/v1/coffees

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.

GET /v1/coffees/cof_001 HTTP/1.1
If-None-Match: "a1b2c3d4"
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"
Cache-Control: public, max-age=300

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.

  1. 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". The Authorization header is missing, or the token is invalid or has expired. The response must include WWW-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 in Accept (output).
  • 415 → the server does not understand what the client sends in Content-Type (input).
GET /v1/coffees/cof_001 HTTP/1.1
Accept: application/xml
HTTP/1.1 406 Not Acceptable
Content-Type: application/json

{ "error": { "code": "format_not_available", "message": "Only application/json is supported.", "details": [] } }
PATCH /v1/coffees/cof_001 HTTP/1.1
Content-Type: application/json-patch+json
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.

  1. 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.

  1. 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

  1. 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.

  1. The error body: problem+json and Aroma Store's format

The 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

  1. code is contract. In snake_case, stable, unique, and never translated. It is what software compares.
  2. message is for humans. Its wording can change and it can be translated (Accept-Language, 02-05). Never compare it in code.
  3. details is an array, always present even when empty, so that clients do not have to check whether it exists.
  4. No sensitive data: no queries, no stack traces, and no telling whether an email address exists in the database.
  5. 5xx responses carry traceId; 4xx responses do not need it.

Implementing all of this as Express middleware is lesson 03-07; here we have only fixed the contract.

  1. 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).

  1. Antipatterns

10.1. Always returning 200 with success: false

HTTP/1.1 200 OK

{ "success": false, "message": "The coffee does not exist" }

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

  • 404 for every client error, hiding 400, 403 and 409 behind the same number.
  • 401 when 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 Location on a 201. The client is left without the URI of the created resource.
  • Forgetting Allow on a 405 or WWW-Authenticate on a 401: they are mandatory by standard and some clients depend on them.
  • Using 409 for validation errors. 409 is about the resource's state; bad data is 400.
  • Returning 200 with an empty list… for an element. An empty collection is 200; a non-existent element is 404.
  • Changing the code of an already published endpoint. Going from 200 to 204 breaks 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 wrong Content-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:

  1. POST /v1/coffees with no Authorization header.
  2. POST /v1/reviews/rev_101/approval with an ordinary customer's token.
  3. POST /v1/orders with 100 units of cof_002, which has stock 80.
  4. GET /v1/coffees/cof_999.
  5. PUT /v1/orders/ord_5001 (Aroma Store only supports PATCH there).
  6. POST /v1/orders/ord_5001/payment on an order that has already been paid.
  7. PATCH /v1/coffees/cof_001 with Content-Type: application/xml.
  8. POST /v1/coffees with {"name": "", "priceEuros": -3}.
  9. DELETE /v1/carts/crt_77/items/cof_002, successful.
  10. 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 200 OK
{ "success": false, "error": "not found" }
HTTP/1.1 200 OK
{ "success": true, "id": "ord_5001" }        ← the response to POST /v1/orders
HTTP/1.1 500 Internal Server Error
{ "message": "ValidationError: priceEuros must be positive\n  at validate (/app/src/coffees.js:42:11)" }
HTTP/1.1 403 Forbidden
{ "message": "You must sign in" }

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

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved