Status Codes and an Error Contract Clients Can Trust

The database went down at 04:12. Nobody found out until 09:30, when a customer emailed asking why their invoices had stopped.

The alerting was not broken. It watched the 5xx rate, and the 5xx rate genuinely was zero, because of one well-meaning line at the bottom of the middleware stack:

app.use((err, req, res, next) => {
  res.json({ ok: false, error: err.message });
});

No status. The default is 200. So for five hours and eighteen minutes the API answered every request with a cheerful 200 OK carrying this:

{ "ok": false, "error": "connect ECONNREFUSED 10.0.3.14:5432" }

Add up what that one line bought:

  • The load balancer polled /health, got a 200, and kept every pod in rotation. Nothing was ever restarted.
  • The 5xx panel stayed flat at 0.00%. Nobody was paged.
  • The mobile client’s retry wrapper checked if (!response.ok), saw true, and never retried anything.
  • A shared cache upstream was free to store it, because 200 is cacheable by default and nothing said otherwise.
  • Every user on the internet was handed the private IP and port of the primary database.
the same database outage, answered two ways200 OK, error in the body503 Service Unavailableload balancer✗ keeps the pod in rotation✓ drains the pod5xx alerting✗ error rate 0.00%, all green✓ pages the on-callclient retry✗ res.ok is true, no retry✓ backs off, then retriesshared cache✗ cacheable, stores it✓ not stored by defaultFour systems, four wrong conclusions, from one missing call to res.status().
The same five-hour outage, answered two ways. Nothing in this picture reads your JSON. Every one of them reads the three digits.

Returning 200 OK with an error in the body is the original sin of API design, and this is why. It is not a style disagreement. You have quietly opted out of every piece of machinery that was built to notice when you break.

Who actually reads the status code

Here is the frame that makes the rest of this easy.

The body is for your client’s developers. The status code is for everything in between. Load balancers, CDNs, corporate proxies, service meshes, retry libraries, your APM’s error-rate calculation, the browser’s response.ok, your uptime checker. None of them parse your JSON. None of them know your field names. All of them read those three digits, and all of them are entitled to act on what they read.

That is the deal. You get one integer to talk to the whole internet with, and it is already standardised, so nobody has to read your docs to understand it. An enormous amount of machinery, for free, if you just say the true thing. Throw it away and you are back to a private protocol only your own client speaks, running on top of an internet that assumes you meant what you said.

Five families, and only one hard question

The first digit is the whole classification. The last two digits, per the spec, have no categorising role at all. They are just labels within a class.

Class Means What a client does
1xx Interim. Hang on, more is coming. Waits. You rarely send these by hand.
2xx It worked. Reads the body as data.
3xx It’s somewhere else. Goes there (or stops, for 304).
4xx You broke it. Fixes the request. Retrying is pointless.
5xx We broke it. Backs off, retries, and tells someone.

The spec’s own wording for the two error classes is worth reading closely, because the difference is doing real work:

The 4xx (Client Error) class of status code indicates that the client seems to have erred.

The 5xx (Server Error) class of status code indicates that the server is aware that it has erred or is incapable of performing the requested method.

“Seems” is the honest hedge: you are asserting a belief about someone else’s request. “Is aware that it has erred” is a confession. Those are different speech acts, and they route to different humans.

4xx or 5xx is the only question that costs money

Every other choice here is a detail. This one decides whether a machine retries and whether a human wakes up.

4xxthe client seems to have erredclient should retrynowakes the on-callnoburns the error budgetno5xxthe server is aware it has erredclient should retryyes, with backoffwakes the on-callyesburns the error budgetyesso there are exactly two ways to get it wrong500 when they sent a bad emailnoise: paged for a typo you cannot fix400 when you dereferenced nullsilence: an outage nothing will ever alert on
The class you pick is a routing decision. 4xx routes the problem to your client's developers; 5xx routes it to you, at 3am.

The test that settles almost every case:

If sending the identical bytes again could succeed, it’s a 5xx. If it would fail identically until the end of time, it’s a 4xx.

A malformed date will still be malformed in thirty seconds. A pool exhaustion might not be. That is the whole distinction, and it is why the two mislabellings hurt differently.

Calling a validation failure a 500 gives you noise. Your dashboard shows a 4% error rate that is actually one customer’s broken cron job sending garbage every 15 seconds. The on-call learns that the alert means nothing, and six weeks later a real outage fires the same alert and gets the same shrug. That is the expensive part. You didn’t break your service, you broke your alerting.

Calling your own crash a 400 gives you silence, and silence is worse. Every client is now told, authoritatively, that they are wrong. They will not retry. They will not report it, because their logs say 400 Bad Request and their first assumption is that they messed up. You have made your bug look like their bug and removed the only signal that would have told you.

The ones everybody gets wrong

401 and 403

Start with the joke everyone has heard and nobody remembers under pressure:

401 says Unauthorized and means unauthenticated. 403 says Forbidden and means unauthorized. The name is the bug. It has been the bug since 1997 and nobody is renaming it, so just take the ten seconds and memorise it.

requestdo I know you?credentialsmay you?permissionsis it there?existenceyourhandlernonono401+ WWW-Authenticate403no creds will help404nothing to seeanswer 404 instead of 403 to hide that it exists401 is the unauthenticated one. Yes, the names are backwards. No, nobody is fixing it.
Three questions, three codes. The dashed path is the one the spec explicitly blesses: answer 404 instead of 403 when even the existence of the thing is a secret.

The decision procedure, phrased so you never have to think about it again:

Would a different set of credentials fix this? If yes, 401. If no, 403.

No token, expired token, garbage token, revoked token: 401. Perfectly valid token belonging to a user who is simply not an admin: 403. It follows that a client can do something useful with a 401 (refresh the token and retry once) and can do nothing at all with a 403 except show a message.

Now the requirement almost nobody honours. The spec is not gentle about it:

The server generating a 401 response MUST send a WWW-Authenticate header field containing at least one challenge applicable to the target resource.

For a bearer-token API that means:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
Content-Type: application/json

and when a token was sent and rejected, the bearer-token spec gives you a vocabulary for saying why:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token",
                  error_description="The access token expired"

That second one is genuinely useful: a client can tell “refresh and retry” apart from “the user must log in again” without parsing your prose.

404 as a deliberate lie

404 normally means what it says. But there is a second, entirely legitimate use, and the spec spells it out:

An origin server that wishes to “hide” the current existence of a forbidden target resource MAY instead respond with a status code of 404 (Not Found).

So when a logged-in user asks for /invoices/9182 and that invoice belongs to a different company, 403 is technically accurate and leaks something real: it confirms that invoice 9182 exists. Scrape ids until you stop getting 404s and you have mapped the size and shape of the customer base without ever seeing a byte of data.

The thing that makes this work is total consistency. If you return 404 for the invoices you want to hide but 403 for the ones you forgot about, the 403 is now a positive signal. Worse, if the 404 path takes 4ms and the 403 path takes 40ms because it loaded the row first, the timing tells the story your status code was hiding. Pick one behaviour for the whole class of resource and check it in the query, not in an if after the fetch.

The rule of thumb: 403 if the URL is public knowledge (/admin is linked in your own nav), 404 if the id itself is the secret.

400 or 422

This is the argument your team will have, and here is the tiebreak: it matters less than you think, and it matters a great deal that you stop having it.

That said, the spec did draw the line, and it is a clean one. 400 is for “something perceived to be a client error (e.g. malformed request syntax)”. 422 Unprocessable Content is narrower:

The 422 (Unprocessable Content) status code indicates that the server understands the content type of the request content (hence a 415 (Unsupported Media Type) status code is inappropriate), and the syntax of the request content is correct, but it was unable to process the contained instructions.

So:

The request Code
Body is not valid JSON at all 400
Content-Type: text/yaml and you only take JSON 415
Valid JSON, but age is -5 and email has no @ 422
Valid JSON, valid values, but the state forbids it 409

409, and its cousin 412

409 Conflict is for a request that is perfectly well-formed and perfectly well-authorised, and still cannot happen because of what is currently true:

The 409 (Conflict) status code indicates that the request could not be completed due to a conflict with the current state of the target resource. This code is used in situations where the user might be able to resolve the conflict and resubmit the request.

That last clause is the filter. 409 implies the client could plausibly do something about it. Real cases:

  • Signup with an email that is already registered (your unique index just raised 23505).
  • POST /orders/o_88/refunds on an order that was already refunded.
  • A state machine violation: shipping an order that is cancelled.
  • An idempotency key replayed with a different body.

The distinction people miss is 409 versus 412 Precondition Failed. It is about who raised the objection:

  • The client sent If-Match: "v7", the resource is now at "v9"412. They stated a precondition and it evaluated false. This one is mechanical.
  • The client sent no precondition at all, and you discovered the clash yourself → 409.

If you are doing optimistic concurrency with ETags, 412 is the correct and more informative answer, and 428 Precondition Required is how you tell a client that they must send If-Match and did not.

429 and 503, the two codes that name a time

These two are special. Everything else says what happened. These say when to come back.

429 Too Many Requests means exactly what it says, and the header that makes it useful is optional but you should always send it:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

Retry-After takes either a delay in seconds or an HTTP-date. Seconds is easier to get right; a date requires the client’s clock to be sane, and clients’ clocks are not sane.

503 Service Unavailable is for “temporarily overloaded or down for maintenance, and this will likely clear up”. Note what that is not: 500 says the condition was unexpected, which gives a client no reason to believe waiting helps. 503 says the opposite. A pod that is failing its readiness check, an app shedding load at the edge, a deploy in progress: all 503, never 500. See graceful shutdown for the draining side of that story.

Both are excluded from caching by the specs (429 responses MUST NOT be stored by a cache; 503 is not heuristically cacheable, so a cache leaves it alone unless you explicitly say otherwise). That is deliberate: a cached rate-limit response would keep punishing a client who has long since calmed down.

Do not invent status codes

Every so often someone proposes 452 Account Suspended. It feels tidy. It is worse than useless, and the spec explains why in one sentence:

A client MUST understand the class of any status code, as indicated by the first digit, and treat an unrecognized status code as being equivalent to the x00 status code of that class.

So your beautiful 452 is a 400 to every proxy, cache, CDN and HTTP library on earth. It conveys less than 403 would, since at least 403 carries meaning. And the only consumer that understands 452 is your own client, which could just as easily have read "code": "account_suspended" out of the body.

The status code is HTTP’s vocabulary and it is fixed. Your vocabulary goes in the body, where it can be as specific as you like and can grow without asking IANA. Do not mix the layers.

what actually went wrong?we broke it5xxthey broke it4xxa bug escaped your handler500overloaded, shedding, deploying503upstream answered with garbage502upstream never answered in time504the body would not parse400no credentials, or stale ones401known, and not allowed403no such thing (or a polite lie)404wrong method for this URL405parsed fine, values are wrong422clashes with the current state409too many, too fast429Headers that ride along: 401 needs WWW-Authenticate, 405 needs Allow.429 and 503 want Retry-After. Never invent a code: an unknown 452 is just a 400 to everything.
The only hard question is the first one. Everything under it is a lookup, which is why it is worth doing once and pinning to the wall.

Try it against some cases you will actually meet. A few of these have a defensible second answer, and the demo says so rather than pretending the world is tidy:

interactivePick the status code

Now the body

The status code tells the internet what happened. The body tells your client’s developer what to do about it. Those are different audiences and they need different amounts of detail.

Most APIs get here and improvise, per endpoint, over three years, with four different people. The result is not one API. It is nine.

✗ what your API returns todayPOST /login{ “error”: “Invalid password” }POST /orders{ “success”: false, “msg”: “out of stock” }GET /users/9{ “errors”: [{ “title”: “Not found” }] }your proxy<html><title>502 Bad Gateway</title>…Four shapes = four branches in every client, forever. The last one throws inside res.json().✓ one shape, every endpoint, every status, forever{ “error”: { “code”: “invalid_credentials”,“message”: “Email or password is wrong.”,“requestId”: “01JZK8Q2M4” } }
Every extra shape is a permanent branch in every client that ever calls you. The nginx page is the one nobody plans for, and it is the one that throws.

The three fields that earn their place

{
  "error": {
    "code": "invalid_credentials",
    "message": "Email or password is wrong.",
    "requestId": "01JZK8Q2M4XW6P9YQZ7N3H0B"
  }
}

code is the contract. A short, stable, machine-readable token from a closed vocabulary you control. It is an API surface, exactly as much as your URLs are, which means: never rename one, never localise one, never make it a sentence. invalid_credentials, not "Invalid credentials". When you add a new one, that is additive and fine. When you change one, you broke every client that switched on it, silently, at runtime.

message is for a human, and specifically for a developer. Not for your UI. The moment a client string-matches your message text, your prose becomes an API you cannot change, and someone’s login page breaks because you fixed a typo. Say so in your docs, loudly: match on code, display your own copy.

requestId is the one people skip and then wish they had. More on it below, but the short version: it turns “it broke, idk” into a five-second grep.

Everything else is optional and situational. What matters far more than the exact field names is that this shape is the same for a 400 and a 500 and a 429, and the same from the endpoint you wrote last night and the one from 2021.

Field-level details for validation

Validation is the one case where a flat message genuinely isn’t enough, because the client wants to paint red text next to three specific inputs. Extend the same envelope rather than inventing a second one:

{
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "details": [
      { "field": "email", "code": "format", "message": "Must be an email address." },
      { "field": "items[0].qty", "code": "min", "message": "Must be at least 1." }
    ],
    "requestId": "01JZK8Q2M4XW6P9YQZ7N3H0B"
  }
}

Three things to get right here. Return all the failures, not the first one, because a form that reveals its problems one round-trip at a time is a form people abandon. Make field a path that survives nesting (items[0].qty, or a JSON Pointer like /items/0/qty if you prefer, but pick one). And give each detail its own code, so a client can distinguish “too short” from “wrong format” without reading English. Validating at the boundary goes deep on generating these from a schema instead of by hand.

Problem Details, if you’d rather not design this yourself

There is a standard for exactly this, and it is decent: RFC 9457, Problem Details for HTTP APIs, published July 2023, obsoleting the older RFC 7807. It defines the media type application/problem+json and five members:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://example.com/probs/out-of-credit",
  "title": "You do not have enough credit.",
  "status": 403,
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/account/12345/msgs/abc",
  "balance": 30,
  "accounts": ["/account/12345", "/account/67890"]
}

Reading that against the envelope above, the mapping is nearly one to one:

RFC 9457 The idea
type A URI identifying the kind of problem. This is your code. Consumers MUST use it as the primary identifier, not title. Absent means about:blank.
title A stable human summary of the type. Should not vary between occurrences except for localisation.
status The HTTP status, duplicated. Advisory only, and you MUST use the same code on the actual response.
detail Human explanation of this occurrence. Explicitly meant to help fix the problem, not to carry debugging output.
instance A URI for this specific occurrence. Your requestId, with a URI hat on.

Extensions (balance, accounts above) are just extra members, and consumers MUST ignore ones they don’t recognise, which is what makes the format additively evolvable.

What must never come out

Back to the opening. err.message in the response body is one of the most productive information-disclosure bugs in existence, precisely because the line that causes it looks so helpful.

Here is what leaks, from a pg driver error on a query that is properly parameterised (this is not an injection story, the SQL is fine, the error is the problem):

// The query is parameterised. That is not the bug.
await db.query('INSERT INTO orders (user_id, total) VALUES ($1, $2)', [userId, total]);
error: insert or update on table "orders" violates foreign key
       constraint "orders_user_id_fkey"
  code: '23503'
  detail: 'Key (user_id)=(8812) is not present in table "users".'
  file: '/srv/app/dist/db/orders.js:41'

Pass that through and you have published: your table names, your column names, your constraint naming convention, your deploy path, your build layout, and the fact that user 8812 does not exist. Do it on a unique-violation instead (23505) and the driver hands the attacker Key (email)=([email protected]) already exists, which is an account-enumeration oracle wearing a database error costume.

Stack traces are worse. A stack names your files, your directory structure, your framework, your npm dependencies and often their versions. That is a shopping list: an attacker now knows exactly which CVE to try. See security in depth and supply chain security for what they do with it.

what actually threwerror: insert or update ontable “orders” violatesforeign key constraint“orders_user_id_fkey”code: 23503at /srv/app/dist/orders.js:41+ 22 more frameserror handlermints one idyour log (private)requestId: 01JZK8Q2M4code: 23503constraint: orders_user_id_fkeyroute: POST /orders · stack: 22the response (public)500 Internal Server Errorcode: internal_errormessage: Something brokerequestId: 01JZK8Q2M4The id is the only thing on both sides. That is the entire trick, and it costs one line.
Two channels, one id. Everything useful goes to the log. The response gets a generic message and the string that connects the two.

Allowlist, never blocklist

The instinct is to scrub: strip the file paths, redact the hostnames, filter the SQL. That is a blocklist, and blocklists lose. You will forget a case, a new dependency will invent a new message format, and the leak comes back in a release nobody reviewed for it.

Invert it. An error message is private until something explicitly says it is public.

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
    readonly details?: unknown,
  ) {
    super(message);
    this.name = 'ApiError';
  }
  // The only errors whose message reaches a client are the ones
  // we constructed on purpose, for a reason we can defend.
  readonly expose = true;
}

export const badRequest = (code: string, msg: string, details?: unknown) =>
  new ApiError(400, code, msg, details);
export const unauthorized = (msg = 'Authentication required.') =>
  new ApiError(401, 'unauthenticated', msg);
export const forbidden = (msg = 'Not allowed.') =>
  new ApiError(403, 'forbidden', msg);
export const notFound = (msg = 'Not found.') =>
  new ApiError(404, 'not_found', msg);
export const conflict = (code: string, msg: string) =>
  new ApiError(409, code, msg);
export const unprocessable = (details: unknown) =>
  new ApiError(422, 'validation_failed', 'Some fields are invalid.', details);

Anything that isn’t an ApiError is, by definition, something you did not anticipate. Which is exactly the definition of a 500. (If you use the http-errors package, this is the same idea as its expose flag, which defaults to true below 500 and false at or above it. Same instinct, same reason.)

One terminal handler

Everything above only pays off if there is exactly one place that turns a thrown thing into a response.

import { randomUUID } from 'node:crypto';

// Runs first: every request gets an id, in and out.
app.use((req, res, next) => {
  req.id = req.get('x-request-id') || randomUUID();
  res.set('x-request-id', req.id);
  next();
});

// ... routes ...

// Runs last, after everything. Four arguments is what makes it an error handler.
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err); // response already streaming; bail
  const known = err instanceof ApiError;

  const status = known ? err.status : 500;
  const code = known ? err.code : 'internal_error';

  // Full detail goes to the log, keyed by the id. Always. Even for 4xx.
  logger[status >= 500 ? 'error' : 'warn']({
    requestId: req.id,
    status,
    code,
    route: `${req.method} ${req.route?.path ?? req.path}`,
    err,                       // the real error object, stack and all
  });

  // A curated subset goes to the client.
  res.status(status).json({
    error: {
      code,
      message: known ? err.message : 'Something went wrong on our end.',
      ...(known && err.details ? { details: err.details } : {}),
      requestId: req.id,
    },
  });
});

The shape of that is what matters, not the framework. Note the pieces:

  • headersSent is the one branch people forget. If you were streaming and blew up halfway, you cannot set a status any more. Hand it to the default handler and let it kill the connection, which at least signals something went wrong. Streaming a response covers what a mid-stream failure looks like from the client’s side.
  • The unknown branch has no access to err.message. Not “sanitised”. Not “truncated”. Not there at all. That is the allowlist doing its job.
  • 4xx gets logged too, at a lower level. A spike in validation_failed on your signup endpoint is a broken deploy on someone’s frontend, and you want to see it before they email you.
  • The id is on the request, the response header, and the body. Three places, because whoever is debugging will only have one of them.

Then the two gaps that are not on any happy path:

// 404: no route matched. Without this, the framework's HTML page wins.
app.use((req, res, next) => next(notFound(`No route for ${req.method} ${req.path}`)));

// The body parser throws before your code ever runs. Translate it.
app.use((err, req, res, next) => {
  if (err.type === 'entity.parse.failed') return next(badRequest('malformed_json', 'Body is not valid JSON.'));
  if (err.type === 'entity.too.large') return next(new ApiError(413, 'body_too_large', 'Body exceeds the limit.'));
  next(err);
});

The correlation id

That id does more work per byte than anything else in your API.

The flow it enables: a user hits an error, your UI shows Something went wrong. Reference: 01JZK8Q2M4, they paste it into a support chat, and someone greps one field and lands on the exact stack, the exact route, the exact user, the exact deploy. No “what were you doing?”, no “can you try again?”, no archaeology.

A few rules that make it work:

  • Accept an incoming x-request-id and reuse it if present, so the id follows a request across your gateway and through your internal services. Generate one only if nobody upstream did.
  • Make it opaque and unguessable-ish. A UUIDv4 or a ULID is fine. A sequential integer tells the world your traffic volume.
  • Put it on every log line for that request, not just the error, so the id gives you the whole story and not one frame of it.
  • Return it on successes too, in the header. When a user says “the thing I did at 2pm was wrong”, a successful-but-wrong request is still a request you need to find.

Observability is where this grows up into structured logging, trace context propagation and actual distributed traces. The id is the seed of all of it, and it is worth planting on day one even if you never do the rest.

The client’s half of the contract

A contract has two sides, and a client that treats your careful status codes as noise wastes all of it. The shape of a client that holds up its end:

async function apiCall(path, init) {
  const res = await fetch(path, init);
  if (res.ok) return res.json();

  // Never assume the body is yours. A proxy may have answered.
  const type = res.headers.get('content-type') ?? '';
  const body = type.includes('json') ? await res.json().catch(() => null) : null;

  throw new ApiClientError({
    status: res.status,
    code: body?.error?.code ?? 'unknown',
    message: body?.error?.message ?? res.statusText,
    requestId: body?.error?.requestId ?? res.headers.get('x-request-id'),
    retryAfter: Number(res.headers.get('retry-after')) || null,
  });
}

The decisions that follow from it:

  • Switch on status first, code second, message never. The status tells you which of the two of you has a problem. The code tells you which problem. The message is for the console.
  • Retry 5xx, 429 and 408. Nothing else. Retrying a 400 in a loop is how you turn one client’s bug into a denial of service against yourself. And only retry idempotent requests, unless you have idempotency keys, which exist precisely so that you can safely retry the ones that aren’t.
  • Honour Retry-After when it’s there, with jitter on top. Exponential backoff when it isn’t.
  • On a 401, refresh the token once and retry once. Once. A refresh loop against an endpoint that always 401s is a beautiful way to get your API key banned by your own rate limiter.
  • Log the requestId on every failure. It costs nothing and it is the only thing that makes a support conversation short.

fetch covers the request side of this in detail.

Summary

  • Returning 200 with an error in the body opts you out of the entire internet. Health checks, caches, retry logic, and your own error-rate alerting all read the status code and none of them read your JSON.
  • The 4xx/5xx split decides two things that cost money: whether a client should retry, and whether a human should be woken up. The test is simple. If the identical request could succeed later, 5xx. If it would fail forever, 4xx.
  • A legal request that hits your bug is a 5xx. Whose input triggered it doesn’t matter. Whose contract was broken does.
  • 401 means unauthenticated, 403 means unauthorized. The names are backwards. Ask “would different credentials help?”, and remember that a 401 MUST carry WWW-Authenticate (use Bearer, never Basic, or the browser hijacks your login).
  • 404 instead of 403 is a legitimate, spec-blessed way to hide that something exists. It only works if you do it for every id in the class, timing included.
  • 400 is broken syntax, 422 is broken values, 415 is the wrong media type, 409 is a clash with current state. 412 is when the client stated a precondition; 409 is when you found the conflict yourself.
  • 429 and 503 are the codes that name a time. Send Retry-After. Neither is cached. Jitter on the client or the whole herd comes back at once.
  • Never invent a status code. Everything on the internet treats an unknown 452 as a plain 400. Your vocabulary belongs in the body’s code, which can grow forever.
  • One error envelope for every endpoint and every status: a stable machine code, a developer-facing message nobody should string-match, details for field errors, and a requestId. Cover your framework’s 404 and your proxy’s HTML page too, or clients will meet shapes you never designed.
  • RFC 9457 Problem Details (application/problem+json, July 2023, obsoletes 7807) is a ready-made version of that envelope: type is your code and is the primary identifier, title is stable per type, status must match the real status, detail and instance are per occurrence. Great for a new API. Not worth churning clients over for an old one, and the shared type registry has not taken off.
  • Errors are private until explicitly marked public. Allowlist with an ApiError class, one terminal handler, and no path at all from err.message to the response for anything you didn’t construct yourself. Stack traces, SQL, driver text and internal paths never leave the building.
  • Log everything against a correlation id and return the id. It is the cheapest debugging tool you will ever ship, and it turns “it broke” into a grep.