In my career, I have consumed hundreds of REST APIs and produced dozens. Since I often see the same mistakes repeated in API design, I thought it might be nice to write down a set of best practices. And poke fun at a couple widely-used APIs.
Much of this may be “duh”, but there might be a few rules you haven’t considered yet.
Rule #1: DO use plural nouns for collections
It’s an arbitrary convention, but it’s well-established and I have found violations tend to be a leading indicator of “this API will have rough edges”.
# GOOD
GET /products # get all the products
GET /products/{product_id} # get one product
# BAD
GET /product/{product_id}
Rule #2: DON’T add unnecessary path segments
A common mistake seems to be trying to build your relational model into your URL structure. Etsy’s new API is full of this kind of thing:
# GOOD
GET /v3/application/listings/{listing_id}
# BAD
PATCH /v3/application/shops/{shop_id}/listings/{listing_id}
GET /v3/application/shops/{shop_id}/listings/{listing_id}/properties
PUT /v3/application/shops/{shop_id}/listings/{listing_id}/properties/{property_id}
The {listing_id}
is globally unique; there’s no reason for {shop_id}
to be part of the URL. Besides irritating your developers with extra clutter, it inevitably causes problems when your invariant changes down the road – say, a listing moves to a different store or can be listed in multiple stores.
I’ve seen this mistake repeated over and over; I can only assume it’s a manifestation of someone’s OCD:
GET /shops/{shop_id}/listings # normal, expected
GET /shops/{shop_id}/listings/{listing_id} # someone trying to be "consistent"?
GET /listings/{listing_id} # a much better endpoint
Which is not to say that compound URLs don’t make sense – use them when you genuinely have compound keys.
# When {option_id} is not globally unique
GET /listings/{listing_id}/options/{option_id}
Rule #3: DON’T add .json or other extensions to the url
This seems to have been some sort of default behavior of Rails, so it shows up intermittently in public APIs. Shopify gets shame here.
- URLs are resource identifiers, not representations. Adding representation information to the URL means there’s no canonical URL for a ‘thing’. Clients may have trouble uniquely identifying ‘things’ by URL.
- “JSON” is not even a complete specification of the representation. What transfer encoding, for example?
- HTTP already offers headers (
Accept
,Accept-Charset
,Accept-Encoding
,Accept-Language
) to negotiate representations. - Putting stock text at the end of URLs annoys the people writing clients.
- JSON should be the default anyway.
Back in the 2000s there might have been some question about whether clients want JSON or XML, but here in the 2020s it has been settled. Return JSON, and if clients want to negotiate for something else, rely on the standard HTTP headers.
Rule #4: DON’T return arrays as top level responses
The top level response from an endpoint should always be an object, never an array.
# GOOD
GET /things returns:
{ "data": [{ ...thing1...}, { ...thing2...}] }
# BAD
GET /things returns:
[{ ...thing1...}, { ...thing2...}]
The problem is that it’s very hard to make backwards compatible changes when you return arrays. Objects let you make additive changes.
The obvious common evolution in this specific example will be to add pagination. You can always add totalCount
or hasMore
fields and old clients will continue to work. If your endpoint returns a top-level array, you will need a whole new endpoint.
Rule #5: DON’T return map structures
I often see map structures used for collections in JSON responses. Return an array of objects instead.
# BAD
GET /things returns:
{
"KEY1": { "id": "KEY1", "foo": "bar" },
"KEY2": { "id": "KEY2", "foo": "baz" },
"KEY3": { "id": "KEY3", "foo": "bat" }
}
# GOOD (also note application of Rule #4)
GET /things returns:
{
"data": [
{ "id": "KEY1", "foo": "bar" },
{ "id": "KEY2", "foo": "baz" },
{ "id": "KEY3", "foo": "bat" }
]
}
Map structures in JSON are bad:
- The key information is redundant and adds noise to the wire
- Unnecessary dynamic keys create headaches for people working in typed languages
- Whatever you think a “natural” key is can change, or clients may want a different grouping
Converting an array of objects to a map is a one-liner in most languages. If your client wants efficient random-access to the collection of objects, they can create that structure. You don’t need to put it on the wire.
The worst thing about returning map structures is that your conceptual keys may change over time, and the only way to migrate is to break backwards compatibility. OpenAPI is a cautionary tale – v3 to v4 is full of unnecessary breaking changes because they rely heavily on map structures instead of array structures.
# OpenAPI v3 structure
{
"paths": {
"/speakers": {
"post": { ...information about the endpoint...}
}
}
}
# Proposed OpenAPI v4 structure, which names requests by adding a new
# map layer (eg "createSpeaker").
{
"paths": {
"/speakers": {
"requests": {
"createSpeaker": {
"method": "post",
...rest of the endpoint info...
}
}
}
}
}
If this was a flatter list structure, adding a name to an object is a nonbreaking change:
# Hypothetical flat array structure, using fields instead of map keys
{
"requests": [
{
name: "createSpeaker", // adding this field is nonbreaking
path: "/speakers",
method: "post",
...etc...
}
]
}
Exception to the no-map rule
The exception to the no-map rule is simple key/value pairs, like Stripe’s metadata.
# OK
{
"key1": "value1",
"key2": "value2"
}
Nobody will fault you for this structure. But if the values are more than simple strings, prefer arrays of objects instead.
Rule #6: DO use strings for all identifiers
Always use strings for object identifiers, even if your internal representation (ie database column type) is numeric. Just stringify the number.
# BAD
{ "id": 123 }
# GOOD
{ "id": "123" }
A great API will outlast you, your implementation code, and the company that created it. In that time your infrastructure might be rewritten on a different technology platform, migrated to a new database, or merged with another database that contains conflicting IDs.
String IDs are incredibly flexible. Strings can encode version information or segment ID ranges. Strings can encode composite keys. Numeric IDs put a straightjacket on future developers.
I once worked on a system that (because of a database merge) had to segment numeric ID ranges by giving one group positive IDs, the other negative IDs. Aside from the general ugliness, you can only do this segmentation once.
As a bonus, if all your ID fields are strings, client developers working in typed languages don’t need to think about which type to use. Just use strings!
Rule #7: DO prefix your identifiers
If your application is at all complicated, you’ll end up with a lot of different object types. Keeping opaque IDs straight is a mental challenge for both you and your client developers. You can dramatically improve the ergonomics of your API by making different types of IDs self-describing.
- Stripe’s identifiers have two-letter-plus-underscore prefixes:
in_1MVpWEJVZPfyS2HyRgVDkwiZ
- Shopify’s graphql identifiers look like URLs (though their REST API IDs are numeric, boo):
gid://shopify/FulfillmentOrder/1469358604360
It doesn’t matter what format you use, as long as 1) they’re visually distinct and 2) they don’t change.
Everyone will appreciate the reduced support load when you can instantly tell the difference between an “order line item ID”, a “fulfillment order line item ID”, and an “invoice item line item ID”.
Rule #8: DON’T use 404 to indicate “not found”
The HTTP spec says you should use 404 to indicate that a resource was not found. A literal interpretation suggests you should return 404 for GET/PUT/DELETE/etc requests to an ID that does not exist. Please do not do this – hear me out.
When calling (say) GET /things/{thing_id}
for a thing that doesn’t exist, the response should indicate that 1) the server understood your request, and 2) the thing wasn’t found. Unfortunately, a 404 response does not guarantee #1. There are many layers of software that can return 404 to a request, some of which you may have no control over:
- Misconfigured client hitting the wrong URL
- Misconfigured proxies (client end and server end)
- Misconfigured load balancers
- Misconfigured routing tables in the server application
Returning HTTP 404 for “thing not found” is almost like returning HTTP 500 – it could mean the thing doesn’t exist, or it could mean something went wrong; the client cannot be sure which.
This is not a minor problem. One of the hardest things about distributed systems is maintaining consistency. Let’s say you want to delete a resource from two systems (Alpha and Bravo) and all you have is a simple REST API (no two-phase-commit):
- In a single database transaction, SystemAlpha deletes Thing123 and enqueues a NotifyBravo job
- The NotifyBravo job runs, calling
DELETE /things/Thing123
on SystemBravo
This works because the queue will retry jobs until success. But it may also retry jobs that have succeeded; queues are at-least-once, not exactly-once.
Since successfully-executed DELETE
jobs may retry anyway, jobs must treat the “not found” response as success. If you treat 404 as success, and a failure in your stack returns 404, your job will be removed from the queue and your delete will not propagate. I have seen this happen in real life.
You could simply have DELETE
return 200 (or 204) OK when deleting a nonexistant thing – it makes sense, and I think it’s an acceptable answer for DELETE
. But some analogue of this issue exists for GET
, PUT
, PATCH
, and other methods.
You could use 404 but return a custom error body and demand that clients check for a correct error body. This is asking for trouble from lazy client programmers. It might or might not be “your fault” when clients see eventually inconsistent data, but the support calls they send you will be real.
My advice is to pick another 400-level error code that clients can interpret as “I understand what you’re asking for, but I don’t have it”. I use 410 GONE. This diverges slightly from the original intended meaning of 410 (“it existed before, but it doesn’t now”) but nobody actually uses that error, it’s reasonably self-explanatory, and there’s no risk that a f