Designing the Contract
IntermediateStatus codes, response shapes and an error format the frontend can actually branch on. Agreeing these once removes an entire category of argument.
Overview
The contract between a frontend and a backend is usually implicit, which means it is inconsistent: one endpoint returns a bare array, another wraps it, errors arrive as a string here and an object there, and the frontend accumulates special cases. Agreeing the shape up front costs an hour. The parts that matter are honest status codes, a consistent envelope for collections, and — most importantly — an error format with a stable machine-readable code, because a frontend cannot branch on a human-readable sentence.
Status Codes That Mean Something
The set worth using, and the anti-pattern to avoid.
200 OK read, or an update that returns the resource
201 Created + a Location header
204 No Content a successful delete, no body
400 Bad Request malformed — unparseable JSON, wrong types
401 Unauthorized not authenticated (the name is a misnomer)
403 Forbidden authenticated, not permitted
404 Not Found missing, or hidden from this user on purpose
409 Conflict duplicate, or a concurrent edit
422 Unprocessable well-formed but fails validation
429 Too Many rate limited — include Retry-After
500 Server Error your bug
503 Unavailable dependency down; include Retry-After
// The anti-pattern, still common:
200 OK { "success": false, "error": "Not found" }
// Every caching layer, monitor and retry policy now thinks this
// succeeded, and the frontend must inspect the body to know.
// 401 vs 403 decides client behaviour: 401 -> refresh or sign in,
// 403 -> show a forbidden or upgrade screen. Getting these backwards
// causes redirect loops.An Error Format You Can Branch On
A stable code, a human message, and per-field detail.
// One shape, every endpoint
{
"error": {
"code": "validation_failed", // STABLE. The frontend branches on this.
"message": "Some fields need attention", // human, may be reworded
"fields": { // per-field, for forms
"email": "Already registered",
"password": "At least 8 characters"
},
"request_id": "req_01HX3..." // for support and logs
}
}
// Why the code matters: a message is copy and will be reworded or
// translated. Branching on message text breaks the day someone
// improves the wording.
if (err.code === 'insufficient_credits') showTopUp()
if (err.code === 'validation_failed') applyFieldErrors(err.fields)
// FastAPI, centrally:
@app.exception_handler(AppError)
async def handle(request, exc):
return JSONResponse(status_code=exc.status,
content={"error": {"code": exc.code, "message": exc.message,
"fields": exc.fields,
"request_id": request.state.request_id}})
// Never leak internals to the client. Log the stack trace with the
// request_id; return the code and the id.Collections and Change
A consistent envelope, and how to evolve without breaking clients.
// Pick one envelope and use it everywhere
GET /problems?topic=arrays&page=2&limit=20
{
"items": [ … ],
"page": 2, "limit": 20, "total": 137, "has_more": true
}
// A bare array leaves nowhere to put pagination later without a
// breaking change.
// Naming: pick snake_case or camelCase and hold it. Mixed casing
// across endpoints is a permanent source of small bugs. A Python
// backend serving a JS frontend usually converts at the edge:
class Base(BaseModel):
model_config = ConfigDict(alias_generator=to_camel,
populate_by_name=True)
// Dates: always ISO 8601 with a timezone. "2026-09-08" with no zone
// is a different day depending on the reader.
"created_at": "2026-09-08T10:30:00Z"
// Money: integer minor units, never a float.
"amount": 49900, "currency": "INR" // ₹499.00
// Evolving without breaking:
// safe adding a field, adding an optional parameter
// breaking removing or renaming a field, changing a type,
// making an optional parameter required, changing an
// error code
// Additive changes need no version. For a genuine break, version
// the path (/v2/...) and run both until the old clients are gone —
// and remember a deployed frontend keeps running the old bundle for
// hours after you ship the new backend.Key Points to Remember
- 1Return honest status codes — a 200 with success:false breaks caching, monitoring and retries
- 2401 means authenticate, 403 means not permitted; swapping them causes redirect loops
- 3Errors need a stable machine-readable code, because messages get reworded and translated
- 4Wrap collections in an envelope so pagination can be added later without a breaking change
- 5Use ISO 8601 with a timezone and integer minor units for money; additive changes are safe, renames are not
Interview Questions
Sign in to ask AriaWhy is returning 200 with an error in the body a bad idea?
What belongs in an API error response and why include a stable code?
Which API changes are backwards compatible and which are not?
Ask Aria about Designing the Contract
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.