Designing API responses
The API response is the most important part of the entire API.
- Did the API consumer ask a question? They need that answer.
- Did the API consumer ask to transfer £1,000,000? They need to be confident that went well.
- Did the API consumer make a mistake? Tell them how to fix it so they can get back to making you money.
Creating clear, consistent API responses is crucial for building usable APIs. This guide covers essential patterns and best practices for API responses.
Anatomy of an API Response
An API response is primarily made up of a status code, headers, and a response body, so let’s look at each of those parts in turn.
Headers
Just like requests allow API consumers to add HTTP headers to act as metadata for the request, APIs and other network components can add headers to a response.
HTTP/2 200 OKContent-Type: application/jsonCache-Control: public, max-age=18000RateLimit: "default";r=50;t=30{"title": "something"}
This is a successful request, with some JSON data as highlighted by the Content-Type
header. The API has also alerted the API consumer that this is cacheable so they don’t need to ask for it again for 5 hours, and explained that the client is running a little low on their rate limiting policy with only 50 more requests allowed in the next 30 seconds.
API responses contain lots of useful metadata in the headers, but data is going to be in the response body.
Response Body
You should strive to keep response consistent and well-structured, with minimal nesting and correct use of data types.
{"id": 123,"name": "High Wood","location": {"lat": 50.464569783,"lon": -4.486597585},"created_at": "2024-10-24T12:00:00Z","links": {"reviews": "/places/123/reviews"}}
It’s pretty common to add an id
of some sort, often data will have dates, and
relationships and available actions can be linked allowing API consumers to easily
find related information without going on a scavenger hunt.
Status Codes
So far we’ve only looked at success, but how do we know if something has worked or not?
You could look at the response body and try to figure it out, and for years
people were doing silly things like setting { "success": true/false }
in their
response body to give people a hint, but as always there’s a far better way
defined in the HTTP spec which covers loads more use-cases and works out of the
box with loads of tools: HTTP Status Codes.
A status code is a number and a matching phrase, like 200 OK
and 404 Not Found
. There are countless status codes defined in the HTTP RFCs and
elsewhere, and some big companies have invented their own which became common
conventions, so there’s plenty to choose from.
Arguments between developers will continue for the rest of time over the exact appropriate status code to use in any given situation, but these are the most important status codes to look out for in an API:
2XX is all about success
Whatever the API was asked to do was successful, up to the point that the
response was sent. A 200 OK
is a generic “all good”, a 201 Created
means
something was created, and a 202 Accepted
is similar but does not say anything
about the actual result, it only indicates that a request was accepted and is
being processed asynchronously. It could still go wrong, but at the time of
responding it was all looking good at least up until it was put in the queue.
The common success status codes and when to use them:
- 200 - Generic everything is OK.
- 201 - Created something OK.
- 202 - Accepted but is being processed async (for a video means. encoding, for an image means resizing, etc.)
- 204 - No Content but still a success. Ideal for a successful
DELETE
request, for example.
Example success response
HTTP/1.1 200 OKContent-Type: application/json{"user": {"id": 123,"name": "John Doe"}}
3XX is all about redirection
These are all about sending the calling application somewhere else for the
actual resource. The best known of these are the 303 See Other
and the 301 Moved Permanently
, which are used a lot on the web to redirect a browser to
another URL. Usually a redirect will be combined with a Location
header to
point to the new location of the content.
4XX is all about client errors
Indicate to your clients that they did something wrong. They might have forgotten to send authentication details, provided invalid data, requested a resource that no longer exists, or done something else wrong which needs fixing.
Key client error codes:
- 400 - Bad Request (should really be for invalid syntax, but some folks. use for validation).
- 401 - Unauthorized (no current user and there should be).
- 403 - The current user is forbidden from accessing this data.
- 404 - That URL is not a valid route, or the item resource does not exist.
- 405 - Method Not Allowed (your framework will probably do this for you.)
- 406 - Not Acceptable (the client asked for a content type that the API does not support.)
- 409 - Conflict (Maybe somebody else just changed some of this data, or status cannot change from e.g: “published” to “draft”).
- 410 - Gone - Data has been deleted, deactivated, suspended, etc.
- 415 - The request had a
Content-Type
which the server does not know how to handle. - 429 - Rate Limited, which means take a breather, sleep a bit, try again.
5XX is all about service errors
With these status codes, the API, or some network component like a load
balancer, web server, application server, etc. is indicating that something went
wrong on their side. For example, a database connection failed, or another
service was down. Typically, a client application can retry the request. The
server can even specify when the client should retry, using a Retry-After
HTTP
header.
Key server error codes:
- 500 - Something unexpected happened, and it is the API’s fault.
- 501 - This bit isn’t finished yet, maybe it’s still in beta and you don’t have access.
- 502 - API is down, but it is not the API’s fault.
- 503 - API is not here right now, please try again later.
As you can see, there are a whole bunch of HTTP status codes. You don’t need to try and use them all, but it is good to know what they are and what they mean so you can use the right one for the job.
You have two choices, either read the full list of status codes from the IANA (opens in a new tab), or swing by http.cats (opens in a new tab) and see what the cats have to say about it.
Using Status Codes
import axios, { AxiosError } from 'axios';async function makeHttpRequest() {try {const response = await axios.get('https://example.com/api/resource');console.log('Response:', response.data);} catch (error) {if (! axios.isAxiosError(error)) {console.error('An unexpected error occurred:', error);return;}const axiosError = error as AxiosError;if (axiosError.response?.status === 401) {console.error('You need to log in to access this resource.');} else if (axiosError.response?.status === 403) {console.error('You are forbidden from accessing this resource.');} else if (axiosError.response?.status === 404) {console.error('The resource you requested does not exist.');} else {console.error('An error occurred:', axiosError.message);}}}makeHttpRequest();
Now you can warn API consumers of fairly specific problems. Doing it way is cumbersome, but there’s plenty of generic libraries with various extensions and “middlewares” that will help auto-retry any auto-retriable responses, automatically cache cachable responses, and so on.
NOTE
Avoid confusing your API consumers by enabling retry logic in your Speakeasy SDK.
Best Practices
1. Keep Status Codes Appropriate & Consistent
It’s important to keep status codes consistent across your API, ideally across your entire organization.
This is not just for nice feels, it helps with code reuse, allowing consumers to share code between endpoints, and between multiple APIs.
This means they can integrate with you quicker, and with less code, and less maintenance overhead.
2. Keep Request & Response Bodies Consistent
Sometimes API developers end up with divergent data models between the request and the response, and this should be avoided whenever possible.
Whatever shape you pick for a request, you should match that shape on the response.
// POST /places{"name": "High Wood","lat": 50.464569783,"lon": -4.486597585}
// GET /places/123{"id": 123,"name": "High Wood","lat": 50.464569783,"lon": -4.486597585,"created_at": "2024-10-24T12:00:00Z"}
You can see that some differences, like id
or created_at
dates on the
response but not the request. That’s OK, because they can be handled as
“read-only” or “write-only” fields in the API documentation and generated code,
meaning they are using the same models just ignoring a few fields depending on
the context.
The problem often comes from various clients having a word with the API developers about “helping them out”, because some library being used by the iOS app would prefer to send coordinates as a string and they don’t want to convert them to a decimal for some reason. Then the API team wanted to have the responses wrapped into objects to make it look tidy, but the React team said it would be too hard to get their data manager to do that, so the request skipped it.
// POST /places{"name": "High Wood","lat": "50.464569783","lon": "-4.486597585"}
// GET /places/123{"id": 123,"name": "High Wood","location": {"lat": 50.464569783,"lon": -4.486597585},"created_at": "2024-10-24T12:00:00Z"}
Aghh!
This sort of thing causes confusion for everyone in the process, and whilst any one change being requested might feel reasonable, when a few of them stack up the API becomes horrible to work with.
Push back against request/response model deviations. It’s not worth it.
3. Return detailed errors
Just returning a status code and a message is not enough, at the bare minimum add an error message in the JSON body that adds more context.
HTTP/2 409 ConflictContent-Type: application/json{"error": "A place with that name already exists."}
This is better than nothing but not ideal. Other information needs to be added to help with debugging, and to help the API client differentiate between errors.
There is a better way: RFC 9457 (opens in a new tab) which defines a standard way to return errors in JSON (or XML).
HTTP/2 409 ConflictContent-Type: application/problem+json{"type": "https://api.example.com/probs/duplicate-place","title": "A place with that name already exists.","detail": "A place with the name 'High Wood' already exists close to here, have you or somebody else already added it?","instance": "/places/123/errors/<unique-id>","status": 409}
More on this in the API Errors guide.
Best Practices
1. Keep Status Codes Appropriate & Consistent
It’s important to keep status codes consistent across your API, ideally across your entire organization.
This is not just for nice feels, it helps with code reuse, allowing consumers to share code between endpoints, and between multiple APIs.
This means they can integrate with you quicker, and with less code, and less maintenance overhead.
2. Keep Request & Response Bodies Consistent
Sometimes API developers end up with divergent data models between the request and the response, and this should be avoided whenever possible.
Whatever shape you pick for a request, you should match that shape on the response.
// POST /places{"name": "High Wood","lat": 50.464569783,"lon": -4.486597585}
// GET /places/123{"id": 123,"name": "High Wood","lat": 50.464569783,"lon": -4.486597585,"created_at": "2024-10-24T12:00:00Z"}
You can see that some differences, like id
or created_at
dates on the
response but not the request. That’s OK, because they can be handled as
“read-only” or “write-only” fields in the API documentation and generated code,
meaning they are using the same models just ignoring a few fields depending on
the context.
The problem often comes from various clients having a word with the API developers about “helping them out”, because some library being used by the iOS app would prefer to send coordinates as a string and they don’t want to convert them to a decimal for some reason. Then the API team wanted to have the responses wrapped into objects to make it look tidy, but the React team said it would be too hard to get their data manager to do that, so the request skipped it.
// POST /places{"name": "High Wood","lat": "50.464569783","lon": "-4.486597585"}
// GET /places/123{"id": 123,"name": "High Wood","location": {"lat": 50.464569783,"lon": -4.486597585},"created_at": "2024-10-24T12:00:00Z"}
Aghh!
This sort of thing causes confusion for everyone in the process, and whilst any one change being requested might feel reasonable, when a few of them stack up the API becomes horrible to work with.
Push back against request/response model deviations. It’s not worth it.
3. Return detailed errors
Just returning a status code and a message is not enough, at the bare minimum add an error message in the JSON body that adds more context.
HTTP/2 409 ConflictContent-Type: application/json{"error": "A place with that name already exists."}
This is better than nothing but not ideal. Other information needs to be added to help with debugging, and to help the API client differentiate between errors.
There is a better way: RFC 9457 (opens in a new tab) which defines a standard way to return errors in JSON (or XML).
HTTP/2 409 ConflictContent-Type: application/problem+json{"type": "https://api.example.com/probs/duplicate-place","title": "A place with that name already exists.","detail": "A place with the name 'High Wood' already exists close to here, have you or somebody else already added it?","instance": "/places/123/errors/<unique-id>","status": 409}
More on this in the API Errors guide.