API Design
Returning response data

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 OK
Content-Type: application/json
Cache-Control: public, max-age=18000
RateLimit: "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 OK
Content-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.

Info Icon

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 Conflict
Content-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 Conflict
Content-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 Conflict
Content-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 Conflict
Content-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.