API Documentation Best Practices for OpenAPI Specs

API documentation best practices for OpenAPI: summaries that scan, examples that run, auth and error docs, and a CI check that stops docs drifting.

On this page

Good API documentation answers three questions for every operation: what it does, how to call it successfully, and what happens when the call fails. If your docs are rendered from an OpenAPI file, those answers live in the spec. Give every operation a short summary and a description that states the rules a caller would otherwise learn by trial and error. Attach at least one realistic request and response example. Declare authentication as a security scheme, not as a paragraph of prose. Document the 4xx responses with a real error schema. Then make CI fail when the spec and the code disagree, because a spec nobody checks drifts within a few releases.

This guide covers each of those, with a small invoices API scored before and after. For what the term covers in general, see the API documentation glossary entry.

Summaries scan, descriptions explain

The OpenAPI Specification gives every operation two text fields with different jobs. The summary is "a short summary of what the operation does". The description is the long form and supports CommonMark, so it can hold lists, links and code. Readers meet them in that order: they scan a list of summaries to find the operation they need, then read one description in full.

That gives you a rule for each. A summary is a verb phrase of two to six words that would make sense in a navigation sidebar: "List invoices", "Send an invoice", "Refund a payment". It does not repeat the path, and it does not end with a period. APIForge's endpoint list, for example, prints the summary under each path, so "GET /v1/invoices" followed by "Gets invoices" tells the reader nothing new. A description holds what the path and schema cannot say: default sort order, which records are excluded, side effects, idempotency, rate limits that apply to this operation only, and anything a caller has to do first.

FieldWeakUseful
summaryThis endpoint is used to retrieve the list of invoice objects.List invoices
descriptionReturns invoices.Returns invoices newest first. Drafts are excluded unless status=draft is passed.
summaryPOST /v1/invoices/{id}/sendSend an invoice
descriptionSends the invoice.Emails the invoice to the customer and moves it from draft to open. Calling it twice does not send a second email.
The useful versions state behavior the reader cannot infer from the path.

Apply the same split to tags (a tag description says what the group of operations is for), to parameters (describe the allowed values and the default, not the type, which the schema already shows), and to schema properties. A property called amount with no description forces every client developer to guess the unit. "Amount in the smallest currency unit" settles it in five words.

A worked example: scoring an undocumented spec

Here is a spec that parses and validates without errors. Three of its four operations have no summary or description, request bodies are untyped objects, and only one operation documents an error.

YAMLinvoices-before.yaml
openapi: 3.1.0
info:
  title: Invoices API
  version: 1.4.0
paths:
  /v1/invoices:
    get:
      operationId: listInvoices
      responses:
        "200":
          description: OK
    post:
      operationId: createInvoice
      requestBody:
        content:
          application/json:
            schema:
              type: object
      responses:
        "201":
          description: Created
  /v1/invoices/{id}:
    get:
      operationId: getInvoice
      summary: Get an invoice
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: OK
        "404":
          description: Not found
  /v1/invoices/{id}/send:
    post:
      operationId: sendInvoice
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "202":
          description: Accepted

APIForge scores this spec 84.2 out of 100. The documentation category gets 2.5 of its 10 points and error handling gets 2.5 of 10, because only GET /v1/invoices/{id} has a summary and only that operation documents a 4xx response. Structure loses 0.8 points for the four-segment send path. The issue list names the three operations with no summary or description and the three with no error responses.

The documented version below fixes both categories and adds the parts the score does not measure: a description on the API itself, a security scheme, a request example and an error body. It scores 100.

YAMLinvoices-after.yaml
openapi: 3.1.0
info:
  title: Invoices API
  version: 1.4.0
  description: |
    Create, list, and send invoices. All requests need an API key
    from Settings > Developers. Amounts are integers in the
    smallest currency unit (cents for USD).
servers:
  - url: https://api.example.com
security:
  - ApiKeyAuth: []
paths:
  /v1/invoices:
    get:
      operationId: listInvoices
      summary: List invoices
      description: Returns invoices newest first. Draft invoices are excluded unless status=draft is passed.
      tags: [Invoices]
      parameters:
        - name: status
          in: query
          description: Filter by invoice status.
          schema:
            type: string
            enum: [draft, open, paid, void]
      responses:
        "200":
          description: A page of invoices.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Invoice"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createInvoice
      summary: Create an invoice
      description: Creates a draft invoice. Nothing is emailed until you call the send operation.
      tags: [Invoices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceInput"
            examples:
              oneLine:
                summary: Single line item in USD
                value:
                  customerId: cus_4821
                  currency: usd
                  lines:
                    - description: Design review, 3 hours
                      amount: 45000
      responses:
        "201":
          description: The draft invoice.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: The body is valid JSON but breaks a business rule, for example an unknown customerId.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: https://api.example.com/problems/unknown-customer
                title: Unknown customer
                status: 422
                detail: No customer with id cus_4821 exists in this account.
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Secret key from Settings > Developers. Test keys start with sk_test_.
  responses:
    Unauthorized:
      description: The X-API-Key header is missing or the key was revoked.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
  schemas:
    InvoiceInput:
      type: object
      required: [customerId, currency, lines]
      properties:
        customerId:
          type: string
        currency:
          type: string
          description: ISO 4217 code, lowercase.
        lines:
          type: array
          minItems: 1
          items:
            type: object
            required: [description, amount]
            properties:
              description:
                type: string
              amount:
                type: integer
                description: Amount in the smallest currency unit.
    Invoice:
      allOf:
        - $ref: "#/components/schemas/InvoiceInput"
        - type: object
          required: [id, status]
          properties:
            id:
              type: string
              examples: [inv_9f31]
            status:
              type: string
              enum: [draft, open, paid, void]
    Problem:
      type: object
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
To keep the sample short, this version covers the two collection operations only. The scored file is exactly this text.

Examples a reader can paste and run

An example is often the first thing a developer copies. OpenAPI lets you put one on a Media Type Object, a Parameter Object, or a schema. On media types and parameters, example (a single value) and examples (a named map) are mutually exclusive, so pick one per location. Use the named map when an operation has more than one real use case: an invoice with one line item, an invoice with tax, an invoice in a currency without minor units. Each named example carries its own summary, so the reader can tell the cases apart before reading the payload.

  • Use values that look real and pass validation. "string" for an email and 0 for an amount teach nothing; "ana@example.com" and 45000 show the format and the unit.
  • Reuse IDs across examples. If the create example uses cus_4821, the get example should return cus_4821, so a reader can follow one record through the API.
  • Show an error example next to the success example, especially for 422, where the reader needs to know which field the server will complain about.
  • In OpenAPI 3.1, schemas follow JSON Schema 2020-12, which uses examples (an array). The singular example keyword on a schema still works but is deprecated in 3.1.
  • Validate examples against their schema in CI. An example that no longer matches the schema is worse than none, because readers trust it.

Document authentication in the spec

Authentication is where new users get stuck first, and prose alone does not help tools. Declare it in components.securitySchemes using one of the five scheme types the specification defines: apiKey, http (for bearer and basic), mutualTLS, oauth2 and openIdConnect. Apply it with a top-level security requirement so every operation inherits it. An operation can override the top-level requirement, and an empty array, security: [], removes it, which is how you mark a public health check or a login operation.

The scheme's description field is where the human answer goes: where to get a key, what a test key looks like, and how long a token lives. Then document what failure looks like. Give every protected operation a 401 response for a missing or invalid credential, and a 403 where a valid credential can still lack permission. Both should point at the same error schema. The OpenAPI authentication example shows a bearer scheme declared this way, and API security best practices covers what to put behind it.

Document errors as carefully as success

Client code spends more lines handling failures than successes, yet many specs list only 200. Document each error status an operation can really return, with a description that says what triggers it and what the caller should do. "400 Bad Request" repeats the status line. "The limit parameter is above 100" tells the reader how to fix the request.

Use one error schema across the API. RFC 9457 defines the application/problem+json media type with the members type, title, status, detail and instance, and allows extension members for problem-specific data. Its type member is a URI, and the RFC says dereferencing it should provide human-readable documentation for the problem type. That gives you a natural place for long-form error docs: one page per problem type, linked from every response that can return it. For the full pattern, see REST API error handling and the problem details response example.

Keep the docs in sync with CI

Docs drift because nothing fails when they do. A renamed field, a new required parameter, or an extra error status ships with the code, and the spec catches up later or never. Three checks in CI close most of that gap, and each catches a different kind of drift.

Three CI checks for API docs

  1. Validate the spec on every pull request

    Fail the build when the document does not parse, a $ref does not resolve, or a required field such as a response description is missing. Run the same check locally with the OpenAPI validator when you edit by hand.

  2. Regenerate and diff if your spec comes from code

    In a code-first setup, generate the spec in CI and compare it with the committed file. If they differ, someone changed an endpoint without updating the published contract. The workflow below does this with git diff.

    YAML
    name: openapi-sync
    on: [pull_request]
    jobs:
      spec:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - run: npm ci
          - run: npm run generate:openapi
          - run: git diff --exit-code openapi.json
  3. Test responses against the spec

    Call a staging deploy and validate each response body against the documented schema for its status code. This catches drift that neither of the first two checks can see, such as a serializer that renames a field. Validating API responses against your OpenAPI schema walks through it with Ajv.

The generate:openapi script stands in for whatever your framework uses to export its spec. Treat the committed openapi.json as the reviewed contract: a pull request that changes it should get the same review as a database migration. Version the spec in info.version, which the specification defines as the version of the OpenAPI document, separate from both the OpenAPI version and your implementation version.

Review checklist before you publish

  • Every operation has a verb-phrase summary and an operationId; operations with any rule a caller must know have a description.
  • Every parameter and schema property whose meaning is not obvious from its name has a description that states units, formats and defaults.
  • Every request body and every 2xx response has at least one example built from realistic values.
  • A security scheme is declared and applied, public operations use security: [], and protected operations document 401.
  • Every operation documents its error statuses with a shared error schema.
  • CI validates the spec, checks it against the code, and tests live responses against it.

To check the first and fifth items quickly, paste your spec into the API quality score. It lists every operation missing a summary or description and every operation with no documented 4xx or 5xx response, alongside the naming, HTTP and versioning findings. For a longer pre-release list, use the OpenAPI production checklist.

Frequently asked questions

The summary is a short label for the operation, used in lists and navigation, so keep it to a verb phrase such as "List invoices". The description is the long explanation and supports CommonMark. Put defaults, side effects and preconditions in the description.

Generate the reference pages from the spec and write guides, tutorials and migration notes by hand. Reference content changes with every release, so it should come from the file CI already checks. Hand-written guides explain workflows that span several operations, which a spec cannot express well.

Make CI fail when the spec and the service disagree. Validate the spec on every pull request, regenerate and diff it if it comes from code, and test live responses against the documented schemas. Each check catches a different kind of drift.

Sources

  1. OpenAPI Specification 3.1.0: Operation ObjectOpenAPI Initiative
  2. OpenAPI Specification 3.1.0: Security Scheme ObjectOpenAPI Initiative
  3. RFC 9457: Problem Details for HTTP APIsIETF