Siddhant Deval
Siddhant Deval
backend16 min read

RESTful API Design: Resource Contracts, HTTP Semantics & Status Code Discipline

REST is not a set of conventions teams pick up informally — it is a uniform interface constraint that, when followed precisely, produces APIs that are self-describing, cache-correct, and safe to retry. This article derives RESTful design from first principles: resource modeling, HTTP method idempotency contracts, status code semantics, pagination invariants, and RFC 7807 structured error bodies.

Series·Part 1 of 7

API Architecture & System Resilience

RESTful API Design: Resource Contracts, HTTP Semantics & Status Code Discipline

Senior engineers don't just wire services together — they design the boundary: the contract, the trust model, the failure envelope, and the signal pipeline that proves it's working. In REST APIs, that boundary starts with the URL and the HTTP method. The most expensive mistake in backend development is building an API that works — that returns the right data and accepts the right payloads — but that violates HTTP semantics in ways that clients cannot detect until they retry a failed request and discover they've created duplicate orders, applied a discount twice, or deleted the wrong resource. This article derives RESTful design from first principles so that every decision carries an engineering rationale, not a convention.

Architectural Note

Series positioning: This is Part 1 of the API Architecture & System Resilience series. It establishes the REST design vocabulary that all subsequent articles build on — Part 2 (API Gateways) assumes fluency in method semantics and status code contracts.


1. The Uniform Interface Constraint

REST is not "JSON over HTTP." It is an architectural style defined by Roy Fielding in his 2000 dissertation, and its most important constraint is the uniform interface: every resource is identified by a URI, and every interaction uses the same small set of standardized HTTP methods with well-defined semantics. When teams violate this constraint, they create proprietary RPC protocols with HTTP as the transport — and lose every benefit that standard tooling, caches, proxies, and clients expect.

1.1 Resource Modeling: Nouns, Not Verbs

HTTP
❌ Verb-oriented (RPC disguised as HTTP):
POST /getOrder?orderId=42
POST /cancelOrder
GET  /createPayment

✅ Resource-oriented (REST):
GET    /orders/42
POST   /orders/42/cancellations
POST   /payments

The URI identifies a resource (a noun), not an operation (a verb). The HTTP method is the verb. This distinction matters because HTTP caches, load balancers, and API gateways make decisions based on the method — a GET is safe to cache and retry; a POST is not. Verb-in-URL APIs lose all of this infrastructure-level intelligence.

Crucial Requirement

Resource hierarchy depth should not exceed 3 levels: /{collection}/{id}/{sub-collection}. Deeper hierarchies (e.g., /users/{id}/orders/{orderId}/items/{itemId}/discounts) create tight coupling between URL structure and database schema, make API evolution harder, and produce URLs that are impossible to remember. If you need a 4th level, extract it as a top-level resource.

1.2 Collection vs Singleton Resources

Pattern URI Example Semantics
Collection /orders A set of resources; supports GET (list) and POST (create)
Singleton /orders/42 A single resource; supports GET, PUT, PATCH, DELETE
Sub-collection /orders/42/items Items belonging to order 42; independent collection
Singleton sub-resource /orders/42/status A scalar property modeled as a resource when mutation matters
HTTP method semantics matrix showing GET, POST, PUT, PATCH, DELETE across safe, idempotent, request body, response body, and common status codes dimensions.
HTTP method semantics matrix showing GET, POST, PUT, PATCH, DELETE across safe, idempotent, request body, response body, and common status codes dimensions.

2. HTTP Method Semantics & Idempotency Contracts

2.1 The Five Methods and Their Contracts

HTTP
GET    /orders/42       → Safe + Idempotent. No side effects. Identical response every time (given stable state).
POST   /orders          → Neither safe nor idempotent. Creates a new resource. Response includes Location header.
PUT    /orders/42       → Idempotent (not safe). Replaces entire resource. Repeated calls produce same result.
PATCH  /orders/42       → Idempotent only when deterministic. Partial update.
DELETE /orders/42       → Idempotent (not safe). Repeated deletes return 404 after first success — that is correct behavior.

The critical distinction is between safe (no side effects — GET, HEAD, OPTIONS) and idempotent (multiple identical requests produce the same server state — GET, PUT, DELETE). POST and non-deterministic PATCH are neither.

2.2 The PUT vs PATCH Idempotency Trap

TYPESCRIPT
// ❌ PATCH used as a relative increment — NOT idempotent
PATCH /inventory/SKU-001
{ "adjustQuantity": -5 }
// Calling this twice reduces quantity by 10. Retrying a failed request causes data corruption.

// ✅ PATCH used as a deterministic set — IS idempotent
PATCH /inventory/SKU-001
{ "quantityOnHand": 45 }
// Calling this twice produces the same result: quantityOnHand = 45

// ✅ PUT for full replacement — always idempotent
PUT /inventory/SKU-001
{
  "sku": "SKU-001",
  "quantityOnHand": 45,
  "reorderPoint": 10,
  "warehouseId": "WH-NYC-01"
}
// PUT requires ALL fields. Omitting a field implies its removal (or reset to default).
Performance / Safety Warning

If your PATCH endpoint accepts relative operations (adjustQuantity: -5), it is no longer idempotent and cannot be safely retried without an idempotency key. Make this explicit in your API contract — clients who retry on network failure will corrupt your data.

2.3 POST for Non-Idempotent Creation

TYPESCRIPT
// ✅ POST creates a new resource — response MUST include Location header
POST /orders
Content-Type: application/json
{
  "userId": "usr_abc",
  "items": [{ "sku": "SKU-001", "quantity": 2 }]
}

// Response:
HTTP/1.1 201 Created
Location: /orders/ord_xyz789
Content-Type: application/json
{
  "id": "ord_xyz789",
  "status": "pending",
  "createdAt": "2026-09-06T17:43:00Z"
}

The Location header is mandatory on 201 Created — it tells the client where the new resource lives without requiring a second request. Omitting it forces clients to parse the response body to determine the new resource URI, which is not standardized.


3. Status Code Taxonomy

3.1 2xx — Success

Code Name When to Use
200 OK Success with body GET, PUT, PATCH responses with a body
201 Created Resource created POST that creates a new resource; include Location header
202 Accepted Async processing Request accepted but processing is async (job queued); include job status URL
204 No Content Success, no body DELETE that succeeds; PATCH/PUT when response body is not needed
Pro Tip & Optimization

Return 202 Accepted with a Location header pointing to a job status endpoint for long-running operations. Never make clients wait synchronously for operations that take more than 5 seconds. The job status endpoint should return 200 with {"status": "pending"|"running"|"completed"|"failed"}.

3.2 4xx — Client Errors (Never Retry Without Code Change)

HTTP
400 Bad Request        → Malformed syntax. The request body is not parseable JSON, or a required field is missing entirely.
401 Unauthorized       → No credentials or expired token. Client must re-authenticate before retrying.
403 Forbidden          → Valid credentials, insufficient permissions. Retrying with the same token will never succeed.
404 Not Found          → Resource does not exist. Client must not retry — the resource is gone.
409 Conflict           → State conflict. Duplicate creation attempt, optimistic lock failure (ETag mismatch), or state machine violation.
422 Unprocessable      → Syntactically valid but semantically invalid. endDate < startDate, quantity > stockLevel, etc.
429 Too Many Requests  → Rate limit exceeded. Client MUST respect Retry-After header.
Performance / Safety Warning

400 and 422 are NOT synonyms. Returning 400 for semantic validation errors (endDate before startDate) forces clients to parse the error message body to understand the root cause. Use 422 for field-level semantic violations and reserve 400 for structural/syntactic malformation. GraphQL APIs that always return 200 make this worse — distinguish the cases.

3.3 5xx — Server Errors (Potentially Retryable)

Code Retryable? Client Action
500 Internal Server Error Sometimes Retry with backoff only if idempotent operation
502 Bad Gateway Yes Upstream service error; retry with backoff
503 Service Unavailable Yes Service overloaded or deploying; respect Retry-After
504 Gateway Timeout Yes (carefully) Upstream timed out; retry only if idempotent

4. RFC 7807 Problem Details — Structured Error Bodies

TYPESCRIPT
// ❌ Ad-hoc error body — clients must parse free-text strings
HTTP/1.1 422 Unprocessable Entity
{
  "error": "Invalid date range",
  "message": "The end date cannot be before the start date"
}
// Branching on this requires string matching — fragile and untranslatable

// ✅ RFC 7807 Problem Details — machine-readable, extensible
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/invalid-date-range",
  "title": "Invalid Date Range",
  "status": 422,
  "detail": "The checkout endDate (2026-01-01) must be after startDate (2026-03-01).",
  "instance": "/orders/ord_xyz789/checkout",
  "errors": [
    {
      "field": "endDate",
      "code": "DATE_BEFORE_START",
      "value": "2026-01-01"
    }
  ]
}

The type field is a URI that uniquely identifies the error class. Clients can branch on it with a simple string equality check — no regex, no message parsing. The URI does not need to resolve to a live page (though it should), but it must be stable and unique per error type.

Mental Model Check

Think of type as an enum value that is globally unique because it is a URI. Two different APIs can both define a "not-found" error with different type URIs, and a client can distinguish them. This is the machine-readable contract that error codes provide in gRPC — REST needed RFC 7807 to get there.


5. Pagination Patterns

5.1 Why Offset Pagination Fails at Scale

TYPESCRIPT
// ❌ Offset pagination — inconsistent under concurrent inserts
GET /orders?offset=100&limit=20

// Problem: Between page 5 and page 6, a new order is inserted at position 100.
// Page 5 returned items 100–119.
// Page 6 now returns items 120–139 — but the item that WAS at 120 shifted to 121.
// Item at OLD position 120 (now 121) appears on BOTH page 5 and page 6.
// Item at OLD position 119 is skipped entirely.
// This is a silent data integrity bug, invisible to the client.

5.2 Cursor-Based Pagination

TYPESCRIPT
// ✅ Cursor pagination — stable under concurrent mutations
GET /orders?limit=20
// Response:
{
  "data": [...],
  "pagination": {
    "nextCursor": "eyJpZCI6Im9yZF8xMjMiLCJjcmVhdGVkQXQiOiIyMDI2LTA5LTA2In0=",
    "hasMore": true
  }
}

// Next page:
GET /orders?limit=20&cursor=eyJpZCI6Im9yZF8xMjMiLCJjcmVhdGVkQXQiOiIyMDI2LTA5LTA2In0=

The cursor is a base64-encoded opaque token encoding the last-seen position (typically the id and createdAt of the last item). Clients treat it as opaque — they must not decode or construct cursors. Insertions before the cursor position do not affect subsequent pages.

5.3 Keyset Pagination — Database-Native Scale

SQL
-- ✅ Keyset pagination — uses the index directly, O(log n) regardless of page number
SELECT id, title, created_at
FROM orders
WHERE created_at < '2026-09-06T17:43:00Z'
   OR (created_at = '2026-09-06T17:43:00Z' AND id < 'ord_xyz789')
ORDER BY created_at DESC, id DESC
LIMIT 21;
-- Fetch 21 to detect hasMore; return 20 to the client

-- ❌ Offset query — O(n) full scan grows with page depth
SELECT id, title, created_at
FROM orders
ORDER BY created_at DESC
OFFSET 10000 LIMIT 20;
-- At offset 10,000: PostgreSQL scans 10,000 rows to throw them away. At offset 1,000,000: it scans 1,000,000.
Pagination evolution showing offset drift under inserts (left, red), cursor pagination with stable opaque token (center), and keyset pagination using indexed column comparisons for O(log n) database performance (right, cyan).
Pagination evolution showing offset drift under inserts (left, red), cursor pagination with stable opaque token (center), and keyset pagination using indexed…

6. API Versioning Strategies

6.1 The Three Approaches

Strategy Example Pros Cons
URL path /v1/orders Explicit, cacheable, simple to test URLs change; old versions must be maintained
Header Accept: application/vnd.example.v2+json Clean URLs; content negotiation standard Invisible in browser, harder to test
Query param /orders?version=2 Simple to add Non-standard; pollutes cache keys
Pro Tip & Optimization

URL path versioning (/v1/) is the pragmatic choice for most APIs: it is explicit, cache-correct, and testable with any HTTP client. Reserve header versioning for APIs where URL stability is more important than version clarity (e.g., public APIs with published bookmarks).

6.2 When Breaking Changes Are Unavoidable

TYPESCRIPT
// ✅ Non-breaking changes (safe to ship without version bump)
// - Adding new optional fields to responses
// - Adding new optional fields to request bodies
// - Adding new endpoints
// - Adding new enum values (if clients ignore unknown values)

// ❌ Breaking changes (require version bump)
// - Removing fields from responses
// - Renaming fields
// - Changing field types (string → number)
// - Changing required/optional status of request fields
// - Changing status codes for existing conditions
// - Removing endpoints

// ✅ OpenAPI 3.1 deprecation pattern:
{
  "userId": {
    "type": "string",
    "description": "User identifier",
    "deprecated": true,
    "x-deprecation-reason": "Use accountId instead. userId will be removed in v3 (2027-01-01)."
  },
  "accountId": {
    "type": "string",
    "description": "Account identifier — replaces userId"
  }
}

7. Filtering, Sorting & Field Selection

7.1 Query Parameter Conventions

HTTP
GET /orders?status=shipped&createdAfter=2026-09-01T00:00:00Z&sort=-createdAt,id&fields=id,status,totalCents

# Filtering:  ?status=shipped&createdAfter=2026-09-01T00:00:00Z
# Sorting:    ?sort=-createdAt,id     (- prefix = descending)
# Sparse:     ?fields=id,status,totalCents  (reduces response payload)

7.2 Contract-First Design with OpenAPI 3.1

YAML
# openapi.yaml — schema-first, validated in CI
openapi: "3.1.0"
info:
  title: Orders API
  version: "1.0.0"
paths:
  /orders:
    get:
      operationId: listOrders
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, processing, shipped, delivered, cancelled]
        - name: sort
          in: query
          schema:
            type: string
            pattern: "^-?(createdAt|updatedAt|totalCents)(,-?(createdAt|updatedAt|totalCents))*$"
        - name: cursor
          in: query
          schema:
            type: string
            description: Opaque pagination cursor from previous response
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderListResponse"
        "400":
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
TYPESCRIPT
// CI gate: validate all request/response bodies against the OpenAPI schema
import { OpenApiValidator } from 'express-openapi-validator'

app.use(
  OpenApiValidator.middleware({
    apiSpec: './openapi.yaml',
    validateRequests: true,
    validateResponses: true, // Catch response schema violations in test/staging
  })
)
// Any response that violates the schema in staging fails the build.
// This is the only reliable way to prevent accidental breaking changes.
Crucial Requirement

Enable validateResponses: true in staging and CI environments. Most teams validate only requests. Response validation catches schema drift — when the database returns a null that your schema marks as required, or when a field type changes silently — before it reaches clients in production.


Summary

Concept Rule
Resource URIs Nouns only. Hierarchy max 3 levels. Collections are plural.
GET / HEAD Safe + idempotent. Never modify state. Cache-eligible.
POST Creates resources. Not idempotent. Always return Location header on 201.
PUT Full replacement. Idempotent. All fields required; omitted = deleted/defaulted.
PATCH Partial update. Idempotent only if deterministic (set, not increment).
DELETE Idempotent. Returns 204 on success, 404 on repeat — both are correct.
Status codes 400 = malformed; 422 = semantically invalid; 409 = conflict; never conflate.
Error bodies RFC 7807 application/problem+json. type is a stable URI. Clients branch on type.
Pagination Keyset for scale; cursor for stability; offset only for UI-driven small datasets.
Versioning URL path (/v1/) for most APIs. Deprecate fields before removal.
Contract-first OpenAPI 3.1 schema validated in CI for both requests and responses.

What's Next

In Part 2, we examine the API Gateway — the single enforcement point that sits in front of all your well-designed REST resources and applies JWT validation, rate limiting, circuit breaking, and trace ID injection as a unified infrastructure layer. Part 2: API Gateways →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#REST#API Design#HTTP#OpenAPI#Backend
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.