OpenAPI Response Design: Status Codes, Nullable, Errors

OpenAPI response design best practices: document every status code, separate optional from nullable, reuse components, and model headers and RFC 9457 errors.

On this page

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.

YAMLpayments-before.yaml
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: object

A 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:

OperationSuccessErrors worth declaring explicitly
POST create201 with a Location header and the new resource400 or 422 for invalid input, 409 for a conflict such as a duplicate key
GET one item200 with the resource404 when the id does not exist
GET a list200 with a page object, never a bare array if you may add paging later400 for invalid filter or cursor values
PUT or PATCH200 with the updated resource, or 204 with no body404, 409 for version conflicts, 422 for invalid changes
DELETE204 with no content404, or 409 when other records still depend on it
Any operation401 and 403 when auth applies, plus default for unexpected failures
A starting point. Declare a code when its body or its meaning differs from default.

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.

Contractrequired arrayType in OpenAPI 3.1Type in OpenAPI 3.0
Always present, never nullListedtype: stringtype: string
Always present, may be nullListedtype: [string, "null"]type: string with nullable: true
Sometimes absent, never nullNot listedtype: stringtype: string
Sometimes absent, may be nullNot listedtype: [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.

YAML
capturedAt:
  type: [string, "null"]
  format: date-time
  description: Null until the payment is captured
failureReason:
  type: string
  description: Present only when status is failed
OpenAPI 3.1 syntax. In 3.0, write type: string and nullable: true.

OpenAPI 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.

YAML
responses:
  "404":
    $ref: "#/components/responses/Problem"
  default:
    $ref: "#/components/responses/Problem"
Operations point at a shared response instead of repeating it.

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.

YAML
"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.

YAML
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 follows the same pattern with type, const bank_transfer, and iban.

The 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?

ScenarioRecommendationAlternative
The shapes share most fields and differ by one or two optional onesUse one object and document when the optional fields appear.—
Each shape has its own required fieldsUse 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 fieldUse 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.

YAML
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: string

Wrap 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.

YAMLpayments-after.yaml
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: string

APIForge'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.

Frequently asked questions

Optional means the key may be missing, which you express by leaving the property out of the required array. Nullable means the key may hold null, which is nullable: true in OpenAPI 3.0 and a type list that includes "null" in 3.1. They are independent, and a property can be required and nullable at the same time.

Use both. Declare the codes a client reacts to differently, such as 404 or 409, and add default for anything unexpected. A spec with only default hides the errors clients most need to plan for.

Validators do not need it, because each variant's const or required fields already decide which branch matches. Code generators and documentation tools use the discriminator to pick the variant type without trying each one, so add it with an explicit mapping.

Sources

  1. OpenAPI Specification 3.1.0: Responses, Header and Discriminator ObjectsOpenAPI Initiative
  2. OpenAPI Specification 3.0.3: nullable and the Reference ObjectOpenAPI Initiative
  3. RFC 9457: Problem Details for HTTP APIsIETF