API Advice
Designing REST APIs: Responses Your Users Expect
Nolan Sullivan
August 6, 2024
This article shows you how to use your OpenAPI specification to design API responses that are efficient, informative, and user-friendly.
User-First Design
API requests are only as useful as their responses. So, to design a useful API, you must consider how to structure responses that make sense for your users. A good user experience requires an API that either responds with appropriate data or tells users how to recover from errors.
One way to enhance user experience is to take the design-first approach: Write your OpenAPI specification before you code. Starting from a specification puts the user experience at the first step of the development process. The specification has well-defined properties for schemas and responses, which helps you consider how to structure your design. Designing first also helps contain development scope, since developers can code to a precise specification.
This article is a guide to using the OpenAPI specification to design responses. Regardless of whether your API returns a successful ‘200’ status code or an error with a ‘500’ status code, OpenAPI provides you with the tools and methodologies to meticulously design your API responses for optimal performance and seamless user experiences. In the following sections, we will delve into the key principles and best practices to ensure your API responses meet the highest standards of usability, efficiency, and effectiveness.
Principles of Good API Style
Before you start writing responses, consider the following guiding principles of API design. From data structure to error messages, these principles should inform every aspect of how you approach responses.
Be as Explicit as Possible
Describe the data the API returns as explicitly as possible. The more explicit and precise you are, the easier it is for users and their systems to interpret returned values.
The OpenAPI Specification has many properties to describe a data field. Every field requires a defined data type. Beyond this, when possible, also provide additional information about the data format and range, for example, a string may have minLength
and maxLength
values. Precise and predictable information helps users handle responses.
Also be explicit in how you name your data properties. Overly general names (like type
) describe many things. More specific names (like userType
) immediately provide context about their data.
Explicitness also helps users interpret response descriptions. For example, List of users
is not as helpful as List of users with active subscriptions
.
Be Consistent
Most schemas in OpenAPI can be referenced and reused. Reuse adds consistency and makes writing easier.
Consistent naming conventions make it easier for computers and humans to process information. Consistent response descriptions with parallel grammar and coherent phrasing make response bodies easier to read and interpret.
Prefer Flat… Until You Need to Nest
As the Google JSON Style Guide (opens in a new tab) recommends, “data should not be arbitrarily grouped.” Group data in objects only when the grouping makes semantic sense and is more convenient.
Each nested object in the specification requires another level of indentation. Be mindful of this indentation – too much might indicate that it’s time to refactor.
Of course, information hierarchies often do help categorize and sort information. Use your judgment to determine whether a grouping is worth the added complexity.
In the following sections, we’ll provide illustrative descriptions of common status codes for better understanding. For the full list, review the HTTP response status codes (opens in a new tab) reference. Now, let’s continue with the response descriptions that will help us craft precise and informative responses in the OpenAPI specification.
-
In the response object, provide a description.
Some descriptions are reusable for all status codes. Other descriptions may depend on the specific operation.
responses:"200":description: A new user was created.Be as specific as is reasonable. Often,
200
statuses use onlyOK
for the description, and that might be sufficient. -
Define content type and schema.
In the
content
object, describe the content type and the returned schema. Often the schema needs only to reference a structure described in thecomponents
object of your specification.REST APIs often return JSON content,
application/json
. But other formats, liketext/plain
orimg/png
, are common, too.
All together, a full responses
object looks something like this:
paths:/v1/user:post:operationId: createUserv1summary: Create user###responses:"200":content:application/json:schema:## Reuses the User object$ref: "#/components/schemas/User"description: OK"400":description: Bad request. User name must be a string.default:## A default response for cases that are undescribed$ref: "#/components/responses/default"
Note: Each response may also include response headers providing additional context about things like rate limits.
Status, description, and content: that’s all you need to describe a response. However, the best way to structure this content is highly contextually dependent.
The rest of this article recommends how you should describe this content for successful and unsuccessful requests.
Make Success Feel Successful
Broadly, users make requests for two reasons:
- To get resources
- To create or modify the status of a resource
So, your success responses must tell the user what succeeded and provide the resources they requested.
Give GETs Their Resources
Users typically expect GET requests to return some data. The structure and content of the data depend entirely on the resources the application offers. For specifics about how to describe this data, read our guide to data type formats (opens in a new tab).
However, no matter the specifics, the response to a GET is likely either:
- An object for a specifically requested item
- An array of these items
Many, if not most, of the items returned by a GET are composed of data structures that the API reuses in other requests and responses.
To keep your interface consistent and avoid tedious repetition, describe all reusable items in components/schemas
.
Then reference the object in your response.
For example, a GET request to a /users/{user_id}
probably returns a single user object:
"200":content:application/json:schema:$ref: "#/components/schemas/User"
A GET request to just /users
likely returns an array of these objects.
To describe this array, your schema might reference a Users
schema that contains an array of user
objects.
And for list operations like this, remember to paginate.
Large response bodies can become performance bottlenecks. Even if the listed objects are few at first, the number will grow with your user base.
In this schema, pagination is described by the offset
property.
users:description: A list of users to return.items:$ref: "#/components/schemas/User"type: arrayoffset:type: integerdescription: The page to returndefault: 1
Define Payloads for Other Methods by Use Case
For other methods, like PUT and POST, the payloads are more variable.
For example, if a POST creates an object, users may find it convenient for the API to return the created object.
However, returning the full object may not always be useful or practical, especially when there are performance constraints to consider.
In these cases, it may be sufficient to return only the newly created ID with a link, or a link to its resource (refer to the 201
status described in the subsequent section).
Similar advice applies to PUT and PATCH requests. If returning the object isn’t necessary, your response might require only a description that informs the user of the new status.
put:"200":description: User was updated.
Besides, POST requests often do more than create, since the flexible syntax lends itself to custom operations.
Going Beyond 200
200
is the most common success status, but not the only one.
If appropriate, consider including the following codes as responses for some operations.
201
: Lets users know the resource is created. Returns a link instead of an object.204
: Indicates an empty response body (and informs that an empty body is expected). Some APIs use the204
status to indicate success for operations that don’t require additional information or resources. For example, a successful DELETE request operates on resources that, by definition, no longer exist when its response is sent. A204 No Content
can indicate that the resource was successfully deleted and that the empty body is part of the defined behavior.
Return Errors That Inform and Help Recover
By definition, errors mean the API did not return a requested resource. If a call returns an error, tell users what happened and how they can recover.
Your specification should describe all known responses to codify errors and suggest appropriate recovery. For some errors, a status code and terse description provide enough information. Other errors may require different bodies for different operations.
Reuse Descriptions for Standard Errors
Some error statuses always have the same causes and the same response bodies.
For example, 429
errors always indicate that a rate limit was reached, and 418
errors always indicate that the user should make a cup of tea (opens in a new tab).
To minimize writing and to maintain message consistency for users,
define these responses in your components/responses
object and reuse them across definitions.
Here’s an example of a responses
object whose properties all refer to reusable error bodies:
responses:"401":$ref: "#/components/responses/401""403":$ref: "#/components/responses/403""404":$ref: "#/components/responses/404""429":$ref: "#/components/responses/429"
The content for these errors is described in the components
part of the specification:
components:responses:"401":description: Unauthorized. The request did not have a valid API key."403":description: Forbidden. This API key doesn't have necessary permissions."404":description: Not Found. Server cannot find the requested resource."429":description: Too Many Requests. The rate limit has been reached for this API key.
Provide Details To Help Fix Bad Requests
Other status codes may need more than a generic message.
For example, requests that receive a 400 Bad request
status often have invalid fields.
For these cases, consider giving each relevant operation a unique 400
description that describes its necessary fields.
/v1/user:post:summary: Create userresponses:"400":description: Bad request. Operation requires valid `username` and `email` fields.content:schema:$ref: "#/components/schemas/FailedUserCreation"
A caveat for this recommendation is that the number of response bodies can be very large, especially if responses are dynamically generated based on field-validation errors.
It may be impractical to describe all error cases.
As a workaround, some specification authors write only a specific description
(as in the preceding snippet), then refer to a generic 400
schema with placeholder values.
Multi-Purpose Response Descriptions
When referencing a reusable component is insufficient, the OpenAPI Specification has a few features to make descriptions more flexible. These features provide ways to join sets of schemas, define responses across a range of statuses, or provide default responses.
Join Schemas With allOf
You might want to compose a response from separate schemas.
For example, a request for a resource about an administrative user might include the basic user
object along with additional properties particular to administrators.
In this case, describe your schemas with the allOf
operator, putting each schema as an item in an array.
Admin:description: A user with admin privilegesallOf:- $ref: '#/components/schemas/User'- type: objectproperties:super_admin:type: booleandescription: Whether the user has super admin privileges
For another use of allOf
, the API may have basic and extended error models, as given in the example from the specification (opens in a new tab).
Select Schemas With anyOf
and oneOf
These operators describe responses that might contain some combination of schemas.
Similar to allOf
, these operators are defined in an array.
For example, a request to an endpoint called /one-binary-number
might return one of two possible schemas:
myBinarySymbol:oneOf:- $ref: '#/components/schemas/zero'- $ref: '#/components/schemas/one'
Alternatively, anyOf
could return one or both of the preceding references.
Status Ranges
Sometimes, a single response is enough for an entire numeric range of statuses.
For these times, use the nXX
convention (where n
is the number the status code starts with).
For example, you may want all 500 errors to return the same body:
'5XX':description: This was our fault. Please wait a minute and try again.
This
xx
description does not override other defined responses for the same error class. So you could describe, for example, the501
error explicitly, then use5xx
as a catch-all for all other server-side errors.
The Default Property
As this article has emphasized, specificity is generally preferred. But a well-defined default can also be important.
For these times, the responses
object also accepts a default
property:
paths:/health:get:responses:"200":description: OKdefault:## reusable default$ref: "#/components/responses/default"## default definitioncomponents:responses:default:content:application/json:schema:$ref: "#/components/schemas/Error"description: Default error response
Conclusion
If you choose the design-first approach, the OpenAPI Specification provides a great authoring medium, with well-defined properties to structure well-defined data. It also has a robust ecosystem of tooling, which comes with its own benefits, as you can use the specification to create SDKs, contract test, mock servers, and so on.
But no matter how you write your API, the principles of design don’t change. An API is as good as the value it returns. So, when you create an API, design its responses from the user’s perspective.
Further Reading
The following links are for canonical sources of information relevant to OpenAPI and HTTP responses.