REST is not a religion. It’s a set of constraints that, when applied thoughtfully, produces APIs that age well — clients can build mental models from them, debugging is straightforward, and the system stays understandable as it grows. When applied dogmatically, REST produces APIs that are obnoxious to use.

This post is about the middle ground. Practical guidance for designing APIs that work in production, drawn from too many years of building and consuming them.

The core idea

A REST API exposes resources. Resources have URLs. You operate on them with HTTP methods. You exchange representations (usually JSON). The HTTP standard does most of the work — your job is to map your domain to it cleanly.

The opposite is RPC: “call this function with these args.” RPC has its place (gRPC, GraphQL), but for most external HTTP APIs, REST hits the right balance of expressiveness and convention.

Resource modeling: nouns, not verbs

Bad:

GET  /getUserById?id=42
POST /createPost
POST /deletePostById
POST /publishPost

Good:

GET    /users/42
POST   /posts
DELETE /posts/{id}
POST   /posts/{id}:publish      # action on a resource

A resource URL is a noun. The HTTP method is the verb. Don’t put the verb in the URL.

For “actions” that don’t fit cleanly — :publish, :cancel, :lock — Google’s API design guide popularized the colon-suffix convention. Pragmatic; better than POST /publish-post.

Plural collections, singular instances

/users           # the collection of users
/users/42        # one user
/users/42/posts  # this user's posts (sub-collection)

Always plural at the collection level. Always plural even if there’s only one instance — /users/42 is one user from the users collection.

HTTP method semantics

MethodIdempotentSafeBodyUse for
GETYesYesNoRead
HEADYesYesNoRead metadata only
POSTNoNoYesCreate / non-idempotent action
PUTYesNoYesReplace (full resource)
PATCHNo (technically)NoYesPartial update
DELETEYesNoNoRemove

Idempotent means: the same request can be sent N times with the same effect as sending it once. Safe means: doesn’t change server state.

Get these right and clients can retry safely:

  • A network blip during a PUT? Retry.
  • A timeout on a DELETE? Retry.
  • A timeout on a POST to create something? Don’t retry blindly; use idempotency keys (below).

Status codes that matter

You don’t need all 50+ HTTP status codes. You need maybe a dozen, used consistently:

2xx — success

  • 200 OK — generic success.
  • 201 Created — successful resource creation. Set Location header to the new resource.
  • 202 Accepted — request accepted but not yet processed (async work).
  • 204 No Content — success, no body. Common for DELETE responses.

3xx — redirection

  • 301 Moved Permanently — for true API URL changes (rare and dangerous).
  • 304 Not Modified — if you support ETag/If-None-Match for caching.

4xx — client errors

  • 400 Bad Request — malformed request (invalid JSON, missing required field structure-wise).
  • 401 Unauthorized — no/invalid credentials.
  • 403 Forbidden — authenticated but not allowed.
  • 404 Not Found — resource doesn’t exist (or shouldn’t be revealed to exist).
  • 405 Method Not Allowed — wrong method for the URL.
  • 409 Conflict — version conflict, duplicate, etc.
  • 422 Unprocessable Entity — semantically invalid (validation errors).
  • 429 Too Many Requests — rate limited.

5xx — server errors

  • 500 Internal Server Error — unexpected failure.
  • 502 Bad Gateway — upstream server returned bad response.
  • 503 Service Unavailable — overloaded or down for maintenance.
  • 504 Gateway Timeout — upstream server timed out.

Structured error responses

When you return an error, return a structured one. The de facto standard is RFC 7807 / RFC 9457 (application/problem+json):

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type":   "https://example.com/probs/validation",
  "title":  "Validation failed",
  "status": 422,
  "detail": "Email is required and must be valid",
  "instance": "/users",
  "errors": [
    {"field": "email",   "code": "required"},
    {"field": "age",     "code": "min", "limit": 18}
  ]
}

A simpler in-house format also works fine; the important thing is be consistent across the entire API:

  • A machine-readable error code ("validation_failed").
  • A human-readable message.
  • Per-field errors when relevant.

If your error format changes between endpoints, clients will hate you.

Pagination

Two strategies. Pick one.

Offset-based

GET /posts?page=3&page_size=20

Returns:

{
  "data": [ /* 20 items */ ],
  "page": 3,
  "page_size": 20,
  "total": 1247
}

Pros: simple, supports “jump to page N”. Cons: unstable (inserts/deletes shift pages); slow on huge tables (OFFSET 100000 is expensive in SQL).

GET /posts?cursor=eyJpZCI6MTIzfQ&limit=20

Returns:

{
  "data": [ /* 20 items */ ],
  "next_cursor": "eyJpZCI6MTQzfQ",
  "prev_cursor": null
}

The cursor is an opaque token (often a base64-encoded {id, timestamp}). Pages are stable across inserts and fast on big tables. Lose the ability to jump to “page 50”.

For most APIs, cursor-based wins. For admin dashboards where users want to navigate, offset is fine.

Filtering, sorting, sparse fieldsets

GET /posts?status=published&author_id=42&sort=-created_at&fields=id,title,created_at
  • ?key=value for simple equality filters.
  • sort=-field for descending, sort=field for ascending. Comma-separate multiple.
  • fields= to let clients ask for less data — useful for mobile.
  • For complex filters, JSON in a ?filter= param or a separate POST /search.

Idempotency keys for unsafe writes

POST /payments is not idempotent — retrying could double-charge. The standard fix: let the client pass an Idempotency-Key header:

POST /payments
Idempotency-Key: 2c8a4a6b-9f3e-4f1c-bb88-5dceaa9b8311
Content-Type: application/json

{ "amount": 5000, "currency": "USD" }

Store the key + response on the server. If the same key arrives again within (e.g.) 24 hours, return the original response — don’t re-execute.

Stripe popularized this pattern. Use it for any POST where retries could cause harm.

Versioning: pick a strategy and commit

Three common approaches:

URL versioning (most common, simplest)

/v1/users
/v2/users

Clear, easy to route, easy to deprecate. Version per major change.

Header versioning

GET /users
Accept: application/vnd.example.v2+json

“Cleaner” URLs. Harder to inspect in logs, harder to test in curl. Generally not worth the friction.

No versioning

Just be additive. Don’t break things. New fields are okay; removing fields requires deprecation.

For APIs with millions of clients, additive evolution is what large platforms (GitHub, Stripe) actually do — explicit versions are reserved for very breaking changes. For a small API, URL versioning is the simplest path that buys you a future.

Authentication

  • Bearer tokens in Authorization: Bearer <token> — works for both session tokens and JWTs.
  • API keys in a header (Authorization or X-API-Key). Don’t put them in URLs (logs, browser history, referrer leaks).
  • OAuth 2 for third-party integrations.

Document exactly which endpoints are public, which require auth, and which need elevated permissions. This is one of the most common docs gaps.

For implementation, see JWT Authentication in FastAPI .

Designing for change

The single biggest test of an API: how does it look in 5 years?

  • Add fields freely. Clients should ignore unknown fields.
  • Don’t remove fields without a deprecation period. Even unused fields. Clients depend on what they think exists.
  • Don’t change semantics silently. A 200 that suddenly means something else is a footgun.
  • Document deprecations with Deprecation and Sunset headers + a clear migration path.
  • Avoid magic enum changes. Adding a new enum value can crash clients that switch on the enum exhaustively.

A few more rules I’d die on

  • Use ISO 8601 for timestamps (2026-04-28T15:30:00Z). Always UTC. Never Unix epoch in user-facing fields unless you’re sure.
  • Use snake_case consistently for JSON fields. Or camelCase consistently. Pick one. Don’t mix.
  • Plural for arrays. tags not tag.
  • Booleans for binary state, enums for everything else. is_active: true is fine; status: "active" | "pending" | "suspended" ages better.
  • Don’t expose internal IDs you don’t control. UUIDs are fine; auto-incrementing primary keys leak how many records you have.
  • Document with OpenAPI and check it into version control. Generated docs are better than written docs that go stale.

Testing your API

  • Contract tests — schema validation that catches accidental breaking changes.
  • Integration tests that hit real DB → real API → real responses. See Testing FastAPI Apps .
  • Documented examples — every endpoint in the docs should have a working request/response example.

When REST isn’t the right answer

REST is great for resource-oriented APIs. It’s awkward for:

  • Real-time updates — websockets or SSE, not polling REST.
  • Rich querying with deeply nested data — GraphQL.
  • Internal microservice RPC — gRPC.
  • File uploads/downloads — REST works but feels heavy; consider direct-to-S3 with presigned URLs.

Don’t force REST onto problems it doesn’t fit. Use the right protocol for the job.

Conclusion

Good REST API design is mostly about consistency and respecting HTTP. Use the right method for the right semantics. Return the right status codes. Structure your errors. Pick one pagination, one auth, one versioning, one error format — and stick with all of them across every endpoint.

When you do that, your API stays simple to reason about even at thousands of endpoints. When you don’t, every endpoint is a snowflake — and your docs become a graveyard of “well, this one is different because…”

For implementation specifics, see Building a REST API with Django REST Framework , Getting Started with FastAPI , and Building a REST API in Go with net/http .

Happy designing!


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .