REST API Contract Design
Design clean, consistent, versioned REST APIs: resource naming, idempotency, pagination strategies, error response shape, versioning, and OpenAPI contract-first design.
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.
| Good | Bad | Why |
|---|---|---|
GET /orders | GET /getOrders | The method (GET) is the verb; the URL is the noun |
POST /orders | POST /createOrder | Same — POST already means "create" |
GET /orders/{orderId} | GET /order?id=123 | Path segments identify a specific resource, not query params |
GET /customers/{customerId}/orders | GET /orders?customerId=123 (for ownership nesting) | Nesting expresses "orders that belong to this customer" |
GET /orders/{orderId}/line-items | GET /customers/{id}/orders/{id}/line-items/{id}/product | Nest 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=123 | For 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).
| Method | Safe | Idempotent | Typical use | Retry-after-timeout? |
|---|---|---|---|---|
GET | Yes | Yes | Read a resource or collection | Always safe to retry |
HEAD | Yes | Yes | Read headers only, no body | Always safe to retry |
PUT | No | Yes | Full replace of a resource at a known URI | Safe to retry — same input, same end state |
DELETE | No | Yes | Remove a resource | Safe to retry — deleting twice = still deleted (2nd call may 404, which is fine) |
PATCH | No | Not guaranteed | Partial update | Only safe to retry if the patch is itself idempotent (e.g. {"status": "SHIPPED"}, not {"increment": 1}) |
POST | No | No | Create a new resource, or a non-idempotent action | Not 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.
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.
| Range | Meaning | Common codes to actually use |
|---|---|---|
| 2xx | Success | 200 OK (GET/PATCH/PUT success), 201 Created (POST success, include Location header), 202 Accepted (async processing started), 204 No Content (DELETE success, no body) |
| 3xx | Redirection | 301 Moved Permanently (resource URL changed), 304 Not Modified (conditional GET, ETag match) |
| 4xx | Client error | 400 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 |
| 5xx | Server error | 500 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
// GET /orders?limit=20&offset=40
{
"data": [ { "id": 1041, "status": "SHIPPED" } ],
"meta": { "limit": 20, "offset": 40, "total": 1834 }
}Keyset (seek) pagination
// GET /orders?limit=20&after_id=1040
{
"data": [ { "id": 1041, "status": "SHIPPED" } ],
"meta": { "limit": 20, "next_after_id": 1060 }
}Cursor pagination
// 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.
| Offset | Keyset | Cursor | |
|---|---|---|---|
SELECT cost at deep pages | OFFSET 100000 still scans/skips 100k rows | Cheap — WHERE id > ? uses the index directly | Cheap — same underlying mechanism as keyset |
| Consistency under concurrent inserts | Broken — items shift between pages ("page drift") | Stable — new items don't shift already-seen positions | Stable, same as keyset |
| Jump to arbitrary page N | Yes — trivial | No — sequential only | No — sequential only |
| Client complexity | Simplest — just an integer | Needs the last-seen key from the previous page | Simplest for the client — server hands back an opaque token |
| Exposes internal sort key | Yes (after_id) | Yes | No — encoded, hides implementation |
| Best for | Small datasets, UI page-number widgets | Large datasets, infinite scroll, APIs | Large 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
GET /orders?status=SHIPPED&createdAfter=2026-01-01&sort=-createdAt,status&fields=id,status,total
| Parameter | Convention | Example |
|---|---|---|
| Filtering | field=value for equality; documented operators for ranges | status=SHIPPED, createdAfter=2026-01-01, total_gte=100 |
| Sorting | Comma-separated fields, - prefix for descending | sort=-createdAt,status (newest first, then by status) |
| Partial response | fields parameter listing only the wanted fields | fields=id,status,total — reduces payload size for mobile/bandwidth-constrained clients |
| Search | A dedicated q param for free-text, distinct from structured filters | q=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.
{
"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"
}
}| Field | Purpose |
|---|---|
code | Stable, machine-readable string enum — clients branch on this, never on message |
message | Human-readable, safe to show in logs/UI — not a substitute for code |
details | Array of field-level issues, primarily for validation errors (422) |
traceId | Correlates 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
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/orders, /v2/orders | Explicit, cacheable per-version, easy to route/observe | URL isn't a stable identifier for the resource across versions; "resource" and "version" get conflated |
| Custom header | Api-Version: 2 | Keeps URLs stable and RESTful | Easy to forget in client code; less visible in logs/curl; harder to test in a browser |
| Content negotiation | Accept: application/vnd.company.v2+json | Most "correct" REST — versioning is part of content type | Least discoverable; tooling support is weaker; steep learning curve for consumers |
| Query parameter | /orders?version=2 | Simple, visible | Easy 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.
{
"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.
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: stringContract-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/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1723459200HTTP/1.1 429 Too Many Requests
Retry-After: 3011. Caching with ETags and Conditional Requests
REST gets HTTP caching for free — an underused piece of the contract that avoids re-sending unchanged data.
GET /orders/1041 HTTP/1.1
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Cache-Control: private, max-age=60A subsequent request can send the ETag back and let the server short-circuit to a 304 when nothing changed:
GET /orders/1041 HTTP/1.1
If-None-Match: "a1b2c3d4"
HTTP/1.1 304 Not ModifiedThe 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.
PUT /orders/1041 HTTP/1.1
If-Match: "a1b2c3d4"
HTTP/1.1 412 Precondition FailedIf-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
// POST /orders/bulk
{
"operations": [
{ "method": "PATCH", "orderId": 1041, "body": { "status": "SHIPPED" } },
{ "method": "PATCH", "orderId": 1042, "body": { "status": "CANCELLED" } }
]
}// 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.
// POST /reports -> 202 Accepted
{
"operationId": "op_8f14e45f",
"status": "PROCESSING",
"_links": { "self": { "href": "/operations/op_8f14e45f" } }
}// 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 /ordersfrom 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
codefield instead of themessagefield? - 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
ETagwithIf-Matchon 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?