TL;DR
- Treat OpenAPI as a contract, not a brochure. Ship 3.1 specs with JSON Schema 2020‑12.
- Standardize errors with RFC 7807 Problem Details (
application/problem+json). - Pick a pagination pattern (cursor/keyset recommended) and make it consistent.
- Require operationId, stable tag buckets, and reusable components (schemas/responses/parameters).
- Add examples for every operation. Lint with Spectral, diff with oasdiff, test with Prism/Schemathesis/Dredd in CI.
- Version deliberately (/v1 + Sunset/Deprecation headers), design for idempotency, and document rate limits & webhooks.
1) Opinionated styleguide (bits that matter)
- OpenAPI 3.1.0, JSON Schema dialect 2020‑12 (
jsonSchemaDialectat root). - Resource‑oriented paths:
/users,/users/{user_id},/users/{user_id}/keys. Path params are snake_case and singular. - Nouns in paths, verbs in methods. No verbs in path segments.
- Always set:
operationId,summary,tags,requestBody(for POST/PUT/PATCH),responses. - Reuse
components:schemas,parameters,responses,headers,securitySchemes. - Document auth front‑and‑center; prefer OAuth2 or HTTP bearer; allow API keys only when necessary.
- Choose pagination (cursor) and filtering conventions and stick to them.
- Return Problem Details on 4xx/5xx, with a machine‑readable
typeand stable fields. - Provide examples and example cURL blocks for every endpoint.
- Mark deprecations with
deprecated: true+Sunset+Deprecationheaders; give timelines.
2) Copy‑paste template (OpenAPI 3.1)
openapi: 3.1.0
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
info:
title: Example API
version: 1.0.0
description: >
Contract-first, JSON Schema 2020-12. Errors use Problem Details.
servers:
- url: https://api.example.com/v1
tags:
- name: users
description: User accounts
- name: payments
description: Payment intents & charges
- name: webhooks
description: Incoming events you must handle
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
apiKey:
type: apiKey
in: header
name: X-API-Key
headers:
RateLimit-Limit:
description: Requests available in the current window
schema: { type: string }
RateLimit-Remaining:
description: Remaining requests in the current window
schema: { type: string }
RateLimit-Reset:
description: Seconds until reset
schema: { type: string }
Sunset:
description: RFC 8594 Sunset header
schema: { type: string, format: date-time }
parameters:
CursorParam:
name: cursor
in: query
description: Opaque pagination cursor
required: false
schema: { type: string }
LimitParam:
name: limit
in: query
description: Max items to return (1..100)
required: false
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
UserIdPath:
name: user_id
in: path
required: true
description: User identifier
schema: { type: string, pattern: "^[a-z0-9_\-]{1,64}$" }
IdempotencyKey:
name: Idempotency-Key
in: header
required: false
description: Unique key to safely retry write requests
schema: { type: string, maxLength: 80 }
responses:
Problem:
description: Error (RFC 7807)
headers:
RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }
RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }
RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" }
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
examples:
not_found:
value:
type: "https://docs.example.com/problems/not-found"
title: "Resource not found"
status: 404
detail: "User 'u_123' was not found"
instance: "/v1/users/u_123"
OkList:
description: OK
headers:
RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }
RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }
RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" }
content:
application/json:
schema:
type: object
required: [items, next_cursor, has_more]
properties:
items:
type: array
items: { $ref: "#/components/schemas/User" }
next_cursor:
type: [ "string", "null" ]
has_more:
type: boolean
schemas:
Problem:
type: object
required: [type, title, status]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer, minimum: 100, maximum: 599 }
detail: { type: string }
instance: { type: string }
errors:
description: Optional field errors
type: array
items:
type: object
required: [field, message]
properties:
field: { type: string }
message: { type: string }
User:
type: object
additionalProperties: false
required: [id, email, created_at]
properties:
id: { type: string, example: "u_123" }
email: { type: string, format: email }
name: { type: [ "string", "null" ] }
created_at: { type: string, format: date-time }
PaymentIntent:
type: object
additionalProperties: false
required: [id, amount, currency, status, client_secret]
properties:
id: { type: string, example: "pi_123" }
amount: { type: integer, minimum: 1, example: 1999 }
currency: { type: string, pattern: "^[a-z]{3}$", example: "usd" }
status: { type: string, enum: [requires_payment_method, processing, succeeded, canceled] }
client_secret: { type: string }
paths:
/users:
get:
operationId: listUsers
summary: List users
tags: [users]
security: [ { bearerAuth: [] } ]
parameters:
- $ref: "#/components/parameters/CursorParam"
- $ref: "#/components/parameters/LimitParam"
responses:
"200":
$ref: "#/components/responses/OkList"
default:
$ref: "#/components/responses/Problem"
post:
operationId: createUser
summary: Create a user
tags: [users]
security: [ { bearerAuth: [] } ]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
name: { type: string, maxLength: 120 }
examples:
minimal: { value: { email: "[email protected]" } }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
default:
$ref: "#/components/responses/Problem"
/users/{user_id}:
parameters:
- $ref: "#/components/parameters/UserIdPath"
get:
operationId: getUser
summary: Retrieve a user
tags: [users]
security: [ { bearerAuth: [] } ]
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
default:
$ref: "#/components/responses/Problem"
patch:
operationId: updateUser
summary: Update a user
tags: [users]
security: [ { bearerAuth: [] } ]
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
name: { type: [ "string", "null" ], maxLength: 120 }
examples:
rename: { value: { name: "Alice A." } }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
default:
$ref: "#/components/responses/Problem"
/payments/intents:
post:
operationId: createPaymentIntent
summary: Create a payment intent (idempotent)
tags: [payments]
security: [ { bearerAuth: [] } ]
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, currency]
properties:
amount: { type: integer, minimum: 1 }
currency: { type: string, pattern: "^[a-z]{3}$" }
responses:
"201":
description: Created
headers:
RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }
RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }
RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" }
content:
application/json:
schema: { $ref: "#/components/schemas/PaymentIntent" }
default:
$ref: "#/components/responses/Problem"
webhooks:
payment.succeeded:
post:
operationId: webhookPaymentSucceeded
summary: Receive payment succeeded events
tags: [webhooks]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
type: { const: "payment.succeeded" }
data:
type: object
properties:
payment_intent: { $ref: "#/components/schemas/PaymentIntent" }
responses:
"200":
description: Acknowledge receipt
default:
$ref: "#/components/responses/Problem"
Use
$refeverywhere to avoid drift. Keep schemas closed withadditionalProperties: falseunless you intentionally allow extension fields.
3) Errors that don’t surprise clients (Problem Details)
- Content type:
application/problem+json. - Always include:
type(URL to docs),title,status,detail,instance(optional), and a stableerrors[]array for field issues. - Never return plain strings for errors. Clients should always be able to parse a structured body.
4) Pagination, filtering, sorting
- Prefer cursor style:
?limit=20&cursor=abc...and return{ items, next_cursor, has_more }. - For sort/filter, document whitelisted fields. Avoid arbitrary
sort_byif you can’t guarantee stable order. - Document RateLimit-* headers and
Retry-Afterbehavior on429.
5) Auth, permissions, and idempotency
- Document securitySchemes (OAuth2, bearer, API keys), required scopes, and which endpoints need which.
- For writes, support
Idempotency-Keyon POST/PUT/PATCH to make retries safe. Specify retention window (e.g., 24h). - For webhooks, document signature verification and replay protection (timestamps + tolerance).
6) Versioning & deprecation
- Put the major version in the server URL (
/v1). Minor/patch changes are additive only (new fields, optional params). - Breaking changes → /v2. Publish migration guides and add
Sunset+Deprecationheaders with clear dates. - For schema evolution: only add fields; don’t change types; don’t remove required fields; don’t repurpose enums (add new values).
7) Tooling that pays off (CI gates)
- Lint: Stoplight Spectral with your style rules (operationId pattern, tag presence, no anonymous schemas).
- Diff: oasdiff to fail PRs on breaking changes (response schema tightening, removed fields, status code removals).
- Test: Prism (mock server), Dredd (request/response contract), Schemathesis (property‑based fuzzing, edge cases).
- Docs: Redoc/Swagger UI with auth controls, server selector, and code samples.
- SDKs: OpenAPI Generator only after the spec is clean; fork templates to enforce your naming conventions.
8) Examples everywhere (clients copy what they see)
- Include request/response examples for success and failure.
- Show cURL, JavaScript/TypeScript, Python, Go snippets.
- Keep example IDs realistic (
u_123,pi_123), timestamps in RFC 3339, and uuid where applicable.
9) Common pitfalls & fast fixes
| Pitfall | Why it hurts | Fix |
|---|---|---|
| No operationId | Bad SDK names, breaking clients | Require unique, verb‑noun operationId |
| Inconsistent pagination | Client spaghetti | Standardize on cursor contract across resources |
| Free‑form errors | Unparseable | Use Problem Details everywhere |
| Leaky enums | New values break clients | Advise clients to treat enums as open; document new values |
| Missing examples | Poor DX, copy‑paste bugs | Add examples for every 2xx/4xx/5xx |
| Nested anonymous schemas | Duplication & drift | Ref reusable components/schemas |
| Changing response shapes | Breaks SDKs | Only additive changes on v1; bump major for breaking |
| One giant YAML | Merge hell | Use multi‑file + bundle in CI (e.g., redocly build) |
Quick checklist
- [ ] OpenAPI 3.1, JSON Schema 2020‑12, lint with Spectral.
- [ ] Every op has operationId, summary, tags, examples.
- [ ] Problem Details for 4xx/5xx; rate‑limit headers documented.
- [ ] Cursor pagination (+ filters/sorts), consistent across endpoints.
- [ ] Idempotency‑Key for writes; webhook signature docs.
- [ ] /v1 in URL; Sunset/Deprecation headers for old endpoints.
- [ ] CI: oasdiff for breaking changes, Prism/Dredd/Schemathesis tests.
- [ ] Redoc/Swagger UI with auth + code samples.
- [ ] Bundle & publish spec with every release; version the spec file too.
One‑minute adoption plan
- Convert your spec to 3.1 and set
jsonSchemaDialect. Add Problem Details response and RateLimit headers incomponents. - Pick cursor pagination and refactor list endpoints to
{ items, next_cursor, has_more }. - Add operationId, tags, and examples to all ops; move inline schemas to
components. - Wire Spectral + oasdiff in CI; fail on breaking changes.
- Stand up Redoc with a “Try it” console and publish it automatically from main.