Platform

API Design That Ages Well — Patterns We Use on Every Project

Most API mistakes are visible within three months. The ones that aren't destroy a codebase over two years. These patterns — versioning, error shapes, pagination, and naming — are what survive contact with production.

30 May 20269 min readAnkur

Bad API design reveals itself slowly. The first three months: everything works. Months 4–12: workarounds accumulate. Year two: every feature request starts with "first we need to refactor the API." By year three, the frontend team has built an entire translation layer just to paper over inconsistent response shapes.

We've built APIs for textile ERP, workflow automation platforms, and multi-tenant SaaS. The patterns below aren't theoretical. They're what survived 18 months of production use without requiring a rewrite.

1. Version Your API from Day One

Not "we'll add versioning when we need it." Version from the first endpoint. The cost is one path segment (/v1/). The benefit is the ability to ship breaking changes without coordinating with every API consumer.

GET /api/v1/orders          ← current
GET /api/v2/orders          ← future, when v1 becomes a constraint

We use URL-path versioning (/v1/), not header-based. Header-based versioning (Accept: application/vnd.api+json;version=1) is cleaner in theory. In practice, it's invisible during debugging — you can't copy-paste a curl command and immediately see which version you're hitting. For teams where backend and frontend engineers need to debug together, visibility wins.

💡 Key Insight Version your API, not your data model. If v2 changes the response shape but queries the same database table, that's versioning done right. If v2 requires a separate table because v1 made schema decisions that can't be undone, the API design failed.

2. Every Error Response Looks the Same

The single most frustrating API design sin: different error shapes for different endpoints. One returns { error: "..." }, another returns { message: "...", code: 123 }, a third returns plain text.

Standardize. Every error response follows this shape:

{
  "error": {
    "code": "INSUFFICIENT_INVENTORY",
    "message": "Requested 50 units of SKU-4421. Available: 12.",
    "details": [
      {
        "field": "quantity",
        "reason": "exceeds_available",
        "available": 12,
        "requested": 50
      }
    ],
    "request_id": "req_9x2k7a"
  }
}

Rules:

  • code is a machine-readable string (UPPER_SNAKE_CASE). Frontend code switches on this.
  • message is human-readable, actionable. "Something went wrong" is not actionable.
  • details is an array of field-level errors. Always an array, never an object — single-field errors and multi-field errors use the same iteration pattern.
  • request_id ties the error to server logs. Non-negotiable for debugging in production.

HTTP status codes: 400 for validation, 401 for auth, 403 for authorization, 404 for missing resources, 409 for conflicts, 422 for semantic errors, 429 for rate limits. Never return 200 with an error body. Never return 500 for client mistakes.

3. Pagination That Survives Changing Data

Offset-based pagination (?page=3&limit=20) breaks when rows are inserted or deleted between requests. Item 40 on page 3 becomes item 38 after one deletion — and your user sees a duplicate.

Cursor-based pagination solves this:

{
  "data": [...],
  "pagination": {
    "cursor": "eyJpZCI6ICI0MmFiYyJ9",
    "has_more": true,
    "total": 1847
  }
}

cursor is an opaque token. The server encodes the last-seen sort key (typically a ULID or timestamp + ID tuple). The client passes it back as ?cursor=<value>. The server queries WHERE sort_key > decoded_cursor_value ORDER BY sort_key LIMIT 21 (fetching 21 to detect has_more).

When to use each:

Cursor pagination:

  • Lists that change frequently (orders, messages, activity feeds)
  • Infinite scroll UIs
  • Data ordered by creation time

Offset pagination:

  • Static or slowly-changing lists (audit logs, configuration)
  • UIs with explicit page numbers
  • When total count is a hard requirement

We default to cursor-based for new APIs. Offset is the fallback when the frontend team has a hard requirement for "Page 7 of 23."

4. Date and Time — Pick One Format, Enforce It

Every API we've inherited that used inconsistent date formats eventually caused a production bug. The bug is always minor — a timezone miscalculation, a display issue — but it erodes trust.

Our rule: all timestamps are ISO 8601 in UTC, with milliseconds. Always.

"created_at": "2026-05-30T08:45:00.000Z"

No Unix timestamps (ambiguous — seconds or milliseconds?). No "2026-05-30" strings (what timezone?). No "30/05/2026" localization (parsing ambiguity). No separate date and time fields.

The frontend is responsible for timezone conversion for display. The API is responsible for being unambiguous.

5. Naming — Consistent, Predictable, Boring

Do ThisNot ThisWhy
`created_at``createdAt`, `creation_date`, `date_created`Pick one convention. snake_case for JSON, camelCase if you must. Never mix.
`GET /orders`, `POST /orders``GET /getOrders`, `POST /createOrder`HTTP method already says what you're doing. Don't repeat it.
`GET /orders/42``GET /orders?id=42`Resource ID in the path. Query params for filters, sorting, pagination.
`GET /orders?status=shipped``GET /shipped-orders`Filtering is a query param, not a new endpoint.
`POST /orders/42/cancel``PUT /orders/42 { status: "cancelled" }`Non-idempotent actions get verb endpoints. Partial updates use PATCH.

Naming consistency reduces cognitive load. Every endpoint follows the same patterns. Every response has the same shape. An engineer joining the project should be able to guess the endpoint for a new resource without reading documentation.

6. Bulk Operations Have a Contract

Bulk endpoints are where API design usually collapses. Do you return partial success? What's the HTTP status code when 3 of 10 items fail? Is the response atomic or not?

We use a consistent bulk contract:

{
  "results": [
    { "status": "created", "id": "ord_001", "item": {...} },
    { "status": "error", "item": {...}, "error": { "code": "DUPLICATE", "message": "..." } },
    { "status": "created", "id": "ord_003", "item": {...} }
  ],
  "summary": {
    "total": 3,
    "succeeded": 2,
    "failed": 1
  }
}

HTTP status: 207 Multi-Status when mixed. Never 200 with errors nested inside — clients that check status codes will miss the failures. Never 400 and abandon the whole batch — the caller needs to know which items succeeded.

What We Ship

These six patterns cover 80% of the API design decisions on a new project. The remaining 20% — authentication, rate limiting, webhooks, file upload — are project-specific. But get versioning, errors, pagination, dates, naming, and bulk operations right, and your API will age better than most.

The test: six months from now, when someone asks "how do I paginate the new inventory endpoint?", the answer should be "same as every other endpoint." If it isn't, the API design failed.

Tags

  • api-design
  • rest
  • patterns
  • versioning
  • error-handling
  • pagination