05-schema-api-design

REST API Contract Design

Design clean, consistent, versioned REST APIs: resource naming, idempotency, pagination strategies, error response shape, versioning, and OpenAPI contract-first design.

August 11, 2026
lldapi-designRESTpaginationversioningOpenAPI

REST API Contract Design

A REST API is a contract between teams that may never speak to each other — the mobile team, the third-party integrator, the frontend team six months from now. Once that contract ships, every inconsistency (an endpoint that returns snake_case next to one that returns camelCase, a DELETE that isn't actually idempotent, an error shape that changes per-endpoint) becomes permanent technical debt that client code has to special-case forever. This guide covers the decisions that make an API predictable enough that consumers can guess the next endpoint correctly before reading the docs.


1. Resource Naming Conventions

Resources are nouns, not actions — the HTTP method already carries the verb. URLs should be plural, hierarchical for genuine ownership, and flat once nesting gets deep.

GoodBadWhy
GET /ordersGET /getOrdersThe method (GET) is the verb; the URL is the noun
POST /ordersPOST /createOrderSame — POST already means "create"
GET /orders/{orderId}GET /order?id=123Path segments identify a specific resource, not query params
GET /customers/{customerId}/ordersGET /orders?customerId=123 (for ownership nesting)Nesting expresses "orders that belong to this customer"
GET /orders/{orderId}/line-itemsGET /customers/{id}/orders/{id}/line-items/{id}/productNest one level for ownership; beyond ~2 levels, flatten and use the leaf resource's own top-level collection with a filter
PATCH /orders/{orderId}/cancel (action-as-sub-resource, used sparingly)POST /orders/cancelOrder?id=123For actions that aren't pure CRUD, model as a sub-resource or a state transition, not an RPC-style verb in the path

Nesting limit rule of thumb: nest one level to express ownership (/customers/{id}/orders), but once a resource has its own independent identity and is looked up directly elsewhere (an order via /orders/{id}), don't force callers through the parent. Both /customers/{id}/orders (list, filtered) and /orders/{id} (direct lookup) can coexist.


2. HTTP Methods, Safety, and Idempotency

Two properties matter for every method: safe (doesn't change server state — cacheable, retryable with zero risk) and idempotent (calling it N times has the same effect as calling it once — safe to retry after a timeout).

MethodSafeIdempotentTypical useRetry-after-timeout?
GETYesYesRead a resource or collectionAlways safe to retry
HEADYesYesRead headers only, no bodyAlways safe to retry
PUTNoYesFull replace of a resource at a known URISafe to retry — same input, same end state
DELETENoYesRemove a resourceSafe to retry — deleting twice = still deleted (2nd call may 404, which is fine)
PATCHNoNot guaranteedPartial updateOnly safe to retry if the patch is itself idempotent (e.g. {"status": "SHIPPED"}, not {"increment": 1})
POSTNoNoCreate a new resource, or a non-idempotent actionNot safe to blind-retry — can create duplicates
⚠️

The POST double-submit problem: a client times out waiting for a POST /orders response and retries — but the first request actually succeeded server-side, so the retry creates a second order. Fix with an idempotency key: the client generates a UUID per logical operation and sends it as a header (Idempotency-Key: <uuid>); the server stores completed keys and returns the original response for a repeat, instead of re-executing.

http
POST /orders HTTP/1.1
Idempotency-Key: 8f14e45f-ceea-467e-b3b1-9f4c6b2a1234
Content-Type: application/json
 
{"customerId": 42, "items": [{"productId": 101, "quantity": 2}]}

3. Status Codes

Status codes are part of the contract — clients branch on them programmatically, so consistency matters more than cleverness.

RangeMeaningCommon codes to actually use
2xxSuccess200 OK (GET/PATCH/PUT success), 201 Created (POST success, include Location header), 202 Accepted (async processing started), 204 No Content (DELETE success, no body)
3xxRedirection301 Moved Permanently (resource URL changed), 304 Not Modified (conditional GET, ETag match)
4xxClient error400 Bad Request (malformed input), 401 Unauthorized (no/invalid auth), 403 Forbidden (authenticated but not allowed), 404 Not Found, 409 Conflict (state conflict, e.g. concurrent edit), 422 Unprocessable Entity (well-formed but semantically invalid), 429 Too Many Requests
5xxServer error500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable (include Retry-After)
🚨

Returning 200 OK with {"success": false, "error": "not found"} in the body is a common anti-pattern — it forces every client to parse the body to know if a call failed, defeating HTTP's built-in status-code semantics and breaking generic middleware (caching, retries, monitoring) that keys off the status code.


4. Pagination: Cursor vs Offset vs Keyset

Three strategies exist, and picking the wrong one for the dataset size is a common LLD/system-design interview trap.

Offset pagination

json
// GET /orders?limit=20&offset=40
{
  "data": [ { "id": 1041, "status": "SHIPPED" } ],
  "meta": { "limit": 20, "offset": 40, "total": 1834 }
}

Keyset (seek) pagination

json
// GET /orders?limit=20&after_id=1040
{
  "data": [ { "id": 1041, "status": "SHIPPED" } ],
  "meta": { "limit": 20, "next_after_id": 1060 }
}

Cursor pagination

json
// GET /orders?limit=20&cursor=eyJpZCI6MTA0MCwidHMiOjE3MjM0NTZ9
{
  "data": [ { "id": 1041, "status": "SHIPPED" } ],
  "meta": {
    "next_cursor": "eyJpZCI6MTA2MCwidHMiOjE3MjM0NjB9",
    "has_more": true
  }
}

The cursor is an opaque, server-generated, base64-encoded token (typically encoding the last-seen sort key plus tiebreaker) — the client never constructs or parses it, which lets the server change the encoding later without breaking clients.

OffsetKeysetCursor
SELECT cost at deep pagesOFFSET 100000 still scans/skips 100k rowsCheap — WHERE id > ? uses the index directlyCheap — same underlying mechanism as keyset
Consistency under concurrent insertsBroken — items shift between pages ("page drift")Stable — new items don't shift already-seen positionsStable, same as keyset
Jump to arbitrary page NYes — trivialNo — sequential onlyNo — sequential only
Client complexitySimplest — just an integerNeeds the last-seen key from the previous pageSimplest for the client — server hands back an opaque token
Exposes internal sort keyYes (after_id)YesNo — encoded, hides implementation
Best forSmall datasets, UI page-number widgetsLarge datasets, infinite scroll, APIsLarge public APIs, infinite scroll, when you want to change the underlying sort key later without a breaking change

Interview shorthand: offset pagination breaks down past roughly page 100 on a large table (the OFFSET scan cost grows linearly) and drifts under concurrent writes. Keyset/cursor pagination is what every major public API (Stripe, GitHub, Slack) actually uses for large collections.


5. Filtering, Sorting, and Partial Responses

text
GET /orders?status=SHIPPED&createdAfter=2026-01-01&sort=-createdAt,status&fields=id,status,total
ParameterConventionExample
Filteringfield=value for equality; documented operators for rangesstatus=SHIPPED, createdAfter=2026-01-01, total_gte=100
SortingComma-separated fields, - prefix for descendingsort=-createdAt,status (newest first, then by status)
Partial responsefields parameter listing only the wanted fieldsfields=id,status,total — reduces payload size for mobile/bandwidth-constrained clients
SearchA dedicated q param for free-text, distinct from structured filtersq=urgent+customer
💡

Partial responses (fields=) trade a bigger API surface and server-side complexity for smaller payloads — worth it for high-volume mobile clients, often not worth it for internal admin tools where full objects are simpler to cache and reason about.


6. Standardized Error Response Format

Every error, from every endpoint, should have the same envelope shape so client error-handling code is written once.

json
{
  "error": {
    "code": "ORDER_NOT_FOUND",
    "message": "No order exists with id 9981.",
    "details": [
      { "field": "orderId", "issue": "does not exist" }
    ],
    "traceId": "6b1f0c2e-4a3d-4e91-9c2a-1f7e6d5b8a90"
  }
}
FieldPurpose
codeStable, machine-readable string enum — clients branch on this, never on message
messageHuman-readable, safe to show in logs/UI — not a substitute for code
detailsArray of field-level issues, primarily for validation errors (422)
traceIdCorrelates the error to server-side logs/traces for support and debugging
⚠️

message is for humans and can change wording anytime; code is the actual contract. A client that does if (error.message === "Order not found") breaks the moment a copywriter rewords the message. Document and version the code enum, not the prose.


7. API Versioning

StrategyExampleProsCons
URL path/v1/orders, /v2/ordersExplicit, cacheable per-version, easy to route/observeURL isn't a stable identifier for the resource across versions; "resource" and "version" get conflated
Custom headerApi-Version: 2Keeps URLs stable and RESTfulEasy to forget in client code; less visible in logs/curl; harder to test in a browser
Content negotiationAccept: application/vnd.company.v2+jsonMost "correct" REST — versioning is part of content typeLeast discoverable; tooling support is weaker; steep learning curve for consumers
Query parameter/orders?version=2Simple, visibleEasy to omit (silently falls back to a default), pollutes query string used for filtering

In practice, URL path versioning (/v1/, /v2/) wins for public APIs because it's the most discoverable and the easiest to route, cache, and monitor independently per version — even though header-based versioning is arguably more "RESTfully pure." Optimize for what your actual consumers will get right, not textbook purity.

Whichever strategy you pick, prefer additive, backward-compatible changes within a version (new optional fields, new endpoints) and reserve a version bump for actual breaking changes (removing/renaming a field, changing a field's type, changing required-ness).


8. HATEOAS (Briefly)

Hypermedia As The Engine Of Application State: responses include links to the next valid actions, so clients don't hardcode URL construction.

json
{
  "id": 1041,
  "status": "PENDING",
  "_links": {
    "self": { "href": "/orders/1041" },
    "cancel": { "href": "/orders/1041/cancel", "method": "POST" },
    "customer": { "href": "/customers/42" }
  }
}
💡

HATEOAS is the most-cited, least-implemented part of REST in practice — most real-world "REST" APIs (including most public ones) skip it because it adds payload size and client complexity for a benefit (discoverability, server-driven workflow) that most SPA/mobile clients don't actually consume; they already know the routes from the docs/SDK. Know it for interviews; expect to see it rarely in production APIs outside of specific hypermedia-heavy domains.


9. OpenAPI: Contract-First vs Code-First

Code-first: write the handler/controller code, generate the OpenAPI spec from annotations. Fast to start, but the "contract" is a byproduct that can drift subtly across refactors.

Contract-first: write the OpenAPI YAML spec first, generate/validate server stubs and client SDKs from it, implement handlers against the generated interface. Slower to start, but the contract is the source of truth, reviewable in a PR before any code exists, and multiple teams (backend, frontend, QA) can work in parallel against a stable, agreed-upon shape.

yaml
openapi: 3.0.3
info:
  title: Orders API
  version: "1.0.0"
paths:
  /orders/{orderId}:
    get:
      summary: Fetch a single order
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Order found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "404":
          description: Order not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
components:
  schemas:
    Order:
      type: object
      required: [id, status, total]
      properties:
        id:
          type: integer
        status:
          type: string
          enum: [PENDING, SHIPPED, CANCELLED]
        total:
          type: number
          format: decimal
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string

Contract-first pays off most when multiple teams consume the same API (mobile + web + partner integrations) — the spec becomes the thing everyone reviews and agrees on before implementation, catching shape mismatches at design time instead of integration time. For a single-team internal API with one consumer, code-first is often pragmatically faster.


10. Rate Limiting Headers

Rate limits are part of the contract too — well-behaved clients need to know how close they are to a limit without guessing.

http
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1723459200
http
HTTP/1.1 429 Too Many Requests
Retry-After: 30

11. Caching with ETags and Conditional Requests

REST gets HTTP caching for free — an underused piece of the contract that avoids re-sending unchanged data.

http
GET /orders/1041 HTTP/1.1
 
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Cache-Control: private, max-age=60

A subsequent request can send the ETag back and let the server short-circuit to a 304 when nothing changed:

http
GET /orders/1041 HTTP/1.1
If-None-Match: "a1b2c3d4"
 
HTTP/1.1 304 Not Modified

The same ETag mechanism also solves optimistic concurrency for writes — a PUT/PATCH that includes If-Match fails with 412 Precondition Failed if the resource changed since the client last read it, preventing a lost-update race between two concurrent editors.

http
PUT /orders/1041 HTTP/1.1
If-Match: "a1b2c3d4"
 
HTTP/1.1 412 Precondition Failed

If-Match on a write is the HTTP-native version of optimistic locking — the same concept as a version column checked on UPDATE ... WHERE id = ? AND version = ? at the database layer. Both fail loudly on a stale write instead of silently overwriting a concurrent change.


12. Bulk Operations and Long-Running (Async) Requests

Not every operation fits the request-response-in-milliseconds shape. Two patterns cover the common exceptions.

Bulk operations

json
// POST /orders/bulk
{
  "operations": [
    { "method": "PATCH", "orderId": 1041, "body": { "status": "SHIPPED" } },
    { "method": "PATCH", "orderId": 1042, "body": { "status": "CANCELLED" } }
  ]
}
json
// 207 Multi-Status — each sub-operation reports its own outcome independently
{
  "results": [
    { "orderId": 1041, "status": 200 },
    { "orderId": 1042, "status": 404, "error": { "code": "ORDER_NOT_FOUND", "message": "..." } }
  ]
}

A bulk endpoint should never be all-or-nothing by default unless the client explicitly opts into transactional semantics — partial success with a per-item status is usually the more useful contract, since one bad ID in a batch of 500 shouldn't fail the other 499.

Long-running operations

An operation that can't complete within a normal request timeout (a large export, an async ML inference job) returns 202 Accepted immediately with a handle to poll or a webhook callback, rather than blocking the connection.

json
// POST /reports  ->  202 Accepted
{
  "operationId": "op_8f14e45f",
  "status": "PROCESSING",
  "_links": { "self": { "href": "/operations/op_8f14e45f" } }
}
json
// GET /operations/op_8f14e45f  (poll until done)
{
  "operationId": "op_8f14e45f",
  "status": "COMPLETE",
  "result": { "href": "/reports/rep_9931" }
}
💡

This 202 + poll pattern is the REST-level equivalent of gRPC's server-streaming or a message-queue-backed job — the client gets an immediate acknowledgment and a way to check progress later, instead of holding a connection open for a potentially multi-minute operation.


Interview Questions

  • What's the difference between a "safe" HTTP method and an "idempotent" one? Give an example of a method that's idempotent but not safe.
  • How would you prevent a client's retried POST /orders from creating a duplicate order after a network timeout?
  • Compare offset, keyset, and cursor pagination. At what scale does offset pagination start to break down, and why?
  • Design a standardized error response shape. Why should clients branch on an error code field instead of the message field?
  • Compare URL-path, header-based, and content-negotiation API versioning. Which would you pick for a public API and why?
  • What is HATEOAS, and why do most production REST APIs not fully implement it despite it being part of the original REST constraints?
  • What's the difference between contract-first and code-first API design? When does contract-first's extra upfront cost pay off?
  • Why does nesting resources more than 1-2 levels deep (/a/{id}/b/{id}/c/{id}/d) tend to become a design smell?
  • How does an ETag with If-Match on a write prevent a lost-update race between two concurrent clients editing the same resource?
  • Design a bulk-update endpoint for 500 resources at once. Should a single bad item fail the whole batch? Justify your answer.
  • How would you design an endpoint for an operation that takes 10 minutes to complete? Why not just hold the HTTP connection open?