← All notes
API Development8 min

An error string is not a contract

Most of the APIs we touch either call a platform or get called by a partner. In both directions the failure mode that wastes the most time is the same: an HTTP status and a free-text message that no client can safely branch on. RFC 9457 Problem Details — and the typed error objects platforms already ship — turn that into a contract: a stable type, an honest status, and an instance id you can find in logs.

Status codes answer one question. Partners need three.

An HTTP status tells you the broad class of failure: bad request, not found, conflict, rate limited, server fault. That is useful and it is not enough. A mobile client deciding whether to retry, a partner mapping the failure to a form field, and an operator searching logs for the same occurrence each need a different answer. A bare 400 with a free-text message forces every consumer to invent their own parser for your prose.

We keep seeing the same shape in integrations we consume and ones we ship. Shopify GraphQL mutations can return HTTP 200 with a filled userErrors array — transport success, business failure. Stripe puts a stable error code next to a human message. Homegrown APIs often return {"error": "Invalid order"} and leave the client guessing whether that means missing id, wrong shop, already printed, or a validation typo.

The contract is not the English sentence. The contract is a stable identifier the client can switch on, plus enough structure to act without scraping the detail string.

RFC 9457 is the boring default for REST errors

Problem Details for HTTP APIs (RFC 9457, formerly 7807) is a small JSON document with Content-Type application/problem+json. The useful members are type (a URI that names the problem class), title (stable summary for that type), status (must match the HTTP status), detail (occurrence-specific prose), and instance (a URI or id for this request). Extensions are allowed — validation arrays, retry hints, correlation ids — and clients are expected to ignore members they do not understand.

What matters in practice is that type stays stable across releases. Do not encode the day's wording into the identifier. Prefer something like https://api.example.com/problems/order-already-printed over a slug that changes when marketing rewrites the copy. Document each type somewhere partners can open — even a short HTML page per URI is enough — so the type is a lookup, not folklore.

We use this shape on the small mutation surfaces we expose: print job create, parse kickoff, payment start. Status still carries the class. type carries the decision. detail is for humans reading a log or a toast. If a client has to regex the detail field to decide what to do next, the contract failed.

Validation belongs in one response, not five round trips

When several fields are wrong, return them together. An errors extension — each entry with a field path, a machine code, and a short message — lets a form highlight every broken input on the first submit. Making the caller discover one typo per request is how partner integrations burn a day in QA.

Pick the status carefully. 400 for malformed syntax. 422 when the JSON parsed but the business rules failed. 409 when the resource exists in a state that blocks the write — already printed, already voided, version mismatch. Do not return 200 with an error body. Clients check the status line first; lying there splits every integrator into two camps.

For GraphQL-shaped surfaces, the same discipline applies under different names. Shopify's userErrors with field and message (and often code) is the mutation-side equivalent: always request that array, and never treat a clean top-level errors list as proof the mutation succeeded.

Correlate the failure you returned with the failure you logged

instance should point at something you can find in your logs — a request id, a trace id, a path that includes both. When a partner emails "it failed this morning," you want to paste one token and land on the same row they hit. Returning a friendly message with no handle is how support becomes archaeology.

Keep internals out of the body. Stack traces, SQL fragments, bucket names, and auth provider error dumps do not help the client fix their request; they help the wrong reader map your system. Put diagnostics in your logs keyed by the same instance value you returned. The public detail can say the order was already ticketed. The log can say which printer job id won the race.

Retry guidance belongs in headers and typed problems, not vibes. 429 should carry Retry-After. Transient 503s should be distinguishable by type from permanent 422s so a client library can backoff without asking a human to read the sentence.

What this is not

This is not an argument for inventing a custom error DSL on every project. Prefer Problem Details on REST, and the platform's established pattern when you are wrapping Shopify, Stripe, or similar. Consistency beats originality here.

It is also not a substitute for pinning API versions or minting idempotency keys. Those are adjacent contracts. Versioning keeps the fields stable. Idempotency keeps retries safe. Structured errors keep failure modes actionable. You still need all three on a mutation surface partners will call from production.

If you only change one thing on an existing API, stop returning a single free-text error field as the only signal. Add a stable type (or code), keep status honest, and give instance a value you can search. Partners will write less brittle glue, and you will spend less time decoding screenshots of toast messages.

Questions

Why not just return a human-readable error string?
Humans need the string. Clients need a stable type or code they can switch on without parsing prose. Status plus a free-text message forces every integrator to invent a fragile parser — and it breaks the moment you rewrite the copy.
Should every API adopt RFC 9457?
Use Problem Details for REST/HTTP JSON APIs you control. When you wrap a platform, follow its pattern — Shopify userErrors, Stripe error codes — and map those into your own typed problems at the boundary rather than inventing a third shape.
What belongs in detail versus type?
type names the class and stays stable across occurrences. detail explains this occurrence for a human. If a client must regex detail to decide retry versus fix-the-form, move that signal into type or a documented extension member.

Sources

  1. RFC 9457 — Problem Details for HTTP APIs
  2. Shopify Admin GraphQL — UserError
  3. Stripe — Error codes

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us