REST API Design Principles That Stand the Test of Time
Great APIs feel boring in the best way — predictable, consistent, and easy to reason about. When the surface area is minimal and the behavior is obvious, teams ship faster, clients break less often, and on-boarding a new developer takes hours instead of days.
Bad APIs, on the other hand, are a tax that compounds over time. Every inconsistency becomes a bug waiting to happen. Every undocumented edge case turns into a 2 AM incident. And once clients are built against a broken contract, fixing it means a painful versioned migration.
This post is a deep dive into the principles I keep returning to every time I design a new API or review an existing one. Whether you're building an internal microservice or a public-facing developer platform, these ideas will save you (and your consumers) a significant amount of pain.
Why "Boring" Is a Compliment
There's a culture in software of rewarding cleverness. But in API design, cleverness is the enemy. When a developer hits your endpoint for the first time, they shouldn't have to read five pages of docs to figure out what it does. The behavior should be obvious from context.
Think about the APIs you genuinely enjoy using. Stripe's API is often cited as a gold standard — not because it does something magical, but because it is ruthlessly consistent. Every resource looks the same, every error has the same shape, and the docs are full of working examples. That consistency is a design choice, made deliberately and maintained carefully.
Boring = predictable. Predictable = trustworthy. Trustworthy = adopted.
Core Principles
1. Use Clear, Consistent Resource Names (Nouns, Not Verbs)
Your URL paths should describe things (resources), not actions. The HTTP method already carries the action.
# ❌ Bad — verbs leak into the URL GET /getUser/42 POST /createOrder POST /deleteProduct/7 # ✅ Good — nouns, HTTP method carries the intent GET /users/42 POST /orders DELETE /products/7
A good rule of thumb: if you can read the URL out loud and it sounds like a noun phrase ("users", "orders", "products"), you're probably on the right track.
Plurals vs. Singulars
Use plural nouns for collections (/users, /articles) and a specific identifier for a single resource (/users/42). This convention is widely adopted and immediately familiar to most developers.
GET /articles → list all articles GET /articles/slug-or-id → get a single article POST /articles → create a new article PATCH /articles/slug-or-id → partially update an article DELETE /articles/slug-or-id → delete an article
2. Respect HTTP Semantics
HTTP methods have well-defined semantics. Honoring them means your API integrates smoothly with proxies, caches, and tools that developers already know.
| Method | Meaning | Idempotent? | Safe? |
|---|---|---|---|
GET | Retrieve a resource | ✅ Yes | ✅ Yes |
POST | Create a new resource | ❌ No | ❌ No |
PUT | Replace a resource entirely | ✅ Yes | ❌ No |
PATCH | Partially update a resource | ✅ Yes* | ❌ No |
DELETE | Remove a resource | ✅ Yes | ❌ No |
Idempotent means calling the same request multiple times has the same effect as calling it once. This matters a lot for retries.
A common mistake is using POST for everything, or hiding destructive operations behind GET requests (which browsers and crawlers will happily follow). Both patterns create unpredictable behavior and make debugging a nightmare.
3. Return Consistent Response Shapes
Nothing erodes developer trust faster than an API where each endpoint returns a slightly different shape. Some wrap data, some don't. Some use data, others use result, others use payload. The consumer ends up writing defensive code full of ?. and fallback checks everywhere.
Pick a shape and stick to it across every endpoint:
// ✅ Consistent success response { "data": { ... }, "meta": { "requestId": "req_abc123", "timestamp": "2024-12-12T10:00:00Z" } } // ✅ Consistent error response { "error": { "code": "RESOURCE_NOT_FOUND", "message": "The article with id '99' does not exist.", "details": [] }, "meta": { "requestId": "req_abc124", "timestamp": "2024-12-12T10:00:01Z" } }
This is especially important for error handling. Every error, whether it's a 400, a 404, or a 500, should return a body with the same shape so clients can handle errors generically.
4. Prefer Sane Defaults with Optional Query Parameters
Your "happy path" should require the least amount of configuration. Don't force consumers to pass ?limit=20&sort=createdAt&order=desc on every request just to get a reasonable response. Pick sensible defaults and let query parameters override them.
# Sensible defaults — works without any params GET /articles # Overrides when needed GET /articles?limit=50&sort=title&order=asc&status=published
Document what those defaults are. "Pagination defaults to 20 items, sorted by createdAt descending" should be front and center in your docs.
A Practical Checklist
Use this during API design reviews or when auditing an existing API.
✅ URLs & Resources
- Paths use plural nouns (
/users,/orders) - No verbs in the path (the HTTP method is the verb)
- Nested resources are only one or two levels deep at most
- Relationships between resources are clearly modeled (e.g.,
/users/42/postsfor a user's posts)
✅ HTTP Methods & Status Codes
-
GETrequests are read-only and safe to repeat -
POSTcreates a resource and returns201 Createdwith the new resource -
PUT/PATCHreturns200 OKor204 No Content -
DELETEreturns204 No Contenton success -
404is returned when a resource doesn't exist — not200with an empty body -
401vs403is used correctly (unauthenticated vs unauthorized) -
422is used for validation errors, not400(which is for malformed requests)
A minimal but solid set of status codes to use consistently:
200 OK — successful GET, PATCH, PUT 201 Created — successful POST 204 No Content — successful DELETE or update with no body 400 Bad Request — malformed request (invalid JSON, missing required fields) 401 Unauthorized — authentication is required or failed 403 Forbidden — authenticated but not allowed 404 Not Found — resource doesn't exist 409 Conflict — state conflict (e.g., duplicate email) 422 Unprocessable — valid request format but failed validation 429 Too Many Req — rate limit exceeded 500 Server Error — something went wrong on the server
✅ Pagination
- All list endpoints are paginated (never return unbounded lists)
- Pagination style is documented (cursor-based vs offset-based)
- Response includes metadata about total count and navigation links
// Offset-based (simpler, good for UI with page numbers) { "data": [...], "pagination": { "page": 2, "perPage": 20, "total": 143, "totalPages": 8 } } // Cursor-based (better for high-volume, real-time data) { "data": [...], "pagination": { "nextCursor": "eyJpZCI6IDQyfQ==", "prevCursor": "eyJpZCI6IDIyfQ==", "hasMore": true } }
Cursor-based pagination is generally preferred for large datasets or feeds where items can be added or removed frequently — offset pagination can miss or duplicate items when the dataset changes between pages.
✅ Authentication & Authorization
- Auth mechanism is clearly documented (API key, Bearer token, OAuth2)
- Every endpoint explicitly states what permissions are required
- Sensitive operations require fresh tokens or additional confirmation
- API keys have scopes — consumers only get the access they need
# Example: clear auth requirements in docs GET /users → requires: read:users POST /users → requires: write:users DELETE /users/:id → requires: admin:users
✅ Versioning
- API version is included in the URL or a request header
- A deprecation policy exists and is communicated
- Old versions stay alive for a documented grace period
The two most common versioning strategies:
# URL versioning (most visible, easiest to test in browser) GET /v1/articles GET /v2/articles # Header versioning (cleaner URLs, slightly more opaque) GET /articles Accept: application/vnd.api+json;version=2
URL versioning wins in practice for most teams due to its simplicity and discoverability. Header versioning can be elegant but is harder to test and debug.
✅ Documentation & Examples
- Every endpoint has at least one working example (copy-paste ready)
- Request and response schemas are documented
- Common error scenarios are shown, not just the happy path
- A changelog exists for breaking changes
Handling Errors Gracefully
Error responses deserve as much care as success responses. When something goes wrong, the developer needs three things from your API:
- What happened — a human-readable message
- Why it happened — a machine-readable error code
- What to do about it — guidance or a link to docs
// ✅ Detailed, actionable error response { "error": { "code": "VALIDATION_ERROR", "message": "The request body contains invalid fields.", "details": [ { "field": "email", "issue": "Must be a valid email address.", "received": "not-an-email" }, { "field": "age", "issue": "Must be a positive integer.", "received": -5 } ], "docsUrl": "https://yourapi.dev/docs/errors#VALIDATION_ERROR" } }
Notice the details array — when validation fails, always return all the errors at once, not just the first one. Nothing is more frustrating than fixing one error only to be met with another.
Idempotency Keys for Safe Retries
Network failures happen. Clients retry requests. If your POST /orders endpoint is not idempotent by nature, two retries could create two orders. The solution is an idempotency key: a unique ID the client generates and sends with the request.
POST /orders Idempotency-Key: a8b3c9d2-4e5f-11ef-aa1b-0242ac130003 Content-Type: application/json { "productId": "prod_xyz", "quantity": 1 }
Your server stores the key alongside the result. If the same key comes in again within a time window, return the stored result instead of executing the operation again. Stripe uses this pattern extensively and it's worth adopting for any mutation that has real-world side effects (payments, emails, orders).
A Note on API Versioning and Breaking Changes
The hardest discipline in API design is not breaking things. Once clients depend on a contract, changing it silently is a form of betrayal. Classify every change clearly:
Non-breaking (backwards-compatible):
- Adding a new optional field to a response
- Adding a new optional query parameter
- Adding a new endpoint
- Expanding an enum with new values (with caution)
Breaking (requires a new version):
- Renaming or removing a field
- Changing a field's type (e.g.,
string→number) - Changing the URL path structure
- Changing HTTP method semantics
- Removing an endpoint
When you do make a breaking change, give consumers a sunset date, add a Deprecation header to responses from the old version, and document the migration path clearly.
Wrap-Up
If you optimize for consistency first, everything else becomes easier. Documentation writes itself because patterns repeat. Tests are simpler because behavior is predictable. Consumers onboard faster because the API behaves the way they expect.
The best APIs aren't built in a single inspired afternoon — they're the result of deliberate review, honest feedback, and a willingness to say "let's make this boring and obvious" instead of "let's make this clever."
Use this post as a living reference. Bookmark the checklist, run your next API through it, and push back on shortcuts that feel convenient now but create confusion later. Your future self — and every developer who integrates with your API — will thank you.
If you found this useful, feel free to share it or reach out — I'm always happy to talk API design.

