Advice for designing REST APIs

I’ve had to maintain many APIs over the years. Certain API choices early on make maintaining REST APIs much easier.

Here’s a few things that can make your life easier.


1. Version your API

Put a /v1/ at the start of your API path immediately.

You will want a v2 at some point. Avoid the confusion of dealing with a non-versioned path and a versioned path by having versioning in the path from the get-go.

API versioning should follow semantic versioning; the major version number (updated with breaking changes) should be the one in the path.

You could go even further and add the full semantic version to the path. I think that’s generally not necessary for a REST API, but a version negotiation could be useful for a websocket protocol.

Permitted (non-breaking) changes include adding new paths, adding new fields on a response, and adding new optional fields on a request. Breaking changes include adding new enum values in a response (unless your API specifies how unknown values should be handled), changing the type of a field, removing required fields on a response.

2. Do not use dynamic key names in maps

Let’s say my system allows users to define their own properties.

I could return the properties map as a pure map:

{
  "custom_properties":  {
    "my_first_property": "myValue1",
    "my_second_property": "myValue2"
  }
}

The issue is that a property should be an object in its own right. If we want to add some ‘meta-properties’, i.e. properties about the user defined properties, we would need to do something rather convoluted.

Treat properties as whole objects, and they become infinitely expandable:

{
  "custom_properties": [
    {
      "id": "84abcd57-9539-4ef8-932d-cbaf7b0e6f6d",
      "property_name": "my_first_property",
      "property_value": "my_second_property",
      "last_updated": 1782050410,
      "updated_by": "LeroyJenkins"
    }
  ]
}

One could keep “user_defined_properties” as a map and use the server-defined id as the map key, mapping to a object containing property_name, property_value, etc:

{
  "custom_properties": {
    "84abcd57-9539-4ef8-932d-cbaf7b0e6f6d": {
      "property_name": "my_first_property",
      "property_value": "my_second_property",
      "last_updated_seconds": 1782050410,
      "updated_by": "LeroyJenkins"
    }
  }
}

I would avoid this. While it maps nicely to a resource path (/api/v1/custom_properties/84abcd57-9539-4ef8-932d-cbaf7b0e6f6d), it has several drawbacks:

  • API schema generators like OpenAPI/Swagger prefer predictable keys. It can be quite difficult to make clean documentation that explains what the key of each map entry is.
  • Consumers of the API probably don’t need the optimisation of having the server id be the key value. In fact, the consumer probably doesn’t care that much about the server id at all. In our example, the consumer would care more about finding properties by their name. Allow the consuming app to re-organise the data into the structure it needs.
  • If the entries are being displayed in a table, the client is going to need to transform the data into the array-type anyway.

3. PATCH before PUT

Resources are rarely created and not modified. REST provides two explicit verbs for modifying an existing resource: PATCH and PUT.

PATCH allows the client to specify only the fields that have changed.

PUT requires re-sending of the whole resource.

Unless you need to squeeze out every last drop of performance, the choice doesn’t really matter when the API messages are being generated and sent by machines.

There will be a time, however, where you need to write an update by hand. This may be for manual testing, or because a UI hasn’t been updated yet. It’s far easier to write a PATCH by hand than a PUT.

PATCHs are also easier to make backwards compatible changes to.

If we add a new field to a resource, any clients using the previous version of the API will send patches and the server will accept them as before.

Adding a field to a PUT, however, creates some ambiguity. If the client doesn’t send the field, are they on the old version of the API? Or, are they on the new version but trying to clear the field?

One way to deal with this would be to make the field optional, and do not change the field value if the field is not present. This sounds familiar. It’s a PATCH!

By adding a new field to our resource — and keeping the API backwards-compatible — we have turned our PUT into a PATCH, only with more complicated logic on the backend.

There is a caveat, however. PUTs must be idempotent. PATCHs do not have to be idempotent.

Consider a PATCH that adds an element to an array.

HTTP PATCH /api/v1/clients/1 {
  "operations": [
    {"op": "add", "path": "/emails/-", "value": "[email protected]"}
  ]
}

This type of patch is not going to be idempotent, it will add to the list of emails every time the request is made. Care must be taken to implement idempotency where necessary, perhaps with duplicate checks, idempotency tokens, or optimistic concurrency control using e-tag headers.

Another concern with PATCHs is how to delete a field.

Ensure your backend logic can tell the difference between a field being declared in the patch as null, versus not being present at all

PATCH /people/1

{
  "name": "Leroy Jenkins",
  "email": null
}

Here, the contents of the email field should be deleted.

PATCH /people/1

{
  "name": "Leroy Jenkins"
}

Here, the contents of the email field should be unchanged.

4. Server-side Identifiers

When creating a new resource, the server must be the one to create and return the unique ID of the resource.

Yes, you could have the client create an ID and return a 409 Conflict if that ID already exists, but don’t. It does not scale and means implementing retry logic on the client side (or a strange failure to be reported to the user).

5. Authenticate, Authorise, Account

Do not leave authentication to the last minute. You will forget.

Even if your API is intentionally public, I would still recommend some form of authentication, even something as simple as API tokens. This allows you to monitor and control access later on without upsetting every user when you have to disable authentication-free access.

Authorisation and accounting don’t strictly form part of API design, but they should be part of the wider system design and early implementation. Ensure that API access is limited to who needs it. Ensure that role-based controls can be maintained. Audit API access.

6. Be consistent

Write a document that requires your APIs to follow certain conventions. Typical conventions include:

  • Pluralise resource paths, e.g.
    /api/v1/orders
  • Use kebab-case for paths, snake_case for property names

Create automated analysis tests that will identify inconsistencies.

7. Do not use verbs in the URI

Sometimes we want an HTTP call to do something, like execute a command, or approve a change.

It can be really tempting to do HTTP POST /api/v1/clients/1/approve {}.

This is not REST. It is an RPC call.

As an improvement, we could make the action its own resource: HTTP POST /api/v1/clients/1/actions {"action": "APPROVE"}

This is ultimately still not RESTful, though.

REST stands for Representational State Transfer. We should be transferring the desired state to the server, and the server should decide what actions must be taken to achieve that state. We already have a verb in our HTTP request (POST, PUT, DELETE, etc.). We don’t need another.

So, a RESTful call could be: HTTP PATCH /api/v1/clients/1 {"status": "APPROVED"}. The server can recognise that this is a change in status, and run any required steps in doing the approval.

Why does this actually matter?

In terms of maintenance, we avoid endpoint bloat. In the /api/v1/clients/1/{action} example, we end up with new paths for every type of action we might want. That can be a lot of unique objects for different actions that we have to manage.

By sticking to REST conventions, we are more easily able to leverage existing tools that expect certain standards. As AI tools become more popular, sticking to conventions will also improve their ability to manage our code.

8. Strings for numeric values

JSON has a numeric type. However, not all languages handle numbers equally. Floating-point numbers are often badly handled by default libraries. Large numbers may overflow parsers’ buffers.

Using strings for sending the values ensures the client application will obtain the exact values, and can handle them as it deems most appropriate.

I recommend using numeric JSON types only for integers that you know are going to be small (e.g. <32 bits).

Avoid using numeric types for IDs. You may have an AUTO_INCREMENT field in your database that you use to generate IDs, but in the future want to migrate to using UUIDs. It is far easier to change the values of IDs if you have only guaranteed that the type is a string.


This is by no means an exhaustive list.

Feel free to comment anything else you think is worth sharing.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.