Home/Learn/Full-Stack Integration/Designing the Contract

Designing the Contract

Intermediate
Calling Your API

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

The status is part of the payload
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.

Branch on code, display message, log request_id
// 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.

One envelope, ISO dates, integer money
// 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 Aria
1

Why is returning 200 with an error in the body a bad idea?

Medium
2

What belongs in an API error response and why include a stable code?

Medium
3

Which API changes are backwards compatible and which are not?

Medium

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.

Loading discussion…