OpenAPI best practices are mostly about the document, not the API behind it. Give every operation a unique, readable operationId, because code generators turn it into a method name. Declare tags at the top level so docs group operations in a sensible order. Move anything used twice into components and point to it with $ref. Add named examples that match their schemas. List real servers, declare authentication once in securitySchemes, and run a linter on every pull request so these rules hold after the first author moves on. This guide covers each of those with the OpenAPI 3.1 rules behind them and a worked before and after example.
Resource naming, HTTP methods, and status codes are design questions with their own guide, REST API design best practices. Everything here assumes the design is settled and asks how to write it down well.
Fill in info and servers first
The info object needs only title and version, and version trips people up. The OpenAPI Specification defines it as the version of the OpenAPI document, distinct from both the OpenAPI Specification version and the API implementation version. Bump it whenever the document changes, so a client team can see that the spec they generated from is stale. It does not have to match the /v1 in your paths; the API versioning guide covers how to version the API itself.
If servers is missing or empty, the specification says the default is a single server with the URL /. Interactive docs then send try-it requests to whatever host serves the docs page, which is rarely your API. List each environment a reader may call, with a description, and put the sandbox before production if you want readers to experiment safely. Server variables can template the URL, for example a region or tenant subdomain, and each variable must have a default. Path items and operations can override servers, which is useful when one endpoint, such as file upload, lives on a different host.
servers:
- url: https://sandbox.api.example.com
description: Sandbox with test data
- url: https://{region}.api.example.com
description: Production
variables:
region:
enum: [eu, us]
default: euGive every operation a stable operationId
The specification requires operationId to be unique among all operations, treats it as case-sensitive, and recommends common programming naming conventions because tools may use it to identify the operation. Generators for TypeScript, Python, Go, and other languages take it as the function name. Without it, each generator invents a name from the method and path, and you get names like getV1InvoicesId that change when the path changes.
| operationId | Problem | Better |
|---|---|---|
| (missing) | Generated name depends on the tool and the path | listInvoices |
| get_v1_invoices_id | Encodes the path and version, so it changes when they change | getInvoice |
| invoice | Says nothing about the action | getInvoice or updateInvoice |
| GetInvoice and getInvoice in one file | Unique by case only, so easy to misread, and a generator that changes casing can collide them | Pick one casing for the whole document |
| handleRequest2 | Meaningless to a reader of the SDK | voidInvoice |
Treat operationIds as public names. Renaming one renames a method in every generated SDK, which breaks client code even though no HTTP request changed. If a name is wrong, add the new operation, mark the old one deprecated: true, and remove it in the next major version. Links and some tools also refer to operations by operationId, so a rename can break references inside the document too.
Group operations with declared tags
Docs renderers commonly group operations by tag in the navigation, and some code generators use the tag to split the client into classes such as InvoicesApi and CustomersApi. Declare every tag in the top-level tags array with a description. The specification says tools can use the order of that array, and that tags used by operations but not declared there may be ordered randomly or by the tool's own logic. Give each operation one primary tag. An operation with three tags can appear under each of them in the sidebar and in each generated class.
Reuse definitions through components
The components object holds reusable schemas, responses, parameters, examples, request bodies, headers, security schemes, links, callbacks, and, in 3.1, path items. Keys must match the pattern ^[a-zA-Z0-9\.\-_]+$, so a schema named Invoice Line fails and InvoiceLine works. The reason to reuse is drift. Two inline copies of the same object are edited at different times by different people, and they stop agreeing. In the before example below, one invoice schema types total as an integer and the other as a number, which is exactly that drift.
- Extract an object into components/schemas as soon as two operations use it. Generators produce one named type per component, so Invoice becomes one class instead of InvoicesGet200ResponseItem and InvoicesIdGet200Response.
- Put shared parameters, such as a page size limit or a path ID, in components/parameters. A parameter used by every operation on a path can sit at the path item level instead of being repeated under each method.
- Keep shared response definitions in components/responses. The error handling guide shows this for problem details bodies.
- Leave one-off request bodies inline. A component used once adds a jump for the reader and no consistency benefit.
- Split very large documents into several files with relative $ref paths only when your tooling resolves them. Some tools accept only a single bundled file.
Write examples readers and tools can use
Examples are what most readers look at first, and mock servers and docs tools render them directly. In a media type or parameter, example and examples are mutually exclusive. Prefer examples, the map of named Example objects, when a response has more than one interesting state, such as a paid invoice and a draft with no total. Each named example gets a summary that docs tools show in a dropdown.
In OpenAPI 3.1 the example keyword inside a Schema Object is deprecated in favor of the JSON Schema examples keyword, which takes an array. Whichever you use, the values should validate against the schema. The specification says media type examples SHOULD match the schema, and JSON Schema says the same for examples, but neither is enforced, so stale examples are common. An example with totalCents as "129.00" when the schema says integer teaches every reader the wrong type.
Declare security once with securitySchemes
Define each authentication method under components/securitySchemes. The allowed types in 3.1 are apiKey, http, mutualTLS, oauth2, and openIdConnect. For bearer tokens use type: http with scheme: bearer; bearerFormat: JWT is a documentation hint and changes nothing at runtime. Then apply security in one of three ways.
- Top-level security applies to every operation. Use it when almost every endpoint needs the same credentials.
- Operation-level security overrides the top-level value. An empty array, security: [], removes authentication from that one operation, which is how you mark a public health check or login endpoint.
- An empty object inside the array, security: [{}, {bearerAuth: []}], makes authentication optional, for endpoints that return more data to signed-in callers.
Entries in the security array are alternatives: a request needs to satisfy only one of them. Two schemes inside a single entry are both required. Mixing those up is how a spec ends up saying that an API key alone is enough for an admin endpoint. For OAuth2, list the scopes each operation needs in its security requirement so readers and gateways can see them per endpoint. The authentication example shows a full securitySchemes block.
Before and after: one small spec
AvoidBefore: valid OpenAPI, hard to use
openapi: 3.1.0
info:
title: Invoices API
version: "1"
paths:
/v1/invoices:
get:
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
type: object
properties:
id: { type: string }
total: { type: integer }
status: { type: string }
/v1/invoices/{id}:
get:
summary: Get invoice
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
id: { type: string }
total: { type: number }
status: { type: string }
"404":
description: Not foundNo servers, so try-it calls go to /. No operationIds, tags, security, or examples. The invoice object is written twice and the copies disagree on the type of total. The list operation has no summary.
PreferAfter: the same two operations, written for tools
openapi: 3.1.0
info:
title: Invoices API
version: 1.4.0
description: Create, list, and void customer invoices.
servers:
- url: https://api.example.com
description: Production
- url: https://sandbox.api.example.com
description: Sandbox with test data
tags:
- name: Invoices
description: Billing documents sent to customers
security:
- bearerAuth: []
paths:
/v1/invoices:
get:
tags: [Invoices]
operationId: listInvoices
summary: List invoices
parameters:
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: A page of invoices
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Invoice"
"401":
$ref: "#/components/responses/Problem"
/v1/invoices/{id}:
parameters:
- $ref: "#/components/parameters/InvoiceId"
get:
tags: [Invoices]
operationId: getInvoice
summary: Get one invoice
responses:
"200":
description: The invoice
content:
application/json:
schema:
$ref: "#/components/schemas/Invoice"
examples:
paid:
summary: A paid invoice
value:
id: inv_1042
totalCents: 12900
currency: EUR
status: paid
draft:
summary: A draft with no total yet
value:
id: inv_1043
totalCents: 0
currency: EUR
status: draft
"404":
$ref: "#/components/responses/Problem"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
InvoiceId:
name: id
in: path
required: true
description: Invoice identifier, for example inv_1042
schema:
type: string
Limit:
name: limit
in: query
description: Page size
schema:
type: integer
minimum: 1
maximum: 100
default: 20
schemas:
Invoice:
type: object
required: [id, totalCents, currency, status]
properties:
id:
type: string
totalCents:
type: integer
description: Amount in the smallest currency unit
currency:
type: string
pattern: "^[A-Z]{3}$"
status:
type: string
enum: [draft, open, paid, void]
responses:
Problem:
description: Error in problem details format
content:
application/problem+json:
schema:
type: object
properties:
title:
type: string
status:
type: integerServers list both environments. Each operation has a tag, an operationId, and a summary. Invoice is defined once, the ID parameter sits at the path level, two named examples show different states, and bearer auth applies to every operation.
We ran both files through APIForge's design score. The before version scored 90 out of 100, losing 5 points for documentation because the list operation has no summary and 5 for error handling because the list operation declares no 4xx or default response. The after version scored 100. Adding operationIds, tags, servers, security, examples, and the shared Invoice schema did not change the number. The score grades API design from paths, methods, response codes, and operation descriptions. It does not read authoring details, so a spec can score 100 and still generate an SDK full of getV1InvoicesId methods.
Lint the spec on every pull request
Rules that live only in a style guide stop being followed. Put them in CI so a pull request that breaks one fails before review. A useful first rule set checks things a reviewer misses when reading a diff.
A lint pipeline for an OpenAPI repository
Parse and resolve every $ref
Fail on YAML syntax errors and on any $ref that points to nothing. A broken reference often renders as an empty schema in docs instead of an error.
Check authoring rules
Every operation has an operationId, a summary, and exactly one tag; operationIds are unique; every tag is declared at the top level; servers is not empty; every operation is covered by a security requirement or an explicit security: [].
Validate examples against their schemas
Run each example value through a JSON Schema validator so stale examples fail the build instead of misleading readers.
Score the design
Run a design check for naming, methods, versioning, and documented responses, and fail the build if the score drops below the value on the main branch.
APIForge covers the first and last steps in the browser. The OpenAPI validator parses YAML or JSON, resolves $ref pointers, and lists the operations it found, and the API quality score grades the design across seven categories. The middle steps need a rule-based linter you can configure; the APIForge vs Spectral comparison explains where each fits. Before a release, walk through the OpenAPI production checklist, and if the validator rejects your file, common OpenAPI errors lists the usual causes.