Four things, at minimum: a machine-parsable error CODE (a stable string a client can branch logic on), a human-readable MESSAGE (for logging and debugging, never for client branching logic), a correlation or trace ID (so a developer can find this exact request in server-side logs), and a documented mapping from error codes to HTTP status codes so the status line and the body agree on what actually happened.
Why each field earns its place
Error code, not just a status code. HTTP status codes are coarse (a 400 covers many different validation failures); a specific, stable string like insufficient_inventory lets a client write real logic ("if this specific error, show the user a restock message and offer alternatives") instead of parsing a human-readable sentence, which is fragile and can change wording without warning.
A message meant for humans, not machines. The message field exists for logs, support tickets, and developer debugging; a client should never regex-match against it to decide behavior, because message wording is exactly the kind of thing that changes without being treated as a breaking change.
A correlation ID. When a client reports "I got an error," the correlation ID is what lets an engineer find the exact request in server-side logs in seconds instead of guessing based on approximate timestamps.
A stable status-code mapping. insufficient_inventory should map to the same HTTP status every time (409 Conflict is a reasonable choice here, since it reflects a state conflict rather than malformed input), documented so both server and client code agree on the mapping rather than each side guessing.
Worked example
json
{
"error": {
"code": "insufficient_inventory",
"message": "Only 3 units of sku_1001 are available.",
"correlation_id": "req_8f2c9a1e",
"retryable": false
}
}
This example is 145 bytes as compact JSON. A client parses this programmatically by checking error.code === "insufficient_inventory" (stable, documented) to decide its own behavior (offer the customer 3 units instead of the requested 5), logs error.correlation_id for later debugging if the customer files a support ticket, and displays a localized version of its OWN copy for that error code to the end user rather than showing error.message directly (which is written for a developer audience, in one language, and not meant for end-user display).
Trade-offs and pitfalls
The most common mistake is putting only a human-readable message in the error body and expecting clients to parse it for meaning; the moment that message's wording changes (even a small rewording meant purely to be clearer for humans), any client parsing it silently breaks. A second common mistake: reusing the SAME error code for conceptually different failures because they happen to produce the same HTTP status, which forces clients back to string-matching the message anyway to tell them apart, defeating the entire purpose of having a code field.