A well-designed API is like a well-designed building. It needs solid foundations, clear navigation, thoughtful organization, and room to grow. Just as an architect wouldn't start construction without blueprints, you shouldn't start coding endpoints without a clear design strategy.
Over the years, I've seen APIs that were a joy to integrate with and others that caused endless frustration. The difference almost always comes down to following established conventions and making intentional design decisions. This guide consolidates the patterns I've found most valuable.
Core Principles
Before diving into specifics, let's establish the foundational principles that guide good REST API design:
- Consistency: Similar operations should work similarly across all resources
- Predictability: Developers should be able to guess how things work
- Clarity: The API should be self-documenting where possible
- Flexibility: Support current needs without precluding future requirements
- Robustness: Handle edge cases gracefully
Every decision we make should serve these principles.
Resource Naming Conventions
The URI is the first thing developers see. It should communicate clearly what resource they're working with.
Use Nouns, Not Verbs
Resources are things, not actions. The HTTP method conveys the action.
Recommended:
GET /articles
POST /articles
GET /articles/42
PUT /articles/42
DELETE /articles/42
Avoid:
GET /getArticles
POST /createArticle
POST /deleteArticle/42
The exception is for operations that don't map cleanly to CRUD. In these cases, a verb-based endpoint is acceptable:
POST /articles/42/publish
POST /users/123/reset-password
Use Plural Nouns
Consistency matters more than grammatical correctness. Using plurals for all collection endpoints eliminates ambiguity.
Recommended:
/users
/users/42
/users/42/orders
/users/42/orders/7
Avoid mixing:
/user # Is this a single user or a collection?
/users/42
Use Kebab-Case for Multi-Word Resources
URIs are case-sensitive, but conventions help developers remember them.
Recommended:
/blog-posts
/order-items
/user-profiles
Avoid:
/blogPosts # camelCase is for code, not URIs
/blog_posts # Underscores are harder to read in URLs
/BlogPosts # PascalCase creates case-sensitivity issues
Model Relationships Clearly
Nested resources should express ownership or strong relationships:
/users/42/orders # Orders belonging to user 42
/orders/7/items # Items within order 7
/organizations/5/members # Members of organization 5
However, avoid deep nesting beyond two levels. If you find yourself at /users/42/orders/7/items/3/comments, consider flattening:
/order-items/3/comments # If items have global IDs
Query Parameters for Filtering, Sorting, and Searching
Keep the URI focused on identifying resources. Use query parameters for everything else.
GET /articles?status=published
GET /articles?author=jane-doe
GET /articles?sort=-created_at
GET /articles?sort=title,-created_at
GET /articles?search=kubernetes
GET /articles?fields=id,title,summary
Conventions I recommend:
- Filtering: Use the field name as the parameter:
?status=active - Sorting: Use
sortwith field names, prefix with-for descending:?sort=-date,title - Field selection: Use
fieldsfor sparse fieldsets:?fields=id,name,email - Search: Use
searchorqfor full-text search:?search=query
HTTP Methods
Each HTTP method has specific semantics. Using them correctly makes your API intuitive.
Method Reference
| Method | Purpose | Idempotent | Safe | Request Body | Response Body |
|---|---|---|---|---|---|
| GET | Retrieve resource(s) | Yes | Yes | No | Yes |
| POST | Create resource | No | No | Yes | Yes |
| PUT | Replace resource entirely | Yes | No | Yes | Yes |
| PATCH | Partially update resource | No* | No | Yes | Yes |
| DELETE | Remove resource | Yes | No | No | Optional |
*PATCH can be idempotent depending on implementation
GET: Retrieve Resources
GET requests should never modify state. They must be safe and idempotent.
GET /articles HTTP/1.1 Host: api.example.com Accept: application/json --- HTTP/1.1 200 OK Content-Type: application/json { "data": [ {"id": 1, "title": "First Article", "status": "published"}, {"id": 2, "title": "Second Article", "status": "draft"} ], "meta": { "total": 47, "page": 1, "per_page": 20 } }
POST: Create Resources
POST creates a new resource within a collection. The server assigns the identifier.
POST /articles HTTP/1.1 Host: api.example.com Content-Type: application/json { "title": "New Article", "content": "Article content here...", "author_id": 42 } --- HTTP/1.1 201 Created Location: /articles/48 Content-Type: application/json { "data": { "id": 48, "title": "New Article", "content": "Article content here...", "author_id": 42, "created_at": "2026-01-12T10:30:00Z" } }
Key points:
- Return
201 Createdon success - Include
Locationheader with the new resource URI - Return the created resource with server-generated fields
PUT: Replace Resources
PUT replaces the entire resource. If fields are omitted, they should be cleared or set to defaults.
PUT /articles/48 HTTP/1.1 Host: api.example.com Content-Type: application/json { "title": "Updated Title", "content": "Completely new content...", "author_id": 42, "status": "published" } --- HTTP/1.1 200 OK Content-Type: application/json { "data": { "id": 48, "title": "Updated Title", "content": "Completely new content...", "author_id": 42, "status": "published", "updated_at": "2026-01-12T11:00:00Z" } }
PUT is idempotent: calling it multiple times with the same payload produces the same result.
PATCH: Partial Updates
PATCH updates only the specified fields. Unspecified fields remain unchanged.
PATCH /articles/48 HTTP/1.1 Host: api.example.com Content-Type: application/json { "status": "archived" } --- HTTP/1.1 200 OK Content-Type: application/json { "data": { "id": 48, "title": "Updated Title", "content": "Completely new content...", "author_id": 42, "status": "archived", "updated_at": "2026-01-12T11:30:00Z" } }
Consider using JSON Patch (RFC 6902) for complex updates:
PATCH /articles/48 HTTP/1.1 Content-Type: application/json-patch+json [ {"op": "replace", "path": "/title", "value": "New Title"}, {"op": "add", "path": "/tags/-", "value": "featured"} ]
DELETE: Remove Resources
DELETE removes a resource. It should be idempotent.
DELETE /articles/48 HTTP/1.1 Host: api.example.com --- HTTP/1.1 204 No Content
Important considerations:
- Return
204 No Contenton successful deletion - Return
404 Not Foundif resource doesn't exist (debatable - some prefer204) - Consider soft deletion for audit trails
HTTP Status Codes
Status codes communicate the result of an operation. Using them correctly eliminates the need for clients to parse response bodies to understand what happened.
Success Codes (2xx)
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, or DELETE |
| 201 | Created | Successful POST that created a resource |
| 202 | Accepted | Request accepted for async processing |
| 204 | No Content | Success with no response body (DELETE) |
Client Error Codes (4xx)
| Code | Meaning | When to Use |
|---|---|---|
| 400 | Bad Request | Malformed request syntax, invalid data |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Valid auth but insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 405 | Method Not Allowed | HTTP method not supported for resource |
| 409 | Conflict | Request conflicts with current state |
| 422 | Unprocessable Entity | Syntactically correct but semantically invalid |
| 429 | Too Many Requests | Rate limit exceeded |
Server Error Codes (5xx)
| Code | Meaning | When to Use |
|---|---|---|
| 500 | Internal Server Error | Unexpected server failure |
| 502 | Bad Gateway | Invalid response from upstream server |
| 503 | Service Unavailable | Server temporarily unavailable |
| 504 | Gateway Timeout | Upstream server timeout |
Choosing Between 400, 422, and 409
This is a common point of confusion:
- 400 Bad Request: The request is malformed. JSON parsing failed, required headers missing, etc.
- 422 Unprocessable Entity: The request is well-formed but contains semantic errors. Email format invalid, referenced resource doesn't exist, etc.
- 409 Conflict: The request is valid but conflicts with current state. Duplicate unique constraint, optimistic lock failure, etc.
API Versioning
APIs evolve. Breaking changes are sometimes necessary. Versioning allows you to make changes without breaking existing clients.
Versioning Strategies
1. URI Path Versioning (Recommended for most cases)
GET /v1/articles
GET /v2/articles
Advantages:
- Explicit and visible
- Easy to route at load balancer level
- Simple to document and test
Disadvantages:
- Not "pure" REST (version isn't part of the resource)
- Can lead to URI proliferation
2. Header Versioning
GET /articles HTTP/1.1 Accept: application/vnd.example.v2+json
Advantages:
- Clean URIs
- Follows content negotiation principles
Disadvantages:
- Less discoverable
- Harder to test in browser
- More complex routing
3. Query Parameter Versioning
GET /articles?version=2
Advantages:
- Easy to implement
- Explicit in request
Disadvantages:
- Optional parameter could be forgotten
- Mixes metadata with resource parameters
Versioning Guidelines
- Version the entire API, not individual endpoints
- Support at least one previous version during transition
- Clearly communicate deprecation timelines
- Document breaking changes comprehensively
- Consider using sunset headers:
HTTP/1.1 200 OK Sunset: Sat, 01 Jun 2026 00:00:00 GMT Deprecation: true Link: </v2/articles>; rel="successor-version"
Pagination
Any endpoint that returns collections must support pagination. Returning thousands of records in a single response is never acceptable.
Offset-Based Pagination
The simplest approach, using page and per_page (or limit and offset):
GET /articles?page=3&per_page=20 --- HTTP/1.1 200 OK Content-Type: application/json { "data": [...], "meta": { "current_page": 3, "per_page": 20, "total_pages": 12, "total_count": 234 }, "links": { "first": "/articles?page=1&per_page=20", "prev": "/articles?page=2&per_page=20", "next": "/articles?page=4&per_page=20", "last": "/articles?page=12&per_page=20" } }
Advantages:
- Simple to understand and implement
- Allows jumping to arbitrary pages
Disadvantages:
- Performance degrades on large datasets
- Inconsistent when data changes between requests
Cursor-Based Pagination
More efficient for large datasets and real-time data:
GET /articles?limit=20&cursor=eyJpZCI6MTAwfQ --- HTTP/1.1 200 OK Content-Type: application/json { "data": [...], "meta": { "has_more": true, "next_cursor": "eyJpZCI6MTIwfQ" }, "links": { "next": "/articles?limit=20&cursor=eyJpZCI6MTIwfQ" } }
The cursor is typically an encoded representation of the last item's sort keys.
Advantages:
- Consistent performance regardless of dataset size
- Stable results even when data changes
Disadvantages:
- Cannot jump to arbitrary pages
- More complex to implement
Pagination Best Practices
- Set reasonable defaults (e.g.,
per_page=20) - Enforce maximum limits (e.g.,
per_pagecannot exceed 100) - Always return pagination metadata
- Include navigation links (following HATEOAS principles)
- Document your pagination strategy
Error Handling
Good error responses help developers debug issues quickly. They should be consistent, informative, and secure.
Error Response Structure
{ "error": { "code": "VALIDATION_ERROR", "message": "The request contains invalid data", "details": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" }, { "field": "age", "code": "OUT_OF_RANGE", "message": "Must be between 18 and 120" } ], "request_id": "req_abc123", "documentation_url": "https://docs.example.com/errors/VALIDATION_ERROR" } }
Key elements:
- code: Machine-readable error identifier
- message: Human-readable description
- details: Specific field-level errors when applicable
- request_id: Correlation ID for support and debugging
- documentation_url: Link to more information
Error Handling Guidelines
Be specific but secure:
// Good - specific enough to fix { "error": { "code": "INVALID_CREDENTIALS", "message": "The email or password is incorrect" } } // Bad - reveals too much { "error": { "code": "USER_NOT_FOUND", "message": "No user exists with email [email protected]" } }
Use consistent error codes:
VALIDATION_ERROR
AUTHENTICATION_REQUIRED
PERMISSION_DENIED
RESOURCE_NOT_FOUND
CONFLICT
RATE_LIMIT_EXCEEDED
INTERNAL_ERROR
Include request context for debugging:
{ "error": { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred", "request_id": "req_abc123", "timestamp": "2026-01-12T10:30:00Z" } }
Authentication
Security is non-negotiable. Choose an authentication strategy appropriate for your use case.
API Keys
Simple but limited. Suitable for server-to-server communication.
GET /articles HTTP/1.1 X-API-Key: sk_live_abc123def456
Guidelines:
- Use prefix conventions (
sk_live_,sk_test_) - Transmit only over HTTPS
- Implement key rotation
- Log key usage for auditing
JWT (JSON Web Tokens)
Stateless authentication suitable for distributed systems.
GET /articles HTTP/1.1 Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Token structure:
{ "header": { "alg": "RS256", "typ": "JWT" }, "payload": { "sub": "user_42", "iat": 1704974400, "exp": 1704978000, "scope": "articles:read articles:write" } }
Guidelines:
- Use short expiration times (15-60 minutes)
- Implement refresh token rotation
- Use asymmetric signing (RS256) for distributed verification
- Include only necessary claims
OAuth 2.0
The standard for delegated authorization.
POST /oauth/token HTTP/1.1 Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=abc123 &redirect_uri=https://app.example.com/callback &client_id=client_xyz &client_secret=secret_xyz
Use OAuth 2.0 when:
- Third-party applications need access to user data
- You need granular permission scopes
- You require standardized flows
Authentication Best Practices
- Always use HTTPS
- Implement rate limiting on authentication endpoints
- Use secure token storage (httpOnly cookies or secure storage)
- Implement proper token revocation
- Log authentication events
- Consider implementing MFA for sensitive operations
Putting It All Together
Here's a complete example demonstrating these principles:
# List articles with filtering, sorting, and pagination GET /v1/articles?status=published&sort=-created_at&page=1&per_page=20 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJSUzI1NiIs... Accept: application/json --- HTTP/1.1 200 OK Content-Type: application/json X-Request-ID: req_abc123 X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 999 X-RateLimit-Reset: 1704978000 { "data": [ { "id": 47, "type": "article", "attributes": { "title": "REST API Best Practices", "status": "published", "created_at": "2026-01-12T10:00:00Z" }, "relationships": { "author": { "data": {"type": "user", "id": 42} } }, "links": { "self": "/v1/articles/47" } } ], "meta": { "current_page": 1, "per_page": 20, "total_pages": 3, "total_count": 47 }, "links": { "self": "/v1/articles?status=published&sort=-created_at&page=1&per_page=20", "next": "/v1/articles?status=published&sort=-created_at&page=2&per_page=20", "last": "/v1/articles?status=published&sort=-created_at&page=3&per_page=20" } }
Final Thoughts
Designing a good API is an investment that pays dividends. The time spent on thoughtful naming, consistent conventions, and comprehensive error handling will be repaid many times over in reduced support burden and happier developers.
Remember these key principles:
- Be consistent in naming and behavior
- Use HTTP semantics correctly
- Provide clear, actionable error messages
- Plan for evolution with versioning
- Secure every endpoint appropriately
- Document everything
An API is a contract with your users. Honor that contract by making it clear, stable, and well-documented. Your future self - and everyone who integrates with your API - will thank you.
