Skip to main content
  1. Posts/

The Contract

·785 words·4 mins
Photograph By Romain Dancre
Blog Software Engineering Web Development
Table of Contents

The API That Lies
#

I once inherited an API where every endpoint returned 200 OK. Login failed? 200 with { "success": false, "message": "Invalid credentials" }. User not found? 200 with { "error": "No such user" }. Validation error? 200 with a completely different error shape than the other two.

The frontend had if statements everywhere to figure out if a “successful” response was actually successful. Monitoring showed zero errors because every response was a 200. The API technically worked — it just lied about everything.

An API is a contract. When the contract says “200 means success” but the body says “actually, something broke,” the contract is worthless. These patterns exist to prevent that kind of pain.

Resources, Not Actions
#

REST maps HTTP methods to operations on resources. The URL is the noun, the method is the verb.

GET    /api/v1/users          → list users
GET    /api/v1/users/123      → get one user
POST   /api/v1/users          → create a user
PUT    /api/v1/users/123      → replace a user
PATCH  /api/v1/users/123      → update fields
DELETE /api/v1/users/123      → delete a user

The anti-pattern is putting verbs in URLs: /getUsers, /createUser, /deleteUser/123. These turn your API into an RPC interface that happens to use HTTP. REST already has verbs — they’re called methods.

Nested resources for relationships: /users/123/orders gives you orders for user 123. Clean, predictable, discoverable.

Status Codes That Mean Something
#

The response code should tell the client what happened without reading the body.

CodeWhen to UseCommon Mistake
200Successful GET, PUT, PATCHUsing for everything (including errors)
201Successful POST (created)Forgetting the Location header
204Successful DELETE (no body)Returning 200 with empty body
400Malformed input (bad JSON)Using 500 for validation errors
401Not authenticated (no token)Confusing with 403
403Authenticated but not allowedUsing for unauthenticated requests
404Resource doesn’t existLeaking info (“user exists but you can’t access it”)
409Conflict (duplicate, version mismatch)Returning 400 for conflicts
422Valid format, fails business rulesLumping with 400
429Rate limitedNot returning Retry-After header

The 401 vs 403 distinction trips people up. 401 = “I don’t know who you are” (missing or invalid token). 403 = “I know who you are, but you can’t do this” (valid token, insufficient permissions).

Error Responses: One Shape, Always
#

Every error should look the same. The consumer writes one error handler, not a different one per endpoint.

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": [
      { "field": "email", "message": "must not be empty" },
      { "field": "age", "message": "must be a positive integer" }
    ]
  }
}

Machine-readable code for programmatic handling. Human-readable message for debugging. details array for field-level specifics. And never expose stack traces, SQL errors, or internal file paths in production — that’s a security risk wrapped in a debugging convenience.

Pagination: Two Approaches
#

When an endpoint can return thousands of results, you need pagination.

Offset-based (?page=2&limit=20) is simple. Clients can jump to any page. But on large datasets, the database still scans rows to reach the offset. And if data changes between page requests, items can shift — you might skip or duplicate entries.

Cursor-based (?cursor=eyJpZCI6MTAwfQ==&limit=20) is more robust. The cursor encodes where you left off. The database seeks directly to that position — no scanning. Data changes don’t cause skips or duplicates. But you can’t jump to “page 5” — it’s next/previous only.

Use offset for admin UIs where users need random page access. Use cursor for public APIs, feeds, and anything where data changes frequently.

Version From Day One
#

You will break your API. A field gets renamed, a type changes, an endpoint gets restructured. Without versioning, every consumer breaks.

/api/v1/users  → original
/api/v2/users  → breaking change (field renamed)

URL path versioning is the clearest approach. The version is visible in every request, easy to route at the load balancer level, and impossible to miss. Header-based versioning (Accept: application/vnd.myapp.v2+json) is cleaner in theory but hidden in practice — easy to forget, hard to test.

When to version: removing fields, changing types, changing auth. When NOT to version: adding fields, adding endpoints, adding optional parameters — these are backward compatible.

The K6 Connection
#

When I started load testing APIs , bad API design showed up immediately. Endpoints returning massive payloads that should have been paginated. Missing status codes that made it impossible to distinguish real failures from expected ones in the test results. Inconsistent error shapes that required custom parsing per endpoint.

Good API design isn’t just about making consumers happy — it makes your own testing, monitoring, and debugging significantly easier. When a 500 is a real error and a 422 is a validation failure, your Prometheus alerts can tell the difference.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.

Related

Traffic Cops
·682 words·4 mins
Photograph By Adil Edin
Blog Software Engineering System Design
Load balancing algorithms, L4 vs L7, and why your requests end up where they do
The Bouncer at the Door
·854 words·5 mins
Photograph By Enrico Bet
Blog Software Engineering System Design
Rate limiting algorithms, layered protection, and why your API needs a velvet rope
The Fastest Code Never Runs
·1531 words·8 mins
Photograph By Kelly Sikkema
Blog Software Engineering System Design
Caching, Redis, and the art of not hitting your database