How to Design REST APIs Developers Love 2026
How to Design REST APIs Developers Love 2026
The best APIs share a quality: developers figure them out without reading the docs. Resource naming is intuitive. Error messages explain what went wrong and how to fix it. Pagination works consistently. Authentication is a single header. Here's how to design APIs that developers reach for.
TL;DR
- Use plural nouns as resource names, HTTP methods as verbs, and keep URL hierarchy to a maximum of 3 levels — deep nesting signals a design problem
- Return the right status code: 201 for created resources (not 200), 422 for validation failures (not 400), 409 for conflicts, and 410 Gone when resources were deleted and won't come back
- Error responses need machine-readable codes, field-level validation details, a request ID, and a documentation URL — every one of Stripe's error fields exists because customers needed it
- Wrap responses consistently: either always use
{data: ..., meta: ...}or always return flat objects — mixing envelope styles across endpoints is the most common DX complaint in API design reviews - PATCH semantics require a decision: JSON Merge Patch (RFC 7396) is simpler and covers 90% of use cases; use it
1. Resource Naming
Use Nouns, Not Verbs
Resources are things, not actions. HTTP methods provide the verbs.
✅ GET /users → List users
✅ POST /users → Create user
✅ GET /users/123 → Get user
✅ PUT /users/123 → Update user
✅ DELETE /users/123 → Delete user
❌ GET /getUsers
❌ POST /createUser
❌ POST /deleteUser/123
Use Plural Nouns
Consistency matters more than grammar debates. Use plurals everywhere.
✅ /users
✅ /orders
✅ /products
❌ /user
❌ /order/123 (mixing singular and plural)
Nest for Relationships
Sub-resources express relationships. Keep nesting to 2 levels max.
✅ GET /users/123/orders → User's orders
✅ GET /users/123/orders/456 → Specific order
❌ GET /users/123/orders/456/items/789/reviews → Too deep
Use kebab-case
✅ /api/order-items
✅ /api/user-profiles
❌ /api/orderItems
❌ /api/order_items
2. HTTP Methods and Status Codes
Methods
| Method | Purpose | Idempotent | Safe |
|---|---|---|---|
| GET | Read resource(s) | Yes | Yes |
| POST | Create resource | No | No |
| PUT | Replace resource (full) | Yes | No |
| PATCH | Update resource (partial) | No | No |
| DELETE | Remove resource | Yes | No |
Status Codes
Use specific codes. Don't return 200 for everything.
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, DELETE |
| 201 | Created | Successful POST (include Location header) |
| 204 | No Content | Successful DELETE with no response body |
| 400 | Bad Request | Invalid input (validation errors) |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource, state conflict |
| 422 | Unprocessable Entity | Semantically invalid input |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server failure |
3. Error Handling
The Error Response
Every error should include a machine-readable code, human-readable message, and actionable detail.
{
"error": {
"code": "validation_error",
"message": "The request body contains invalid fields.",
"details": [
{
"field": "email",
"code": "invalid_format",
"message": "Must be a valid email address."
},
{
"field": "age",
"code": "out_of_range",
"message": "Must be between 13 and 120."
}
],
"request_id": "req_abc123",
"documentation_url": "https://api.example.com/docs/errors#validation_error"
}
}
Error Design Rules
- Machine-readable codes —
validation_error,not_found,rate_limit_exceeded - Human-readable messages — explain what happened in plain English
- Field-level details — for validation errors, tell the client which fields failed and why
- Request ID — for debugging and support tickets
- Documentation link — point to docs explaining the error
4. Pagination
Cursor-Based (Recommended)
{
"data": [...],
"pagination": {
"has_more": true,
"next_cursor": "eyJpZCI6MTIzfQ=="
}
}
Request: GET /users?limit=20&cursor=eyJpZCI6MTIzfQ==
Pros: Consistent results when data changes. Performs well at any depth. Cons: Can't jump to page 5 directly. Can't show "page 3 of 10."
Offset-Based
{
"data": [...],
"pagination": {
"total": 500,
"limit": 20,
"offset": 40,
"has_more": true
}
}
Request: GET /users?limit=20&offset=40
Pros: Simple. Can jump to any page. Can show total count. Cons: Inconsistent results if data changes between pages. Slow at deep offsets (OFFSET 10000).
Recommendation: Cursor-based for most APIs. Offset-based only when random access is required. See our deep-dive on pagination patterns for implementation details.
5. Filtering and Sorting
GET /users?status=active&role=admin → Filter
GET /users?sort=created_at&order=desc → Sort
GET /users?fields=id,name,email → Sparse fields
GET /users?search=john → Search
Keep It Consistent
- Use the same parameter names across all endpoints
sortfor field name,orderfor direction (asc/desc)limitandcursor/offsetfor paginationfieldsfor sparse fieldsetssearchfor full-text search
6. Versioning
Start with URL path versioning: /api/v1/users. It's the most understood approach.
Rules:
- Add fields without incrementing version (additive changes are not breaking)
- Increment version for breaking changes (field removal, type changes, behavior changes)
- Support old versions for 12+ months after deprecation announcement
- Document what constitutes a "breaking change" for your API
For a detailed treatment of versioning strategies, see our guide on API breaking changes without breaking clients.
7. Authentication
Use Authorization: Bearer <token> header. Don't put tokens in URLs (they leak in logs, referrers, and browser history).
✅ Authorization: Bearer sk_live_abc123
❌ GET /users?api_key=sk_live_abc123
8. Response Envelope
Consistent Wrapping
Wrap all responses in a consistent envelope:
{
"data": { ... },
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-03-08T12:00:00Z"
}
}
For lists:
{
"data": [ ... ],
"pagination": { ... },
"meta": { ... }
}
URL Design That Doesn't Age
URL design decisions outlive the code that implements them. Clients build integrations against your URL structure, documentation sites link to it, support tickets reference it. Getting this right upfront avoids painful migration work later.
The two-level nesting limit is a practical constraint, not an aesthetic one. URLs like /api/v1/organizations/123/projects/456/tasks/789 are technically valid but create implementation problems. You're forced to validate ownership at every level (does project 456 belong to org 123?), authorization logic becomes deeply nested, and adding a new parent resource in the future requires URL changes. When you need to access a task, flat access with filtering (GET /tasks?project_id=456) is often cleaner than deep nesting.
The path parameter vs query parameter decision follows a simple rule: path parameters identify specific resources, query parameters filter or modify the representation. GET /users/123 — 123 is a path parameter identifying a specific user. GET /users?role=admin — role=admin is a query parameter filtering the collection. Misuse is common: GET /users/active looks like it identifies a specific user named "active" but is actually filtering by status — this should be GET /users?status=active. Keeping this distinction clean prevents confusing URL semantics that trip up API consumers.
Actions that don't map cleanly to CRUD operations need a convention. The approach that ages best: use a noun-based sub-resource for state transitions. POST /invoices/123/payment (create a payment on an invoice) is cleaner than POST /invoices/123/pay (verb in URL). POST /subscriptions/123/cancellation is cleaner than POST /subscriptions/123/cancel. This keeps URLs noun-based while handling lifecycle transitions naturally.
For query parameter conventions: use comma-separated values for multi-value filters (?status=active,pending) or repeated parameters (?status=active&status=pending) — document which approach you use and be consistent. Range parameters work well as ?created_after=2026-01-01&created_before=2026-03-01. Use ISO 8601 timestamps everywhere — never Unix timestamps in query parameters.
HTTP Status Codes Developers Expect
The status codes that cause the most confusion in practice are the 4xx codes where the right choice isn't obvious.
200 vs 201 vs 202: Return 201 Created (not 200) when a resource is created via POST, and include a Location header pointing to the new resource. Return 202 Accepted for asynchronous operations that are queued but not yet complete — the response body should include a status URL or job ID for polling. Return 200 for all other successful operations.
400 vs 422: Use 400 Bad Request for requests that are malformed at the HTTP level — invalid JSON, missing Content-Type header, request body too large. Use 422 Unprocessable Entity for requests that are valid HTTP but fail business logic validation — an email field with an invalid format, a date range where end is before start, a reference to a non-existent resource. Stripe uses 400 for both, which is a common simplification. The distinction matters when clients need to differentiate "my JSON is broken" from "my data is invalid" — use 422 if you want to give clients that signal.
401 vs 403: 401 Unauthorized means the request lacks valid authentication credentials — the client is not authenticated. 403 Forbidden means the client is authenticated but doesn't have permission to access the resource. Returning 404 instead of 403 when you want to hide the existence of a resource is a valid security practice — an unauthenticated user shouldn't know whether resource 123 exists, only that they can't access it.
404 vs 410: 404 Not Found means the resource doesn't exist (or you're hiding it). 410 Gone is the semantically correct code when a resource existed previously but has been permanently deleted. Clients and search engines treat 410 differently — it signals "stop requesting this URL." For APIs that permanently delete resources, consider returning 410 with a body explaining when the resource was deleted.
500 vs 503: Return 500 Internal Server Error for unexpected application errors — unhandled exceptions, database failures, unexpected nil pointers. Return 503 Service Unavailable when your API is intentionally down — maintenance windows, deployment pauses, dependency failures you're aware of. 503 should include a Retry-After header telling clients when to try again. The distinction matters for monitoring: a spike in 500s means your code has bugs; a 503 is expected during planned maintenance.
Error Response Design
The Stripe error format is the gold standard because every field solves a real problem that emerged from years of developer support. Copying it (or improving on it) is better than inventing your own.
The code field must be machine-readable and stable. Strings like validation_error, resource_not_found, rate_limit_exceeded are values that client code can switch on without parsing human-readable messages. The message can change; the code should not. Document every possible error code in your API reference.
Field-level errors are critical for form validation workflows. When a developer is building a registration form and the user submits invalid data, they need to know which field failed and why. A top-level "message": "Validation failed" forces the client to either guess or show a generic error. A details array with per-field codes allows displaying inline error messages next to each field. Always return all validation errors at once rather than stopping at the first failure — forcing users to fix one error at a time is bad UX.
The request_id field solves a specific support workflow. When a developer contacts you with "I got an error," you need to be able to look up the exact request in your logs. Without a request ID, you're searching by timestamp and IP address. With a request ID, you can pull up the exact request, its parameters, and what failed. Include a request ID in every response (success and error) and document how to find it and include it in support tickets.
The documentation_url field is the highest-leverage DX improvement on this list. Linking from an error directly to the documentation for that specific error code eliminates the most common support query pattern ("I got this error, what does it mean?"). Every error code should have a dedicated documentation page explaining the code, what triggers it, and how to resolve it.
Envelope vs No Envelope Debate
The question of whether to wrap API responses in a container object ({data: ..., meta: ...}) or return resources directly has no definitively correct answer. Both approaches are in production at major API providers.
Stripe returns objects directly:
{
"id": "cus_abc123",
"object": "customer",
"email": "user@example.com",
"created": 1234567890
}
GitHub's REST API uses a flat array for lists:
[
{ "id": 1, "name": "repo-one" },
{ "id": 2, "name": "repo-two" }
]
JSON:API uses a structured envelope:
{
"data": {
"type": "customers",
"id": "abc123",
"attributes": { "email": "user@example.com" }
},
"meta": { "request_id": "req_xyz" }
}
The practical argument for envelopes: they allow adding metadata (pagination, rate limit info, request IDs) without changing the resource schema. {data: [...], pagination: {...}} is cleaner than HTTP headers for pagination. The argument against: they add a level of nesting that client code must always unwrap, and sophisticated HTTP clients already have access to headers.
The pragmatic choice: use a consistent {data: ..., meta: ...} envelope for list endpoints (where pagination metadata is needed) and flat responses for single-resource endpoints. This is roughly what Stripe does — consistency within a pattern matters more than the specific pattern chosen.
PATCH vs PUT Semantics
PUT replaces the entire resource. Send all fields. Fields not included in the request are cleared to their defaults. PATCH modifies specific fields. Include only the fields you want to change.
The ambiguity in PATCH is what null means. If a client sends {"bio": null}, does that mean "set the bio field to null/empty" or "leave the bio field unchanged (I'm not sending it)"? This is the core PATCH semantic question, and both interpretations are in use.
JSON Merge Patch (RFC 7396) standardizes PATCH semantics: null means "delete/clear the field." Fields not present in the request are left unchanged. This is simple and covers most use cases:
// PATCH /users/123
// Body: {"name": "New Name", "bio": null}
// Result: name updated, bio cleared, all other fields unchanged
JSON Patch (RFC 6902) is more expressive — it uses an array of operations (add, remove, replace, move, copy, test). It's useful for conditional updates (test operation fails if a field doesn't match, aborting the patch) and for complex transformations. It's significantly more complex to implement and consume:
// PATCH /users/123
// Body:
[
{ "op": "replace", "path": "/name", "value": "New Name" },
{ "op": "remove", "path": "/bio" }
]
Recommendation: implement JSON Merge Patch for most APIs. The RFC 7396 semantics are clear, well-documented, and handled by libraries in every language. Use the Content-Type: application/merge-patch+json header to signal the format. Only implement JSON Patch if you have specific use cases requiring conditional updates or atomic complex operations.
Making PATCH idempotent: PATCH is not idempotent by default (the spec says it might not be). In practice, most PATCH /users/123 {"name": "Alice"} operations are naturally idempotent — applying the same patch twice produces the same result. Document whether your PATCH endpoints are idempotent, and for operations that aren't (like append operations), use idempotency keys. See our API idempotency guide.
9. Learn from the Best
| API | What They Do Well |
|---|---|
| Stripe | Consistent naming, excellent errors, idempotency keys, expandable objects |
| GitHub | Discoverable (HATEOAS links), consistent pagination, excellent docs |
| Twilio | Resource-oriented, consistent patterns across all products |
| Slack | Simple methods, clear error codes, rate limit transparency |
| OpenAI | Clean JSON, streaming support, clear model selection |
Designing APIs? Explore API design tools and patterns on APIScout. Also see our guides on API error handling and status codes and API rate limiting best practices for building production-grade APIs.