“Clever” APIs feel nice for the person who designed them—until the second team integrates them, the mobile app ships, and you’re stuck supporting weird edge cases forever. Good API design is boring on purpose: consistent naming, predictable errors, stable pagination, and versioning that doesn’t turn into a migration festival. This post is a set of API design rules you can apply to REST-ish JSON APIs (and still benefit even if you use GraphQL/gRPC).
Quickstart
If you only fix a few things, fix these first. They’re the highest-leverage “consistency” rules that reduce bugs for every client and every future endpoint.
1) Pick conventions and write them down (one page)
You don’t need a novel—just enough to stop “endpoint-by-endpoint creativity”. Treat the conventions doc like a tiny style guide for your API.
- Resource names + path style (plural nouns, kebab-case or snake_case)
- Field casing (camelCase vs snake_case) and stick to it
- Time format (ISO 8601), timezone (UTC), money format (minor units)
- Error envelope shape + how to expose validation issues
2) Make errors boring and machine-friendly
Human-readable messages are nice; stable codes and structured details are what save you at scale.
- Use consistent HTTP status codes
- Return a stable
codeplus requesttraceId - Include field-level validation errors
- Document retry behavior (safe vs unsafe)
3) Standardize pagination today (before you need it)
Changing pagination later is surprisingly painful because clients bake in assumptions.
- Default to cursor pagination for feeds/lists
- Return
items+nextCursor(+ optionalprevCursor) - Cap
limitand document defaults - Define a stable sort order
4) Decide versioning rules before the first breaking change
Most versioning pain comes from “we’ll figure it out later”. Later arrives fast.
- Prefer additive changes inside one major version
- When breaking: use a clear migration path and deprecation window
- Keep old behavior until clients can move
- Automate compatibility tests (contract tests)
If two endpoints solve similar problems, they should look and feel the same: same naming, same response shape, same pagination strategy, same error model. Consistency reduces documentation needs because the API becomes learnable.
Overview
API design is not just “what URLs exist.” It’s a contract that many teams depend on: web, mobile, partners, internal services, QA tooling, analytics, support dashboards, and sometimes third-party developers. When the contract is inconsistent, every consumer pays the cost repeatedly: more bugs, more retries, more special cases, and more production incidents that feel “random”.
What this post covers
- Naming and resource modeling: paths, fields, verbs, and predictable shapes
- Errors: status codes, error envelopes, validation details, and traceability
- Pagination: offset vs cursor, sorting, and stable list contracts
- Versioning: how to evolve an API without breaking clients
- Practical steps: a repeatable checklist you can apply to any new endpoint
The theme is simple: consistency beats cleverness. Cleverness optimizes for the author of the API. Consistency optimizes for everyone else—especially future you, at 2AM, debugging a client integration.
Core concepts
Before rules, align on mental models. Most API design disagreements come from people optimizing different things: “purity” vs “convenience”, “REST correctness” vs “shipping”, “internal” vs “public”. The following concepts keep you grounded in what matters: clarity, stability, and operability.
1) Your API is a product contract
Treat the API like a UI. A UI with inconsistent buttons frustrates users; an API with inconsistent semantics creates bugs. Once clients depend on behavior, changing it is expensive—so design for evolution from day one.
2) Model resources (nouns) and actions (verbs) separately
A common trap is putting verbs everywhere: /getUsers, /createOrder, /doPayment.
Prefer nouns for resources: /users, /orders, /payments, and let HTTP methods carry the verb:
GET, POST, PATCH, DELETE.
When it’s okay to use “actions”
Some operations don’t map cleanly to CRUD: “verify”, “cancel”, “refund”, “rotate key”. In those cases, use a sub-resource/action that still stays consistent.
POST /payments/{id}/refund(creates a refund resource or triggers a refund action)POST /keys/{id}/rotate(action with clear intent)POST /sessions(create a session; avoid/loginif you want resource consistency)
3) Idempotency and safety are not optional details
Clients retry. Proxies retry. Networks fail. If your API doesn’t clearly separate “safe to retry” from “dangerous to retry”, you’ll eventually get duplicate charges, double creates, or phantom operations.
| Method | Typical meaning | Safe to retry? | Notes |
|---|---|---|---|
| GET | Read | Yes | Should not mutate state |
| PUT | Replace | Usually yes | Idempotent if resource identity is stable |
| PATCH | Partial update | Depends | Define behavior for repeated patch |
| POST | Create / action | Not by default | Use idempotency keys for creates/payments |
| DELETE | Delete | Usually yes | Return 204 even if already deleted (by policy) |
4) Stable shapes beat perfect shapes
Consumers build parsing logic. If one endpoint returns data, another returns result, and another returns the raw object,
you force client code to become a pile of conditions. Pick a response style and keep it consistent.
For single resources, return the resource object as the top-level JSON.
For lists, return { "items": [...], "nextCursor": "..." }.
Keep it boring; keep it consistent.
5) Evolution is the real test of API design
The first version of an API is easy. The hard part is v1 → v1.1 → v1.2 while multiple clients are in the wild. Your rules should optimize for the common changes you’ll make later: new fields, new filters, new states, new errors, new constraints.
Step-by-step
This is a repeatable workflow for designing (or cleaning up) an API endpoint. You can apply it to a single feature or use it as a checklist for an entire API surface.
Step 1 — Write a resource map (5 minutes)
Start with nouns. List the real-world entities your system manages and how they relate. This prevents the “endpoint zoo” where every new feature becomes a one-off URL.
Resource map prompts
- What are the primary resources? (users, orgs, projects, invoices, etc.)
- Which resources are nested vs referenced? (projects belong to orgs)
- What are the “stateful” operations? (cancel, approve, publish)
- What’s the identifier? (UUID, ULID, slug) and is it stable?
Step 2 — Pick naming rules and enforce them
Naming is not aesthetics; it’s discoverability. The goal is that developers can guess the path and field names without memorizing your docs.
Path conventions (good defaults)
- Plural nouns:
/users,/projects - Stable IDs:
/projects/{projectId} - Nesting for containment:
/orgs/{orgId}/projects - Filtering via query:
?status=active
Field conventions (choose one)
- Either
camelCaseorsnake_case(don’t mix) - Booleans as adjectives:
isActive,hasAccess - Timestamps as
createdAt/updatedAt(ISO 8601, UTC) - Money as minor units:
amountCentsoramountMinor+ currency
Short aliases and clever abbreviations feel efficient until someone new joins the project.
Prefer invoiceNumber over invNo; prefer status over state unless you mean something different.
Step 3 — Design the error model first (yes, first)
Errors are where your API reveals its real quality. Clients need to know: what happened, whether it’s their fault, whether they can retry, and how to fix it. The fastest way to get consistent errors is to standardize the envelope.
A practical error envelope
Aim for stable machine fields (code, traceId, details) plus a human message.
Keep it consistent across the entire API.
{
"error": {
"code": "validation_failed",
"message": "One or more fields are invalid.",
"traceId": "01JH0Z8WJX7Y2M3Q1KQ8D5R2A9",
"details": [
{ "field": "email", "reason": "invalid_format" },
{ "field": "password", "reason": "too_short", "minLength": 12 }
]
}
}
Rules that keep errors consistent
- Use HTTP status codes for broad category (400/401/403/404/409/422/429/5xx)
- Use
error.codefor stable programmatic branching (don’t branch on message text) - Include
traceId(or request id) so support can find logs fast - Include field-level details for validation errors
- Document retry guidance (e.g., 429 with
Retry-After)
Step 4 — Define list endpoints with pagination and sorting
Lists are the most common endpoints and the easiest place to become inconsistent. Decide these up front: sort order, pagination type, filters, and default limits.
Offset pagination
Simple, but can be unstable when data changes between requests.
- Works for small tables and admin dashboards
- Can skip/duplicate items when inserts happen
- Best when the dataset is stable and sorting is deterministic
Cursor pagination
More stable for feeds and large datasets. Usually the better default.
- Stable “continue from here” semantics
- Requires a consistent sort key (createdAt/id)
- Harder to jump to arbitrary pages (often fine)
If clients rely on order, the API should specify it. A good default is sort=createdAt:desc for feeds.
For cursor pagination, encode the sort key(s) inside the cursor.
Step 5 — Express the contract in OpenAPI (even if you never publish it)
OpenAPI is more than documentation: it’s a forcing function. If you can’t describe request/response and errors clearly, the endpoint is probably underspecified. Keep specs close to code and use them for client generation or contract tests.
paths:
/v1/projects:
get:
summary: List projects
parameters:
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
- in: query
name: cursor
schema: { type: string, nullable: true }
- in: query
name: status
schema: { type: string, enum: [active, archived] }
responses:
"200":
description: A page of projects
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: "#/components/schemas/Project" }
nextCursor:
type: string
nullable: true
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
Step 6 — Implement with guardrails (centralize validation + errors)
The fastest way to lose consistency is implementing “just this endpoint” differently. Centralize validation, error handling, and response formatting so endpoints can’t drift.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr, constr, ValidationError
app = FastAPI()
class CreateUser(BaseModel):
email: EmailStr
password: constr(min_length=12)
@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
details = [{"field": ".".join(map(str, e["loc"][1:])), "reason": e["type"]} for e in exc.errors()]
return JSONResponse(
status_code=422,
content={
"error": {
"code": "validation_failed",
"message": "One or more fields are invalid.",
"traceId": request.headers.get("x-request-id"),
"details": details,
}
},
)
@app.post("/v1/users", status_code=201)
async def create_user(payload: CreateUser):
# Create user; return consistent shape (resource object).
return {
"id": "usr_01JH0Z8WJX7Y2M3Q",
"email": payload.email,
"createdAt": "2026-01-09T13:21:53Z",
}
Teams don’t “forget” best practices; they get busy. Centralized middleware and shared helpers make it hard to do the wrong thing and easy to do the consistent thing.
Step 7 — Evolve without breaking clients
Most API changes can be additive: add optional fields, add new endpoints, add new enum values carefully, add query filters with safe defaults. Breaking changes should be rare and deliberate.
A practical change policy
- Additive is default: new fields are optional; don’t remove existing fields
- Enums evolve: clients must handle unknown enum values gracefully
- Deprecate visibly: document deprecations + announce timelines
- Measure usage: know which clients still call old behavior
- Break only with a migration plan: new version + compatibility window
Common mistakes
If your API feels “hard to integrate,” it’s usually one of these issues. Each has a straightforward fix—mostly by standardizing decisions and removing one-off behaviors.
Mistake 1 — Inconsistent naming (paths and fields)
Example: /user vs /users, created_at in one response and createdAt in another.
Clients end up with conditional parsing and bugs.
- Fix: publish conventions (plural nouns, one casing, timestamp rules).
- Fix: lint the OpenAPI spec or add contract tests to block drift.
Mistake 2 — “200 OK” for everything
Returning 200 on validation failures (with { success: false }) forces clients to inspect bodies for errors.
It also makes observability worse (everything looks “successful” in dashboards).
- Fix: use 4xx/5xx appropriately (422 for validation, 409 for conflicts, 401/403 for auth).
- Fix: keep a consistent error envelope with stable codes.
Mistake 3 — Pagination changes between endpoints
One list uses page/size, another uses offset/limit, another returns
next as a full URL. Clients can’t reuse list UI or data access code.
- Fix: standardize one pagination style for most lists (cursor is a strong default).
- Fix: return
itemsandnextCursorconsistently.
Mistake 4 — Breaking changes disguised as “minor tweaks”
Removing a field, renaming a status, or changing a default filter breaks clients even if the endpoint URL stays the same. Clients are code, not mind readers.
- Fix: treat breaking changes as version events with a migration plan.
- Fix: prefer additive changes and keep backward compatibility.
Mistake 5 — Hiding operational reality
Missing rate limit errors, no trace IDs, unclear retry rules: the API becomes hard to debug and expensive to support.
- Fix: include request IDs/trace IDs in responses and logs.
- Fix: use 429 +
Retry-Afterand document backoff.
Mistake 6 — Over-optimizing for the “happy path”
Real clients need partial failures, validation details, “not found” behavior, and idempotency. Ignoring those creates retries, duplicates, and confusing UX.
- Fix: design error semantics and idempotency up front.
- Fix: include meaningful status codes and stable error codes.
If a client developer needs to ask, “What does this endpoint return on failure?” more than once, your API needs a consistent error model.
FAQ
Should I use REST, GraphQL, or gRPC?
Use the style that fits your clients and constraints, but keep the same principle: consistency beats cleverness. Even in GraphQL/gRPC, you still need stable naming, predictable errors, and evolvable contracts. If you’re starting simple, a REST-ish JSON API with a solid spec and conventions is often the fastest path.
What’s the best pagination strategy: offset or cursor?
For feeds and large datasets, cursor pagination is usually the better default because it’s more stable under inserts/updates. Offset pagination can be fine for small, relatively static admin tables. The key is to pick one primary strategy and use it consistently.
Which HTTP status codes should I standardize on?
Standardize a small, predictable set: 200/201/204 for success, 400 for bad requests, 401/403 for auth, 404 for missing resources, 409 for conflicts, 422 for validation, 429 for rate limits, and 500/503 for server-side issues. Pair them with stable error codes in JSON.
How do I version an API without annoying everyone?
Avoid version bumps for additive changes. When you must break compatibility, introduce a new major version with a clear migration guide, run both versions in parallel for a deprecation window, and measure client usage so you know when it’s safe to retire the old version.
Is it okay to return different shapes for different endpoints?
Sometimes, yes—but it should be a deliberate, documented pattern (e.g., resource objects vs list envelopes). Random differences make client code brittle. If two endpoints are conceptually similar, keep response shapes aligned.
How do I handle enums changing over time?
Assume enums will evolve. Clients should treat unknown enum values as “other/unknown” rather than crashing. On the server side, add new values in a backward-compatible way and document them; don’t repurpose old values to mean something new.
Cheatsheet
A scan-fast checklist for designing consistent APIs (print this mentally).
Consistency checklist (the “boring API” standard)
- Resources: plural nouns, stable IDs, predictable nesting rules
- Methods: GET read, POST create/action, PATCH partial update, DELETE delete
- Casing: pick
camelCaseorsnake_casefor JSON and enforce it - Timestamps: ISO 8601, UTC; use
createdAt/updatedAt - Lists:
{ items: [], nextCursor: "..." }with cappedlimit - Sorting: explicit and documented; cursor encodes sort key(s)
- Errors: consistent envelope with stable
error.code+traceId - Validation: 422 with field-level details; avoid “200 with success:false”
- Idempotency: support idempotency keys for risky POSTs (payments/creates)
- Auth: consistent 401 vs 403 semantics; don’t leak sensitive details in errors
- Rate limits: 429 + retry guidance (
Retry-After) and consistent headers - Versioning: additive changes by default; breaking changes get a migration plan
- Docs/spec: maintain an OpenAPI contract; use it for contract tests/linting
Tiny status code map (keep it predictable)
| Scenario | Status | Error code example |
|---|---|---|
| Invalid input / missing required fields | 422 | validation_failed |
| Unauthenticated (no/invalid token) | 401 | unauthorized |
| Authenticated but not allowed | 403 | forbidden |
| Resource doesn’t exist | 404 | not_found |
| Conflict (duplicate, invalid state transition) | 409 | conflict |
| Too many requests | 429 | rate_limited |
| Server failure / dependency down | 500/503 | internal_error, service_unavailable |
Consistency is a multiplier: every consistent decision makes the next endpoint cheaper to design, implement, test, and consume.
Wrap-up
The best compliment an API can get is: “It behaves exactly how I expected.” You get there by choosing conventions once (naming, casing, errors, pagination, versioning) and enforcing them everywhere. Clever endpoints and custom behaviors feel like progress, but they create long-term support debt.
Next actions
- Write a one-page API style guide (naming, casing, time/money formats, error envelope)
- Refactor two endpoints to match the guide (prove you can enforce it)
- Standardize list endpoints with one pagination strategy
- Adopt an OpenAPI spec and add a lightweight lint/contract test step
- Define a deprecation/versioning policy before your first breaking change
If you’re thinking about long-term evolution, check the related posts below—especially API versioning and resilience patterns. Consistency in contracts and consistency in failure handling tend to improve together.
Quiz
Quick self-check (demo). This quiz is auto-generated for software / architecture / best.