Design REST and GraphQL APIs that are intuitive, consistent, and evolvable. Covers naming conventions, error handling, pagination, versioning, rate limiting, and the documentation practices that reduce support tickets.
API Design That Developers Actually Want to Use is a guide to crafting RESTful and GraphQL APIs that developers use consistently, without friction, and without needing to consult the docs every time. It matters when teams adopt a new service, when third parties integrate with a product, or when a library exposes functionality across multiple projects. This is not about the perfect API in theory—it’s about the API that developers *actually* use in practice, where every detail from status codes to error messages reduces cognitive load and accelerates onboarding.
The first API call a developer makes should feel like a natural extension of their existing workflow. They should not need to guess whether to use GET /users/123 or GET /api/v1/users/123. The endpoint should be predictable, with clear, consistent patterns.
Use plural, noun-based resource paths: /users, /posts, /comments, /orders. Avoid verbs in the path unless they represent actions not easily modeled as resources: /users/123/follow, /posts/123/publish.
Use query parameters for filtering, sorting, and pagination. The ?page=3&limit=20&sort=created_at&direction=desc&status=published pattern is expected. Support sort as a comma-separated list: ?sort=created_at,updated_at.
Pagination should be standardized: include next and prev links in the response, with page and limit as query parameters. The response body must include total, count, and page metadata:
{
"data": [
{ "id": 1, "name": "Alice", "email": "[email protected]" },
{ "id": 2, "name": "Bob", "email": "[email protected]" }
],
"meta": {
"total": 147,
"count": 2,
"page": 3,
"limit": 20,
"next": "/api/users?page=4&limit=20",
"prev": "/api/users?page=2&limit=20"
}
}
The Accept header should be respected: a client sending Accept: application/json+hal should receive a HAL-compliant response. Support Accept: application/vnd.api+json for JSON:API, and Accept: application/vnd.api+json; version=1.1.
Errors should be uniform across the API. Never return 404 with { "error": "User not found" }. Instead, use a standard error format:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "The requested user could not be found.",
"details": {
"userId": 456,
"timestamp": "2024-03-15T10:23:00Z"
},
"links": {
"documentation": "https://docs.example.com/errors/user-not-found"
}
}
}
Use HTTP status codes meaningfully:
400 Bad Request for malformed input404 Not Found for missing resources409 Conflict for versioned updates (e.g., PUT with outdated ETag)429 Too Many Requests with Retry-After header for rate-limited endpoints500 Internal Server Error for unhandled exceptionsReturn 400 when the request body is invalid, and include a list of errors:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "One or more fields failed validation.",
"details": {
"errors": [
{
"field": "email",
"code": "INVALID_EMAIL",
"message": "Must be a valid email address.",
"value": "alice@"
},
{
"field": "age",
"code": "MIN_VALUE",
"message": "Must be at least 18.",
"value": 16
}
]
}
}
}
Clients should be able to use error.code to handle specific failure cases without parsing full messages.
Versioning should be explicit and stable. Use path-based versioning: /api/v1/users, /api/v2/users. Avoid query-based versioning (?version=2) unless serving multiple APIs on the same host.
When introducing breaking changes, keep the old version live. For example, /api/v1/users remains stable even as /api/v2/users evolves. Use Link header to guide clients:
Link: <https://api.example.com/api/v2/users>; rel="next", <https://api.example.com/api/v1/users>; rel="current"
When deprecating a field or endpoint, include a Deprecation header:
Deprecation: true; until="2025-06-01"; reason="Use /api/v2/users instead"
Clients can monitor this and upgrade their code in advance.
Support idempotent operations: multiple identical requests produce the same result. Use Idempotency-Key header for POST and PUT.
POST /api/v1/orders
Idempotency-Key: a5f8c3b2-d9e1-4b4e-8a7f-12c3d4e5f6a7
On the server, store a mapping of Idempotency-Key → Last-Modified and ETag. If the key exists, return 200 OK with the existing result instead of creating a new resource.
For concurrent updates, use ETag or If-Match headers:
PUT /api/v1/users/123
If-Match: "abc123"
If the resource has changed since the client last fetched it, the server returns 412 Precondition Failed with the current ETag and updated data.
Use PATCH for partial updates with application/json-patch+json:
[
{ "op": "replace", "path": "/name", "value": "Alice Smith" },
{ "op": "add", "path": "/preferences", "value": { "theme": "dark" } }
]
Clients can send this directly from a form without needing to marshal the entire object.
Use standard, well-known authentication mechanisms. Prefer Bearer tokens in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Support both Bearer and Basic auth. For Basic, the header is:
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
Use X-Request-ID header to trace requests across services. Include it in error responses and logs.
For scoped access, use Authorization with scope parameter:
Authorization: Bearer <token>; scope="read:users write:posts"
The API should validate scopes and return 403 Forbidden with WWW-Authenticate header when a scope is missing:
WWW-Authenticate: Bearer scope="read:users", error="insufficient_scope", error_description="The request requires the 'write:posts' scope"
Clients should be able to explore the API without external documentation. Provide a root endpoint:
GET /api/
Response:
{
"links": {
"users": "/api/v1/users",
"posts": "/api/v1/posts",
"docs": "/api/docs",
"self": "/api/"
},
"version": "1.3.0",
"description": "A platform for managing users and content."
}
Support Accept: application/hal+json to return hypermedia links. Include rel and href in responses.
Use Link headers to express relationships:
Link: <https://api.example.com/api/v1/users/123>; rel="self", <https://api.example.com/api/v1/posts?author=123>; rel="posts", <https://api.example.com/api/v1/users/123/follow>; rel="follow"
Clients can follow these links automatically.
Minimize payload size and round trips. Use Content-Type: application/json for most endpoints. For large responses, support gzip compression and ETag caching.
When a client fetches a list of users, allow them to request only specific fields using fields query parameter:
GET /api/v1/users?fields=id,name,email,avatar
The server returns only those fields in the response:
[
{
"id": 1,
"name": "Alice",
"email": "[email protected]",
"avatar": "https://example.com/avatars/alice.jpg"
}
]
Support expand for nested data:
GET /api/v1/posts?expand=author,comments
The server includes nested author and comments objects in the response, and uses fields to control depth:
GET /api/v1/posts?expand=author(fields=id,name)&expand=comments(fields=body,author)
Use Cache-Control headers to guide clients and proxies. A 200 OK response with Cache-Control: max-age=3600 should be reused for one hour.
Failures should be silent but informative. A GET /api/v1/users/123 that returns 404 Not Found should include a Content-Type: application/problem+json with:
{
"type": "https://api.example.com/problems/user-not-found",
"title": "User Not Found",
"status": 404,
"detail": "The requested user with ID 123 could not be found.",
"instance": "/api/v1/users/123"
}
Clients can use type to map to error handlers.
When a request is malformed, return 400 with Content-Type: application/problem+json and a detail field. Use type to point to a machine-readable error definition.
For rate limiting, include X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 973
Retry-After: 30
Clients can back off or queue requests when they hit the limit.
When designing an API, use curl to prototype and validate:
curl -X POST https://api.example.com/api/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Charlie",
"email": "[email protected]"
}'
Use httpie for interactive testing:
http POST https://api.example.com/api/v1/users \
name=Charlie \
[email protected] \
Accept:application/json+hal \
Authorization:"Bearer $TOKEN"
Validate the API with jq and grep:
curl -s https://api.example.com/api/v1/users | jq '.data[0].name'
Write integration tests that check:
Use k6 or Postman for load testing, but keep the core API design independent of any test framework.
An API that developers actually want to use is predictable, consistent, and self-explanatory. It uses standard HTTP practices, communicates clearly through errors, and anticipates common developer workflows. It doesn’t require a thick documentation book or a separate SDK. Instead, it speaks the language of the web: GET, POST, PATCH, ETag, Accept, Authorization, and Link. It works well in production, fails gracefully, and evolves without breaking clients. When a developer makes their first call, they feel confident: “I’ve seen this before. I can trust this API.”
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy