How to design better APIs
15 language-agnostic, actionable tips on REST API design
Table of contents
- 1. Be consistent
- 2. Use ISO 8601 UTC dates
- 3. Make an exception for public endpoints
- 4. Provide a health check endpoint
- 5. Version the API
- 6. Accept API key authentication
- 7. Use reasonable HTTP status codes
- 8. Use reasonable HTTP methods
- 9. Use self-explanatory, simple names
- 10. Use standardized error responses
- 11. Return created resources upon POST
- 12. Prefer PATCH over PUT
- 13. Be as specific as possible
- 14. Use pagination
- 15. Allow expanding resources
APIs are awesome, but they're also extremely hard to design. When creating an API from scratch, you need to get many details right. From basic security considerations to using the right HTTP methods, implementing authentication, deciding which requests and responses you should accept and return, ... the list goes on.
In this post, I'm trying my best to compress everything I know about what makes a good API. An API, that your consumers will enjoy using. All tips are language-agnostic, so they apply to any framework or technology.
1. Be consistent
I know it sounds logical, but it's hard to get this one right. The best APIs I know are predictable. When a consumer uses and understands one endpoint, they can expect that another endpoint works the same way. This is important for the entire API and one of the key indicators of whether or not an API is well-designed and great to use.
- Use the same casing for fields, resources, and parameters (I prefer
snake_case
) - Use either plural or singular resource names (I prefer plural)
/users/{id}
,/orders/{id}
or/user/{id}
,/order/{id}
- Use the same authentication and authorization methods for all endpoints
- Use the same HTTP headers across the API
- For example
Api-Key
for passing an API key
- For example
- Use the same HTTP status codes based on the type of response
- For example
404
when a resource can not be found
- For example
- Use the same HTTP methods for the same kind of actions
- For example
DELETE
when deleting a resource
- For example
2. Use ISO 8601 UTC dates
When dealing with date and time, APIs should always return ISO 8601-formatted strings. Displaying dates in a specific time zone is generally a concern of client applications.
{
"published_at": "2022-03-03T21:59:08Z"
}
3. Make an exception for public endpoints
Every endpoint should require authorization by default. Most endpoints require an authenticated user to be called, so making this the default makes sense. If an endpoint needs to be called publicly, explicitly set this endpoint to allow unauthorized requests.
4. Provide a health check endpoint
Provide an endpoint (for example GET /health
) that determines whether or not a service is healthy. This endpoint can be called by other applications such as load balancers to act in case of a service outage.
5. Version the API
Make sure you version your API and pass the version on each request so that consumers aren't affected by any changes to another version. API versions can be passed using HTTP headers or query/path parameters. Even the first version of the API (1.0) should be explicitly versioned.
Some examples:
https://api.averagecompany.com/v1/health
https://api.averagecompany.com/health?api_version=1.0
6. Accept API key authentication
If an API needs to be called by a third party, it makes sense to allow authentication via API keys. API keys should be passed using a custom HTTP header (such as Api-Key
). They should have an expiration date, and it must be possible to revoke active keys so that they can be invalidated in case they get compromised. Avoid checking API keys into source control (use environment variables instead).
7. Use reasonable HTTP status codes
Use conventional HTTP status codes to indicate the success or failure of a request. Don't use too many, and use the same status codes for the same outcomes across the API. Some examples:
200
for general success201
for successful creation400
for bad requests from the client401
for unauthorized requests403
for missing permissions404
for missing resources429
for too many requests5xx
for internal errors (these should be avoided at all costs)
8. Use reasonable HTTP methods
There are many HTTP methods, but the most important ones are:
POST
for creating resourcesPOST /users
GET
for reading resources (both single resources and collections)GET /users
GET /users/{id}
PATCH
for applying partial updates to a resourcePATCH /users/{id}
PUT
for applying full updates to a resource (replaces the current resource)PUT /users/{id}
DELETE
for deleting resourcesDELETE /users/{id}
9. Use self-explanatory, simple names
Most endpoints are resource-oriented and should be named that way. Don't add unnecessary information that can be inferred from elsewhere. This also applies to field names.
✅ GOOD
GET /users
=> Retrieve usersDELETE /users/{id}
=> Delete a userPOST /users/{id}/notifications
=> Create a notification for a specific useruser.first_name
order.number
❌ BAD
GET /getUser
POST /updateUser
POST /notification/user
order.ordernumber
user.firstName
10. Use standardized error responses
Aside from using HTTP status codes that indicate the outcome of the request (success or error), when returning errors, always use a standardized error response that includes more detailed information on what went wrong. Consumers can always expect the same structure and act accordingly.
// Request => GET /users/4TL011ax
// Response <= 404 Not Found
{
"code": "user/not_found",
"message": "A user with the ID 4TL011ax could not be found."
}
// Request => POST /users
{
"name": "John Doe"
}
// Response <= 400 Bad Request
{
"code": "user/email_required",
"message": "The parameter [email] is required."
}
11. Return created resources upon POST
It's a good idea to return the created resource after creating it with a POST
request. That's mostly important because the returned, created resource will reflect the current state of the underlying data source and will contain more recent information (such as a generated ID).
// Request: POST /users
{
"email": "jdoe@averagecompany.com",
"name": "John Doe"
}
// Response
{
"id": "T9hoBuuTL4",
"email": "jdoe@averagecompany.com",
"name": "John Doe"
}
12. Prefer PATCH
over PUT
As mentioned earlier, PATCH
requests should apply partial updates to a resource, whereas PUT
replaces an existing resource entirely. It's usually a good idea to design updates around PATCH requests, because:
- When using
PUT
to update only a subset of fields for a resource, the entire resource still needs to be passed, which makes it more network-intensive and error-prone - It's also quite dangerous to allow any field to be updated without any restrictions
- From my experience, there barely exist any use cases in practice where a full update on a resource would make sense
- Imagine an
order
resource that has anid
and astate
- It would be very dangerous to allow consumers to update the
state
of anorder
- A state change would much more likely be triggered by another endpoint (such as
/orders/{id}/fulfill
)
13. Be as specific as possible
As outlined in the previous section, it's generally a good idea to be as specific as possible when designing endpoints, naming fields, and deciding which requests and responses to accept. If a PATCH
request only accepts two fields (name
and description
), there is no danger of using it incorrectly and corrupting data.
14. Use pagination
Paginate all requests that return a collection of resources and use the same response structure. Use page_number
and page_size
(or similar) to control which chunk you want to retrieve.
// Request => GET /users?page_number=1&page_size=15
// Response <= 200 OK
{
"page_number": 1,
"page_size": 15,
"count": 378,
"data": [
// resources
],
"total_pages": 26,
"has_previous_page": true,
"has_next_page": true
}
15. Allow expanding resources
Allow consumers to load related data using a query parameter called expand
(or similar). This is especially useful to avoid round-trips and load all data that is necessary for a specific action in one go.
// Request => GET /users/T9hoBuuTL4?expand=orders&expand=orders.items
// Response <= 200 OK
{
"id": "T9hoBuuTL4",
"email": "jdoe@averagecompany.com",
"name": "John Doe",
"orders": [
{
"id": "Hy3SSXU1PF",
"items": [
{
"name": "API course"
},
{
"name": "iPhone 13"
}
]
},
{
"id": "bx1zKmJLI6",
"items": [
{
"name": "SaaS subscription"
}
]
}
]
}
If you enjoyed this post, feel free to follow me on Twitter.