Skip to content
Unverified — AI-generated content. Help verify this page

REST API Best Practices

REST is not a protocol — it is an architectural style defined by Roy Fielding in his 2000 doctoral dissertation. Most APIs that call themselves "RESTful" implement a fraction of what Fielding described. This page covers what actually matters in production: the conventions, patterns, and design decisions that make a REST API intuitive, consistent, and evolvable.

Resource-Oriented Design

REST thinks in resources, not actions. A resource is any concept your API exposes — a user, an order, an invoice, a deployment. Resources are nouns, never verbs.

Naming Conventions

# Good — resources are nouns, collections are plural
GET    /api/users
GET    /api/users/42
GET    /api/users/42/orders
POST   /api/users/42/orders

# Bad — action-oriented, verb-based
GET    /api/getUser?id=42
POST   /api/createOrder
POST   /api/users/42/sendEmail
RuleExampleRationale
Use plural nouns for collections/users not /userGET /users returns a list; GET /users/42 returns one item from that list
Use lowercase with hyphens/order-items not /orderItemsURLs are case-insensitive in practice, hyphens are more readable
Nest to express relationships/users/42/ordersShows that orders belong to a user
Limit nesting to 2 levels/users/42/orders not /users/42/orders/7/items/3/reviewsDeep nesting creates coupling and long URLs
Use query params for filtering/orders?status=shippedKeeps the resource path clean

WARNING

Avoid nesting beyond two levels. If you need /users/42/orders/7/items/3, consider promoting items to a top-level resource: GET /order-items/3. Deep nesting couples your URL structure to your data model.

Singleton vs Collection Resources

GET /users              → Collection: returns array of users
GET /users/42           → Singleton: returns one user
GET /users/42/profile   → Singleton sub-resource (one-to-one relationship)
GET /users/42/orders    → Collection sub-resource (one-to-many relationship)

HTTP Methods

Each HTTP method carries semantic meaning. Using them correctly enables caching, retry logic, and intermediary processing.

MethodSemanticsIdempotentSafeRequest Body
GETRead a resourceYesYesNo
POSTCreate a new resourceNoNoYes
PUTReplace a resource entirelyYesNoYes
PATCHPartially update a resourceNo*NoYes
DELETERemove a resourceYesNoOptional
HEADSame as GET, no bodyYesYesNo
OPTIONSDescribe available methodsYesYesNo

*PATCH can be made idempotent with JSON Merge Patch (RFC 7396), but JSON Patch (RFC 6902) operations like "add to array" are not idempotent.

PUT vs PATCH

typescript
// PUT — replaces the entire resource
// Client must send ALL fields; missing fields are set to defaults
PUT /api/users/42
{
  "name": "Alice Chen",
  "email": "alice@example.com",
  "role": "admin",
  "preferences": { "theme": "dark", "locale": "en-US" }
}

// PATCH (JSON Merge Patch) — updates only specified fields
// Unmentioned fields remain unchanged
PATCH /api/users/42
Content-Type: application/merge-patch+json
{
  "role": "admin"
}

TIP

In practice, most "update" operations should use PATCH, not PUT. True full-resource replacement is rare and error-prone — a consumer who forgets a field will accidentally null it out.

Idempotency in Practice

Idempotency means calling the same operation multiple times produces the same result. This is critical for reliability — when a network timeout occurs, the client can safely retry an idempotent request.

typescript
// Idempotent: calling DELETE twice results in the same state
DELETE /api/orders/42200 OK (order deleted)
DELETE /api/orders/42404 Not Found (already deleted — same end state)

// Non-idempotent: calling POST twice creates duplicates
POST /api/orders { ... }  → 201 Created (order 100)
POST /api/orders { ... }  → 201 Created (order 101) ← duplicate!

To make POST idempotent, use an idempotency key:

typescript
POST /api/orders
Idempotency-Key: txn_abc123def456
Content-Type: application/json

{ "product_id": "prod_99", "quantity": 2 }

The server stores the response keyed by Idempotency-Key. If the same key is sent again, the server returns the stored response without re-executing the operation.

Richardson Maturity Model

Leonard Richardson defined four maturity levels for REST APIs. Most production APIs sit at Level 2.

Level 2 is the Sweet Spot

Most APIs should target Level 2. It gives you the full benefit of HTTP semantics (caching, idempotency, intermediary processing) without the complexity of full HATEOAS.

HATEOAS in Practice

HATEOAS (Hypermedia as the Engine of Application State) means responses include links that tell the consumer what actions are available next.

json
{
  "id": "order_42",
  "status": "pending",
  "total": 99.99,
  "_links": {
    "self": { "href": "/api/orders/42" },
    "cancel": { "href": "/api/orders/42/cancel", "method": "POST" },
    "payment": { "href": "/api/orders/42/payment", "method": "POST" },
    "items": { "href": "/api/orders/42/items" }
  }
}

After the order is paid, the cancel and payment links disappear, and a refund link appears. The client does not need to know the business rules — the API tells it what is possible.

TIP

HATEOAS is powerful but rarely implemented fully in practice. A pragmatic middle ground: include self links and links to related resources, but do not try to encode your entire state machine in hypermedia controls.

Status Codes

Use status codes correctly. They are the first thing consumers check and the primary mechanism for error handling.

Success Codes

CodeWhen to UseResponse Body
200 OKSuccessful GET, PUT, PATCH, DELETEThe resource or result
201 CreatedSuccessful POST that created a resourceThe created resource + Location header
202 AcceptedRequest accepted for async processingStatus endpoint URL or job ID
204 No ContentSuccessful DELETE or PUT with no body to returnEmpty

Client Error Codes

CodeWhen to Use
400 Bad RequestMalformed syntax, invalid JSON, validation errors
401 UnauthorizedMissing or invalid authentication credentials
403 ForbiddenAuthenticated but not authorized for this action
404 Not FoundResource does not exist
405 Method Not AllowedHTTP method not supported for this resource
409 ConflictState conflict (e.g., duplicate creation, version mismatch)
422 Unprocessable EntitySyntactically valid but semantically invalid (validation failed)
429 Too Many RequestsRate limit exceeded

Server Error Codes

CodeWhen to Use
500 Internal Server ErrorUnexpected server failure
502 Bad GatewayUpstream service returned invalid response
503 Service UnavailableServer overloaded or in maintenance
504 Gateway TimeoutUpstream service timed out

DANGER

Never return 200 OK with an error message in the body. This breaks every HTTP client library, every monitoring tool, and every consumer's error handling logic. If something went wrong, use a 4xx or 5xx status code.

Error Response Design (RFC 7807)

RFC 7807 (Problem Details for HTTP APIs) defines a standard error format that is machine-readable and human-friendly.

json
{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient Funds",
  "status": 422,
  "detail": "Account acc_123 has a balance of $10.00, but the transaction requires $25.00.",
  "instance": "/api/transactions/txn_456",
  "balance": 1000,
  "required": 2500,
  "currency": "USD"
}
FieldRequiredPurpose
typeYesURI identifying the error type (can be a documentation link)
titleYesShort, human-readable summary
statusYesHTTP status code (repeated for convenience)
detailNoHuman-readable explanation specific to this occurrence
instanceNoURI identifying this specific error occurrence
extensionsNoAdditional fields specific to the error type

Validation Errors

For validation errors, extend RFC 7807 with a structured errors array:

json
{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The request body contains 2 validation errors.",
  "errors": [
    {
      "field": "email",
      "code": "INVALID_FORMAT",
      "message": "Must be a valid email address"
    },
    {
      "field": "age",
      "code": "OUT_OF_RANGE",
      "message": "Must be between 18 and 120"
    }
  ]
}

TIP

Always include both a machine-readable code (for programmatic handling) and a human-readable message (for display or logging) in validation errors. Consumers should never have to parse free-text messages to determine error types.

Filtering, Sorting, and Field Selection

Filtering

Use query parameters for filtering. Keep the syntax simple and consistent.

GET /api/orders?status=shipped
GET /api/orders?status=shipped&created_after=2026-01-01
GET /api/orders?customer_id=42&status=pending,processing
GET /api/products?price_min=10&price_max=100

For complex filtering, consider a structured filter parameter:

GET /api/orders?filter[status]=shipped&filter[total_gte]=100

Sorting

Use a sort parameter with field names. Prefix with - for descending order.

GET /api/orders?sort=-created_at              # Newest first
GET /api/orders?sort=status,-created_at       # By status asc, then date desc

Field Selection

Let consumers request only the fields they need to reduce payload size.

GET /api/orders?fields=id,status,total
GET /api/orders?fields=id,status&expand=customer

Resource Expansion

Instead of forcing consumers to make multiple requests, allow inline expansion of related resources.

# Without expansion — requires 2 requests
GET /api/orders/42           → { "customer_id": "cust_7", ... }
GET /api/customers/cust_7   → { "name": "Alice", ... }

# With expansion — single request
GET /api/orders/42?expand=customer
→ {
    "id": "order_42",
    "customer": { "id": "cust_7", "name": "Alice", "email": "alice@example.com" },
    ...
  }

WARNING

Limit expansion depth. Allowing ?expand=customer.orders.items.product.category creates arbitrarily complex queries that can crush your database. Cap it at 1-2 levels.

Pagination

Every collection endpoint must be paginated. Returning unbounded result sets is a denial-of-service vulnerability waiting to happen.

See Pagination Patterns for a deep dive on offset vs cursor vs keyset pagination.

Quick example of cursor-based pagination:

json
{
  "data": [
    { "id": "order_100", "status": "shipped" },
    { "id": "order_99", "status": "pending" }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6Ijk5In0=",
    "has_more": true
  }
}

Bulk Operations

When consumers need to operate on multiple resources at once, provide explicit bulk endpoints rather than forcing them to make N individual requests.

typescript
// Bulk create
POST /api/orders/bulk
Content-Type: application/json

{
  "operations": [
    { "method": "create", "body": { "product_id": "prod_1", "quantity": 2 } },
    { "method": "create", "body": { "product_id": "prod_2", "quantity": 1 } }
  ]
}

// Response — per-item status
{
  "results": [
    { "index": 0, "status": 201, "body": { "id": "order_100" } },
    { "index": 1, "status": 422, "body": { "error": "Product prod_2 out of stock" } }
  ],
  "summary": { "succeeded": 1, "failed": 1 }
}

Request and Response Headers

Standard Request Headers

HeaderPurpose
Accept: application/jsonContent negotiation
Content-Type: application/jsonRequest body format
Authorization: Bearer <token>Authentication
Idempotency-Key: <uuid>Safe retries for POST
If-None-Match: <etag>Conditional GET (caching)
If-Match: <etag>Optimistic concurrency control

Standard Response Headers

HeaderPurpose
Content-Type: application/jsonResponse body format
Location: /api/orders/42URL of created resource (with 201)
ETag: "abc123"Resource version for caching
X-Request-Id: req_xyzCorrelation ID for debugging
X-RateLimit-Limit: 1000Rate limit ceiling
X-RateLimit-Remaining: 999Remaining requests in window
X-RateLimit-Reset: 1679961600When the window resets (Unix timestamp)
Retry-After: 30Seconds to wait (with 429 or 503)

Versioning

Every API changes. How you handle those changes determines whether your consumers trust you or fear you.

See API Versioning Strategies for the full treatment. The short version: use URL path versioning (/v2/orders) for simplicity, and maintain a clear deprecation policy.

Content Negotiation

# Request JSON (default)
GET /api/orders/42
Accept: application/json

# Request CSV export
GET /api/orders
Accept: text/csv

# Request specific API version via media type
GET /api/orders/42
Accept: application/vnd.myapp.v2+json

API Design Checklist

Use this checklist before shipping any new endpoint:

  • [ ] Resource name is a plural noun
  • [ ] HTTP method matches the operation semantics
  • [ ] Success status code is correct (201 for creation, 204 for deletion, etc.)
  • [ ] Error responses follow RFC 7807 format
  • [ ] Validation errors include field-level detail
  • [ ] Collection endpoints are paginated
  • [ ] Sensitive data is not leaked in responses
  • [ ] Idempotency key is supported for non-idempotent operations
  • [ ] Rate limiting headers are included
  • [ ] OpenAPI spec is updated

Further Reading

"What I cannot create, I do not understand." — Richard Feynman