DEV Community

REST API Design Best Practices: A Practical Guide for 2026

Resource-Oriented Naming - Not Action-Oriented

The single biggest smell in a REST API is action verbs in URLs:

# Bad - these are RPC, not REST
GET /api/getUser?id=42
POST /api/createUser
POST /api/deleteUser/42
POST /api/activateUserSubscription

Resources are nouns, not verbs. The HTTP method is the verb:

# Good
GET /api/users/42
POST /api/users
DELETE /api/users/42
POST /api/users/42/subscriptions          # nested resource
DELETE /api/users/42/subscriptions/active

Key conventions that have held across every production API I've consulted on:

  • Plural nouns: /users, not /user. Consistency with list endpoints (GET /users = a list) makes singular feel like a bug.
  • Kebab-case for multi-word resources: /order-items, not /orderItems or /order_items. It's URL-safe and matches what browsers expect.
  • Nest at most two levels: /users/42/orders/7 is fine. /users/42/orders/7/items/3/addresses/9 is a cry for help. At that point, use a query parameter: /items?order_id=7.
  • Use query params for filtering, not path segments: /users?status=active&role=admin, not /users/active/admins.

Consistent Error Responses - The Contract People Actually Rely On

Most API errors are parsable only by humans staring at a screen. That's a bug. Every error response should follow the same schema so clients can handle them programmatically:

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User with id 42 was not found.",
    "details": {
      "resource": "users",
      "identifier": "42"
    },
    "request_id": "req_a1b2c3d4"
  }
}

The code field is the key - it's a machine-readable string clients can switch on. Never change the meaning of a code after release. The request_id is a trace identifier your logs and your users' bug reports can share, so debugging a production error doesn't start with "which request was that."

Here's a FastAPI middleware that enforces this consistently:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

class APIException(Exception):
    def __init__(self, code: str, message: str, status: int, details: dict = None):
        self.code = code
        self.message = message
        self.status = status
        self.details = details or {}

@app.exception_handler(APIException)
async def api_error_handler(request: Request, exc: APIException):
    return JSONResponse(
        status_code=exc.status,
        content={
            "error": {
                "code": exc.code,
                "message": exc.message,
                "details": exc.details,
                "request_id": request.headers.get("x-request-id", "unknown"),
            }
        },
    )

With this in place, your HTTP status codes still mean what HTTP says they mean (4xx vs 5xx), but the code field gives clients an actionable discriminator. Stripe pioneered this pattern and it's stood the test of a decade.

Pagination That Doesn't Surprise

Cursor-based pagination beats offset-based for production APIs. Here's why: if a new record is inserted before page 2, offset pagination shifts every subsequent page - users see duplicates or miss records.

Cursor pagination uses a stable pointer:

GET /users?cursor=eyJpZCI6IDQyfQ==&limit=20

Response:

{
  "data": [ ... ],
  "pagination": {
    "next_cursor": "eyJpZCI6IDg0fQ==",
    "has_more": true,
    "limit": 20
  }
}

The cursor is an opaque base64-encoded token, usually containing the last seen record's ID and a timestamp. Clients don't decode it - just pass it to the next request.

Implementation is straightforward with SQL's WHERE id > :cursor ORDER BY id pattern:

SELECT id, name, email FROM users
WHERE id > :cursor
ORDER BY id ASC
LIMIT 21  -- fetch one extra to detect has_more

The extra row tells you has_more = rows_returned == limit + 1. Drop that row from the response and encode its id as the next cursor. This pattern handles millions of rows without the performance cliff that OFFSET hits past page 50.

API Versioning - Accept or Header, Not URL Path

The old orthodoxy was /api/v1/users. This commits you to maintaining parallel URL trees forever. A better approach: use the Accept header (content negotiation):

# Request
GET /api/users/42
Accept: application/vnd.devopsdaily.v2+json

# Response
Content-Type: application/vnd.devopsdaily.v2+json

Why this wins: the same URL always works. New clients get v2, old clients get v1, your infrastructure stays simple. When v3 ships, you don't redeploy - you update a default-version config. Stripe and GitHub both moved to this model for good reason.

The practical downside is discoverability - a developer can't glance at the URL and see "this is v2." Mitigate by documenting version defaults in your API reference and returning the version in response headers:

X-API-Version: 2
Deprecated: true
Sunset: Sat, 19 Dec 2026 00:00:00 GMT

If you absolutely must use URL versioning (perhaps your consumers are curl-in-a-shell-script types), stick to the simple /v1/ prefix - but deprecate it in favor of the Accept header on your next major release.

Rate Limiting That Tells You When to Retry

Rate limiting without feedback headers is just annoying. Every response - successful or not - should carry:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 483
X-RateLimit-Reset: 1721414400

And when the client exceeds the limit:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after 42 seconds.",
    "details": {
      "limit": 1000,
      "reset_at": "2026-07-19T14:00:00Z"
    }
  }
}

The Retry-After header is the most important part - without it, clients have to guess, and they'll either hammer you (making things worse) or back off too conservatively (starving your own metrics).

If you're using a token bucket algorithm (and you should be), the reset time is simply the bucket's next refill tick. Here's a minimal implementation using sliding window counters in Python - no external dependency:

import time
from collections import defaultdict

class SlidingWindowRateLimiter:
    def __init__(self, max_requests: int, window_seconds: int = 60):
        self.max_requests = max_requests
        self.window = window_seconds
        self.requests = defaultdict(list)  # key -> [timestamps]

    def check(self, key: str) -> tuple[bool, int, int]:
        now = time.time()
        cutoff = now - self.window
        self.requests[key] = [t for t in self.requests[key] if t > cutoff]
        remaining = self.max_requests - len(self.requests[key])
        if remaining > 0:
            self.requests[key].append(now)
            return True, remaining, int(cutoff + self.window)
        return False, 0, int(cutoff + self.window)

For production, swap this for Redis-backed counters - but the API contract stays identical.

Idempotency and Safe Retries

Network failures happen. When a client sends POST /api/payments and gets a timeout, was the payment charged or not? Without idempotency, the safe answer is "I don't know" - which is the wrong answer.

Use idempotency keys. The client generates a unique key (UUID) and sends it on every mutation request:

POST /api/payments
Idempotency-Key: 7c7a3e81-3a1e-4b1e-8e1e-3e1e7c7a3e81
Content-Type: application/json

{
  "amount": 2999,
  "currency": "usd",
  "source": "tok_visa"
}

The server caches the response keyed by (Idempotency-Key, endpoint). If it sees the same key again, it returns the cached response instead of executing the operation twice:

# Fast enough for most use cases - a simple dict
# For production: Redis with TTL matching your max retry window
idempotency_store: dict[str, dict] = {}

@app.post("/api/payments")
async def create_payment(request: Request):
    idem_key = request.headers.get("Idempotency-Key")
    if not idem_key:
        raise APIException("IDEMPOTENCY_KEY_REQUIRED", ...)
    cached = idempotency_store.get(idem_key)
    if cached:
        return JSONResponse(content=cached, status_code=200)
    result = await process_payment(request.json())
    idempotency_store[idem_key] = result
    return JSONResponse(content=result, status_code=201)

Stripe's idempotency layer handles billions of dollars. The pattern has been battle-tested. Copy it.

Putting It All Together

A well-designed REST API is a contract. The six practices above - resource naming, structured errors, cursor pagination, header-based versioning, informative rate limits, and idempotency keys - form the foundation of APIs that developers enjoy integrating with.

The next time you're building an endpoint, skip the framework boilerplate and start with these. Your consumers (and your on-call rotation) will thank you.

Comments

No comments yet. Start the discussion.