A good OpenAPI response definition lists every status code the operation can return, gives each one a description and a schema, and puts the shared shapes in components so they are defined once. Inside each schema, list the fields that are always present in a required array, mark fields that can hold null as nullable, and keep the two ideas apart. Declare the headers clients depend on, such as Location after a create. Use oneOf with a discriminator when a field can take several shapes, and give every error the same body, ideally RFC 9457 problem details. This guide walks through each of those decisions on a small payments API, from a weak first draft to a finished spec.
A typical first draft
Here is a response section as it often looks after a first pass, or after a framework generated it from code with no annotations. It is structurally valid OpenAPI 3.1. The only status code is 200, the create operation returns an object with no properties, nothing is required, and method is typed as an object with no further detail.
openapi: 3.1.0
info:
title: Payments API
version: 1.0.0
paths:
/v1/payments:
post:
summary: Create a payment
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
/v1/payments/{id}:
get:
summary: Get a payment
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
id:
type: string
amount:
type: integer
status:
type: string
method:
type: objectA client generated from this file knows almost nothing. Every field on a payment is optional in the generated type, the create call appears to return an empty object, and there is no hint of what a failure looks like. The API probably returns 201 on create and 404 for an unknown id, but readers have to discover that by sending requests.
We ran this file through APIForge's deterministic scorer (the same engine behind the API quality score). It scored 90 out of 100. Naming, HTTP usage, structure, consistency, versioning and documentation all received full marks, and error handling received 0 out of 10, with two issues: POST /v1/payments and GET /v1/payments/{id} each have no documented 4xx or 5xx response. The scorer checks that success and error responses exist. It does not look inside the schemas, so an untyped object and a missing required array cost nothing on the score. The rest of this guide covers the work the score cannot see.
Declare every status code the operation returns
The Responses Object maps status codes to Response Objects. The OpenAPI Specification requires at least one entry and gives each entry a required description. Keys can be an exact code such as "201", a range such as "4XX" (uppercase X), or default for anything not listed. When an exact code and a range both match, the exact code wins. Quote the keys in YAML: the 3.0.3 specification says the field must be enclosed in quotation marks for compatibility between JSON and YAML.
Document the codes a client should handle differently, not every code HTTP defines. A reasonable set per operation type looks like this:
| Operation | Success | Errors worth declaring explicitly |
|---|---|---|
| POST create | 201 with a Location header and the new resource | 400 or 422 for invalid input, 409 for a conflict such as a duplicate key |
| GET one item | 200 with the resource | 404 when the id does not exist |
| GET a list | 200 with a page object, never a bare array if you may add paging later | 400 for invalid filter or cursor values |
| PUT or PATCH | 200 with the updated resource, or 204 with no body | 404, 409 for version conflicts, 422 for invalid changes |
| DELETE | 204 with no content | 404, or 409 when other records still depend on it |
| Any operation | 401 and 403 when auth applies, plus default for unexpected failures |
Use default as the catch-all for errors, not as a substitute for the success response. A spec whose only entry is default tells a generator nothing about the happy path. The HTTP status codes reference covers the meaning of each code if you need to settle a 400 versus 422 debate.
A response with no body should have no content key. A 204 is described with a description only. Adding content with an empty schema suggests a JSON body that the server never sends, and some generated clients will then try to parse it.
Required, optional and nullable are separate decisions
Two questions apply to every property: is the key always present, and can its value be null? The required array answers the first. Nullability answers the second. Mixing them up produces wrong client types, because a generator has no other way to learn that a field is always there.
| Contract | required array | Type in OpenAPI 3.1 | Type in OpenAPI 3.0 |
|---|---|---|---|
| Always present, never null | Listed | type: string | type: string |
| Always present, may be null | Listed | type: [string, "null"] | type: string with nullable: true |
| Sometimes absent, never null | Not listed | type: string | type: string |
| Sometimes absent, may be null | Not listed | type: [string, "null"] | type: string with nullable: true |
Avoid the last row. If a client must handle both a missing key and a null value, the two states usually mean the same thing, and you have doubled the checks for no gain. Pick one. In the payments example, capturedAt is always present and null until the payment is captured, which lets a client render a pending state from a single field. failureReason is absent unless the status is failed, so it stays out of the required array.
capturedAt:
type: [string, "null"]
format: date-time
description: Null until the payment is captured
failureReason:
type: string
description: Present only when status is failedOpenAPI 3.0 schemas only accept a single string for type and use the nullable keyword, which per the 3.0.3 specification adds null to the allowed values. OpenAPI 3.1 schemas are JSON Schema 2020-12, so null is a type like any other and nullable no longer exists. Writing a type array in a 3.0 document fails structural validation, and nullable in a 3.1 document is ignored by a 3.1 validator.
readOnly helps when one schema serves both the request and the response. Mark server-generated fields such as id with readOnly: true. The specification says such properties should not be sent in a request, so tools can drop them from request types while keeping them required in responses. The example uses a separate PaymentCreate schema instead, which is easier to read when the request and response differ by more than one or two fields.
Reuse shapes through components
Put each named shape in components/schemas and reference it with $ref. Put whole responses that repeat across operations, such as a standard error, in components/responses. The payment schema is then defined once and used by both the create and the get operation, and a generator emits a single Payment type instead of two anonymous ones with names like InlineResponse200.
responses:
"404":
$ref: "#/components/responses/Problem"
default:
$ref: "#/components/responses/Problem"Three details trip people up. Component names must match the pattern ^[a-zA-Z0-9.\-_]+$, so "Payment Method" with a space is not allowed. In OpenAPI 3.0, a Reference Object replaces the whole object: the 3.0.3 specification says other properties beside $ref must be ignored, so a description written next to a $ref disappears. In 3.1 schemas, $ref is an ordinary JSON Schema keyword and sibling keywords apply. Finally, splitting a spec into components makes typos in reference paths easy; the common OpenAPI errors guide shows the exact message and fix.
Document response headers
Headers are part of the response contract. A client that follows a 201 to the new resource needs Location, and a client that backs off politely needs your rate limit headers. Declare them under headers in the Response Object. Header Objects follow the Parameter Object structure but take their name from the map key, so name and in must not be specified. The specification also says a header named Content-Type is ignored, because the media type key under content already says what the body is.
"201":
description: Payment created
headers:
Location:
description: URL of the new payment
schema:
type: string
format: uri
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"Headers that appear on every response, such as rate limit counters, can live in components/headers and be referenced from each response. Document them where they are actually sent; if 429 responses carry Retry-After and 200 responses do not, say so per status code. The rate limiting guide covers which headers to send.
Model variants with oneOf and a discriminator
A payment can be made by card or by bank transfer, and each method carries different fields. Folding them into one object with every field optional loses the rule that a card always has last4 and a transfer always has an iban. oneOf keeps the rule: each variant is its own schema with its own required list, and the discriminator tells tools which variant to pick from a property value.
PaymentMethod:
oneOf:
- $ref: "#/components/schemas/CardMethod"
- $ref: "#/components/schemas/BankTransferMethod"
discriminator:
propertyName: type
mapping:
card: "#/components/schemas/CardMethod"
bank_transfer: "#/components/schemas/BankTransferMethod"
CardMethod:
type: object
required: [type, last4, brand]
properties:
type:
type: string
const: card
last4:
type: string
brand:
type: stringThe Discriminator Object has one required field, propertyName, and it applies only alongside oneOf, anyOf or allOf. The specification expects the named property to be present in the payload, so list it in every variant's required array. Without the mapping, tools fall back to matching the property value against schema names, which breaks as soon as the value is bank_transfer and the schema is BankTransferMethod. The const on each variant matters for validators: the discriminator is a hint for tools, while const is what makes exactly one branch of the oneOf match. Note that const is a 3.1 keyword; in a 3.0 document, use a one-item enum instead.
Variants or one object?
| Scenario | Recommendation | Alternative |
|---|---|---|
| The shapes share most fields and differ by one or two optional ones | Use one object and document when the optional fields appear. | — |
| Each shape has its own required fields | Use oneOf with a discriminator and a const on the tag property. | Use allOf to extend a shared base if the variants also share several required fields. |
| The variant depends on the status code, not on a field | Use separate response entries per status code instead of oneOf. | — |
Give every error the same shape
Clients write one error handler. If 400 returns {"error": "..."}, 404 returns {"message": "..."} and 500 returns HTML, that handler becomes a pile of special cases. Define one error schema and use it for every 4xx and 5xx response. RFC 9457, which replaced RFC 7807, gives you a ready-made one: the application/problem+json media type with the members type, title, status, detail and instance.
A few rules from the RFC shape the schema. When type is missing, its value is assumed to be about:blank. status is advisory, a copy of the real HTTP status for the client's convenience, so the two should always agree. detail is meant to help the client fix the problem, so it is the wrong place for stack traces. Problem types can add extension members, which is how the ValidationProblem below adds a list of field errors without changing the base shape.
Problem:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri-reference
default: about:blank
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
format: uri-reference
ValidationProblem:
allOf:
- $ref: "#/components/schemas/Problem"
- type: object
required: [errors]
properties:
errors:
type: array
items:
type: object
required: [pointer, detail]
properties:
pointer:
type: string
description: JSON Pointer to the invalid field
detail:
type: stringWrap the schema in components/responses with the content type application/problem+json, then reference it from each operation. The REST API error handling guide covers what the server should put in each field, and the error response example shows a smaller version.
The revised spec
Here is the payments API with all of the above applied. It passes structural validation as OpenAPI 3.1.
openapi: 3.1.0
info:
title: Payments API
version: 1.0.0
paths:
/v1/payments:
post:
operationId: createPayment
summary: Create a payment
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PaymentCreate"
responses:
"201":
description: Payment created
headers:
Location:
description: URL of the new payment
schema:
type: string
format: uri
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"
"422":
$ref: "#/components/responses/ValidationProblem"
default:
$ref: "#/components/responses/Problem"
/v1/payments/{id}:
get:
operationId: getPayment
summary: Get a payment
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The payment
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"
"404":
$ref: "#/components/responses/Problem"
default:
$ref: "#/components/responses/Problem"
components:
responses:
Problem:
description: Error in RFC 9457 problem details format
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
ValidationProblem:
description: The request body failed validation
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ValidationProblem"
schemas:
PaymentCreate:
type: object
required: [amount, currency, method]
properties:
amount:
type: integer
minimum: 1
currency:
type: string
pattern: "^[A-Z]{3}$"
method:
$ref: "#/components/schemas/PaymentMethod"
Payment:
type: object
required: [id, amount, currency, status, method, capturedAt]
properties:
id:
type: string
readOnly: true
amount:
type: integer
description: Amount in the smallest currency unit
currency:
type: string
pattern: "^[A-Z]{3}$"
status:
type: string
enum: [pending, succeeded, failed]
capturedAt:
type: [string, "null"]
format: date-time
description: Null until the payment is captured
failureReason:
type: string
description: Present only when status is failed
method:
$ref: "#/components/schemas/PaymentMethod"
PaymentMethod:
oneOf:
- $ref: "#/components/schemas/CardMethod"
- $ref: "#/components/schemas/BankTransferMethod"
discriminator:
propertyName: type
mapping:
card: "#/components/schemas/CardMethod"
bank_transfer: "#/components/schemas/BankTransferMethod"
CardMethod:
type: object
required: [type, last4, brand]
properties:
type:
type: string
const: card
last4:
type: string
brand:
type: string
BankTransferMethod:
type: object
required: [type, iban]
properties:
type:
type: string
const: bank_transfer
iban:
type: string
Problem:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri-reference
default: about:blank
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
format: uri-reference
ValidationProblem:
allOf:
- $ref: "#/components/schemas/Problem"
- type: object
required: [errors]
properties:
errors:
type: array
items:
type: object
required: [pointer, detail]
properties:
pointer:
type: string
description: JSON Pointer to the invalid field
detail:
type: stringAPIForge's scorer gives this version 100 out of 100, up from 90, because both operations now document error responses. The bigger change is one the score does not measure. A generated client now has a Payment type with six required fields, a nullable capturedAt, a PaymentMethod union it can switch on, and one Problem type for every failure.
- Each operation lists its success code and the error codes a client handles differently, plus default.
- Every object schema has a required array, and nullable fields use the syntax for the document's version.
- Shared shapes live in components/schemas and shared responses in components/responses.
- Headers such as Location are declared on the status codes that send them.
- Variant fields use oneOf with a discriminator and a const tag.
- All errors use one problem details schema under application/problem+json.
Then check the server against it
A carefully modeled response schema only helps if the running API matches it. Once the spec is in shape, compare real responses to it: the guide on how to validate API responses against the OpenAPI schema shows how to do that in the browser and in CI. For the rest of the document, the OpenAPI production checklist covers what to review before publishing.