REST Isn't a Standard — It's a Set of Conventions
There's no RFC for REST APIs. What we call "REST" in practice is really a collection of conventions built on top of HTTP. Some teams follow them religiously; others treat them as loose suggestions. I've found that consistency matters more than purity — pick conventions and stick to them across your entire API surface.
URL Structure: Nouns, Not Verbs
URLs should represent resources (nouns), not actions (verbs). The HTTP method provides the action.
# Good
GET /api/v2/users/42
POST /api/v2/users
PUT /api/v2/users/42
DELETE /api/v2/users/42
# Bad
GET /api/getUser?id=42
POST /api/createUser
POST /api/deleteUser/42
Use plural nouns for collections: /users, not /user. Nested resources should reflect the ownership relationship: /users/42/orders/7 means "order 7 belonging to user 42."
Keep nesting shallow — two levels max. Deep nesting like /users/42/orders/7/items/3/reviews is hard to read and indicates your API might benefit from flattening: /order-items/3/reviews with a query parameter for filtering.
Naming Conventions
Use kebab-case for URL paths: /user-profiles, not /userProfiles or /user_profiles. URLs are case-insensitive by convention, and kebab-case reads best in a browser bar.
For query parameters, camelCase is most common in JSON APIs: /users?sortBy=createdAt&pageSize=20. Match whatever casing your JSON response bodies use — consistency between query params and response fields reduces cognitive load for API consumers.
HTTP Methods and Their Semantics
GET retrieves a resource. Must be safe (no side effects) and idempotent (calling it 10 times gives the same result as calling it once). Never use GET to modify data.
POST creates a new resource. Not idempotent — calling it twice creates two resources. Return 201 Created with a Location header pointing to the new resource.
PUT replaces a resource entirely. It's idempotent — putting the same data twice results in the same state. Send the complete representation, not just changed fields.
PATCH partially updates a resource. Send only the fields you want to change. Whether PATCH is idempotent depends on your implementation. Using JSON Merge Patch (RFC 7396) makes it idempotent; using JSON Patch (RFC 6902) operations might not be.
DELETE removes a resource. Should be idempotent — deleting an already-deleted resource returns 204 or 404, but the server state is the same.
Error Handling That Doesn't Make Developers Cry
Nothing kills developer experience faster than unhelpful error responses. "Internal Server Error" tells the API consumer nothing. "Validation failed" without specifying which field is almost as useless.
A Consistent Error Format
Pick a format and use it everywhere. Here's one that works well in practice:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be between 13 and 150",
"value": -5
}
],
"request_id": "req_abc123"
}
}
The request_id field is worth its weight in gold for debugging. When a customer reports an error, you can trace the exact request through your logs.
Status Code Selection
Don't overthink it. Here are the codes you'll use 95% of the time:
- 200 — Success (GET, PUT, PATCH)
- 201 — Created (POST when a resource is created)
- 204 — No Content (DELETE, or PUT/PATCH when you don't return a body)
- 400 — Bad Request (malformed syntax, invalid values)
- 401 — Unauthorized (no credentials or expired token)
- 403 — Forbidden (valid credentials but insufficient permissions)
- 404 — Not Found
- 409 — Conflict (duplicate resource, version mismatch)
- 422 — Unprocessable Entity (valid syntax but semantic errors)
- 429 — Too Many Requests (rate limited)
- 500 — Internal Server Error (your bug, not the client's)
The 400 vs 422 distinction trips people up. I use 400 for malformed JSON or missing required fields (the request is structurally wrong) and 422 for business logic validation failures (the structure is fine but the values don't make sense). Some teams just use 400 for everything — that's fine too.
API Versioning Strategies
Your API will change. The question is how to handle breaking changes without breaking existing clients.
URL Path Versioning
GET /api/v1/users
GET /api/v2/users
This is the most common approach and I'd recommend it for most teams. It's explicit, easy to understand, and you can run multiple versions simultaneously. The downside is URL pollution — your routing layer needs to handle multiple version prefixes.
Header Versioning
GET /api/users
Accept: application/vnd.myapp.v2+json
Cleaner URLs, but versions are invisible in browser address bars and harder to test with simple tools like curl. GitHub's API uses this approach and it works well at their scale.
Query Parameter Versioning
GET /api/users?version=2
Easy to implement but muddles the query string. I'd avoid this one — query parameters should filter and modify the response, not change the API contract.
When to Version
Not every change needs a new version. Additive changes — new optional fields in responses, new optional query parameters, new endpoints — are backward compatible. Only bump the version for breaking changes: removing fields, changing field types, altering endpoint behavior.
In practice, I'd argue you should aim to never release a v3. If you're on v5, something went wrong with your initial design. Use v1 as long as possible, then v2 for a major overhaul, and ideally stay there.
Pagination
Any endpoint that returns a collection needs pagination. There are two common approaches.
Offset-based: /users?offset=20&limit=10. Simple to implement. The problem: if new records are inserted while someone is paginating, they'll see duplicates or miss items. Also, OFFSET 100000 is slow in most databases.
Cursor-based: /users?cursor=eyJpZCI6NDJ9&limit=10. The cursor is an opaque token (usually a base64-encoded value of the last item's sort key). More complex to implement but consistent results even with concurrent writes, and no performance degradation on deep pages.
For public APIs, cursor-based pagination is worth the extra effort. For internal APIs with small datasets, offset is fine.
Content Negotiation and Response Format
JSON is the default. Use Content-Type: application/json for requests and Accept: application/json for responses. If you need to support other formats (XML, CSV), use content negotiation headers rather than different endpoints.
Wrap your responses consistently. I prefer this structure:
# Single resource
{
"data": { "id": 42, "name": "Alice", "email": "alice@example.com" }
}
# Collection
{
"data": [ ... ],
"pagination": {
"cursor": "eyJpZCI6MTAwfQ==",
"has_more": true
}
}
# Empty collection
{
"data": [],
"pagination": { "has_more": false }
}
The wrapper object gives you a place to add metadata (pagination, rate limit info, deprecation warnings) without modifying the resource structure. Some teams return bare arrays for collections — it works but limits your options for adding metadata later.