OpenAPI & AsyncAPI Schema Authoring: A Complete Guide
Machine-readable API contracts are the source of truth for everything a developer portal ships: interactive reference docs, generated SDKs, mock servers, and the change history consumers depend on. When the contract is authored carefully and validated automatically, every downstream artifact stays correct for free. When it drifts, every artifact drifts with it. This guide is the practical blueprint for authoring, validating, and automating OpenAPI 3.1 and AsyncAPI 3.0 documents so that both your synchronous HTTP surface and your event-driven streams produce trustworthy portal assets without manual rework.
It is written for API engineers, platform teams, and technical writers who own the spec files in version control and want a contract-first workflow that holds up in continuous integration. You will find a side-by-side comparison of the two specifications, deep sections on resource structuring, reusable schema components, event-driven design, security and webhooks, and a complete governance pipeline you can paste into a repository today. Each section links to a focused guide where a topic needs more room than a single page allows.
OpenAPI 3.1 vs AsyncAPI 3.0: a quick reference
The two specifications share a heritage and, since OpenAPI 3.1, a schema dialect, but they describe fundamentally different interaction styles. Use this table to decide which document (or both) your product needs before you write a line of YAML.
| Dimension | OpenAPI 3.1 | AsyncAPI 3.0 |
|---|---|---|
| Paradigm | Request/response (a client calls, waits for a reply) | Publish/subscribe and message-driven (producers emit, consumers receive) |
| Primary transport | HTTP/HTTPS | Kafka, AMQP, MQTT, WebSocket, NATS, and others via bindings |
| Top-level structure | paths, webhooks, components |
channels, operations, components |
| Schema model | JSON Schema 2020-12 (full dialect) | JSON Schema 2020-12, plus pluggable schema formats (Avro, Protobuf) |
| Operation direction | Verb on a path (GET, POST, …) | send / receive actions on a channel |
| Security | securitySchemes (OAuth2, API key, OIDC, mutual TLS) |
securitySchemes including SASL, X.509, scram for brokers |
| Tooling | Spectral, Redocly CLI, Swagger UI, openapi-generator | Spectral, AsyncAPI CLI, AsyncAPI Studio, Modelina, Glee |
| Code generation | Client SDKs and server stubs from paths |
Application code, models, and consumer/producer scaffolds |
| Validation | redocly lint, spectral lint, AJV for examples |
asyncapi validate, spectral lint, AJV for message examples |
| Best fit | CRUD APIs, partner integrations, internal microservices | Streaming data, IoT telemetry, domain events, real-time updates |
A growing number of products ship both: a synchronous OpenAPI surface for commands and queries, and an AsyncAPI document for the events those commands emit. Because both align on JSON Schema 2020-12, the same payload model can be authored once and referenced from each document, which is the foundation the rest of this guide builds on.
The most consequential row in that table is the schema model. Before OpenAPI 3.0 the schema object was a constrained subset of JSON Schema with its own quirks, which meant validators built for JSON Schema could not be pointed at an OpenAPI document and tools had to special-case the dialect. OpenAPI 3.1 closed that gap by adopting JSON Schema 2020-12 wholesale, and AsyncAPI 3.0 did the same. The practical payoff is real: a single AJV validator can check example payloads from either document, a model authored for an HTTP request body can describe a Kafka message without edits, and the mental model you build for one specification transfers directly to the other. Treat that shared dialect as the reason to invest in a clean component layer early, because every hour spent there is repaid across both surfaces.
Contract-first foundations
Contract-first means the specification is written and reviewed before any handler code exists. The contract becomes the negotiation artifact between producer and consumer teams, and code generation flows outward from it. The alternative, deriving the spec from annotated source, couples the public contract to one language’s idioms and routinely leaks internal naming and accidental fields into the API.
Establish a predictable repository layout so tooling and reviewers always know where to look. Keep routing definitions and data models in separate trees, and keep shared models in one place so OpenAPI and AsyncAPI can both reference them.
api/
openapi.yaml # paths, webhooks, operation-level security
asyncapi.yaml # channels, operations, broker bindings
components/
schemas/ # shared JSON Schema models ($ref'd by both)
responses/ # reusable response objects
examples/ # externalized example payloads
.spectral.yaml # governance ruleset
.redocly.yaml # bundling and docs config
Pin every tool version. Spec tooling moves fast and silent behavior changes between minor releases are common, so a lockfile plus an explicit version in your config is the difference between a reproducible build and a Friday-afternoon mystery.
// package.json (excerpt) — pin the toolchain
{
"devDependencies": {
"@redocly/cli": "2.5.0",
"@stoplight/spectral-cli": "6.15.0",
"@asyncapi/cli": "3.3.0",
"@apidevtools/swagger-cli": "4.0.4"
}
}
A minimal but complete OpenAPI 3.1 root looks like this. Note jsonSchemaDialect, which is what makes 3.1 a strict superset of JSON Schema 2020-12, and the separation between paths (routing) and components (models).
# openapi.yaml
openapi: 3.1.0
info:
title: Orders API
version: 1.4.0
description: Commands and queries for the order lifecycle.
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
servers:
- url: https://api.example.com/v1
paths:
/orders:
$ref: ./paths/orders.yaml
components:
schemas:
$ref: ./components/schemas/index.yaml
Structuring paths and resources
Path design is the part of an OpenAPI document consumers feel most directly, and it is the hardest to change once partners have integrated. Model resources as nouns, express actions through HTTP verbs, and keep hierarchy shallow. A path such as /orders/{orderId}/line-items reads as a clear parent/child relationship; a path such as /getOrderLineItemsForCustomer smuggles a verb and an implementation detail into the URL and ages badly.
Give every operation a stable, unique operationId. Generators turn it directly into a method name, so listOrders becomes client.listOrders() while a missing or duplicated operationId produces machine-named methods that break on the next regeneration. Apply tags consistently so the rendered reference groups operations the way consumers think about them.
# paths/orders.yaml
get:
operationId: listOrders
summary: List orders
tags: [Orders]
parameters:
- name: status
in: query
schema:
type: string
enum: [pending, shipped, cancelled]
responses:
'200':
description: A page of orders.
content:
application/json:
schema:
$ref: '../components/schemas/OrderPage.yaml'
post:
operationId: createOrder
summary: Create an order
tags: [Orders]
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/CreateOrderRequest.yaml'
responses:
'201':
description: The created order.
content:
application/json:
schema:
$ref: '../components/schemas/Order.yaml'
Decide your versioning strategy before the first partner integrates, because it is encoded into the contract and expensive to reverse. A version segment in the path such as /v1/orders is the most visible and the easiest for consumers to reason about; a version in a request header keeps URLs stable but hides the contract boundary from logs and browser history. Whichever you choose, apply it consistently across every path and document the deprecation policy in the info.description so consumers know how long a major version is supported. Pagination, filtering, and sorting deserve the same up-front discipline: settle on one query-parameter convention (cursor-based pagination scales better than offset for large collections) and reuse it on every collection endpoint so a consumer who learns one list operation has learned them all.
Keep operations idempotent where the verb implies it. PUT and DELETE must be safe to retry, and for POST operations that create resources, document support for an idempotency key header so a client that times out can safely resend without creating duplicates. These properties are part of the contract even though they are not enforced by the schema, so state them in each operation’s description.
For deeper conventions around resource boundaries, versioning in the URL versus the header, and splitting a monolith spec across services, work through the dedicated guide on structuring OpenAPI paths, which extends these rules to a microservices estate and to the focused walkthrough on how to structure OpenAPI paths for microservices.
Reusable JSON Schema components
Duplication is the slow killer of a spec. The moment the same address shape is inlined in three request bodies, those three copies start to diverge. Centralize every shared model under components/schemas and reference it everywhere with $ref. Because OpenAPI 3.1 and AsyncAPI 3.0 both speak JSON Schema 2020-12, a model file you extract here is usable verbatim from your event messages as well.
# components/schemas/Order.yaml
type: object
required: [id, status, total]
properties:
id:
type: string
format: uuid
status:
type: string
enum: [pending, shipped, cancelled]
total:
$ref: ./Money.yaml
lineItems:
type: array
items:
$ref: ./LineItem.yaml
Use composition keywords deliberately. allOf extends a base type, while oneOf with a discriminator models polymorphic payloads that generators can map to a tagged union or class hierarchy. The discriminator is what lets a generated SDK deserialize into the correct concrete type instead of a loose map.
# components/schemas/PaymentMethod.yaml
oneOf:
- $ref: ./CardPayment.yaml
- $ref: ./BankTransfer.yaml
discriminator:
propertyName: kind
mapping:
card: ./CardPayment.yaml
bank: ./BankTransfer.yaml
The companion guide on defining JSON Schema components covers composition trade-offs in depth, and best practices for JSON Schema composition in APIs shows how allOf, oneOf, and discriminators behave once they reach an SDK generator. To keep the root document readable, externalize bulky sample data per the approach in example payload management; the focused walkthrough on managing large example payloads in API specs shows how to keep examples validated against their schemas.
Event-driven design with AsyncAPI
AsyncAPI 3.0 reshaped the document around three first-class concepts: channels describe where messages flow, operations describe a send or receive action on a channel, and components.messages describe the payloads. This split, new in 3.0, decouples the act of sending from the address it is sent to, which makes the same channel reusable by both a producer and a consumer.
# asyncapi.yaml
asyncapi: 3.0.0
info:
title: Orders Events
version: 1.2.0
servers:
production:
host: kafka.example.com:9092
protocol: kafka
channels:
orderCreated:
address: orders.created
messages:
orderCreated:
$ref: '#/components/messages/OrderCreated'
operations:
publishOrderCreated:
action: send
channel:
$ref: '#/channels/orderCreated'
components:
messages:
OrderCreated:
name: OrderCreated
payload:
$ref: './components/schemas/Order.yaml'
correlationId:
location: $message.header#/correlationId
Bindings carry the protocol-specific details that a generic channel cannot: a Kafka partition key, an AMQP exchange type, an MQTT QoS level. Keep broker hostnames out of the committed file and inject them at build time so one spec serves every environment.
Message versioning in an event-driven system is harder than in HTTP because there is no synchronous handshake where a consumer can negotiate a version. A consumer reads whatever lands on the topic, so an incompatible payload change is felt asynchronously and often silently. Two patterns keep this under control. First, treat additive changes (new optional fields) as compatible and breaking changes (renamed or removed fields, tightened types) as a new message type or a new channel, never an in-place edit. Second, carry an explicit schema version in the message header so consumers can branch on it and you can run old and new producers side by side during a migration. A correlation identifier, shown in the correlationId block above, is equally important: it threads a single business transaction through every event it triggers, which is what makes distributed tracing across a streaming pipeline possible.
For channel naming conventions, payload versioning, and broker-specific patterns, build on AsyncAPI event-driven patterns. If you are weighing which document to reach for, AsyncAPI vs OpenAPI for event-driven architectures walks through the decision with concrete examples.
Security schemes and webhooks
Define authentication once under components.securitySchemes and apply it precisely. A global security requirement sets the default, but every operation can override it, and a public endpoint must declare security: [] to opt out explicitly. Skipping that override is the most common cause of generated SDKs attaching an Authorization header to endpoints that reject it.
# openapi.yaml (excerpt)
components:
securitySchemes:
oauth2:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://auth.example.com/oauth/token
scopes:
orders:read: Read orders
orders:write: Create and modify orders
security:
- oauth2: [orders:read]
paths:
/health:
get:
operationId: healthCheck
security: [] # explicitly public
responses:
'200':
description: Service is healthy.
OpenAPI 3.1 brought webhooks into the document as a top-level webhooks object, so the events your API emits are now part of the same contract as the endpoints it exposes. Callbacks, by contrast, are defined inline on the operation that registers them, describing the request your server will later send back to a consumer-supplied URL.
# openapi.yaml (excerpt)
webhooks:
orderShipped:
post:
operationId: onOrderShipped
requestBody:
content:
application/json:
schema:
$ref: './components/schemas/Order.yaml'
responses:
'200':
description: Acknowledged.
Document the full surface with the guides on security schemes & OAuth flows and webhook & callback definitions. For a concrete machine-to-machine setup, see implementing OAuth2 client credentials in OpenAPI 3.1, and for inbound event contracts, documenting webhook callbacks with OpenAPI extensions.
Linting, governance, and CI
A spec is only trustworthy if a machine enforces its quality on every change. Spectral is the standard linter for both OpenAPI and AsyncAPI: it ships sensible rulesets and lets you encode house style as custom rules. Start from the built-in spectral:oas rules and layer your own on top.
# .spectral.yaml
extends: ["spectral:oas"]
rules:
operation-operationId:
description: Every operation must define an operationId.
severity: error
given: $.paths[*][get,post,put,patch,delete,options,head]
then:
field: operationId
function: truthy
operation-tags:
description: Operations must have at least one tag.
severity: error
given: $.paths[*][get,post,put,patch,delete,options,head]
then:
field: tags
function: truthy
no-inline-large-examples:
description: Externalize large examples with $ref.
severity: warn
given: $.paths..responses..content..example
then:
function: undefined
Linting catches style and completeness. A separate gate, a backward-compatibility diff, catches the change that compiles cleanly but breaks consumers. The workflow below runs both on every pull request, then generates an SDK and builds the docs to prove the spec is codegen-ready before it can merge. It is the heart of a contract-first pipeline.
# .github/workflows/spec-governance.yml
name: Spec Governance
on:
pull_request:
paths:
- 'api/**'
permissions:
contents: read
pull-requests: write
jobs:
govern:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Lint OpenAPI
run: npx @stoplight/spectral-cli lint api/openapi.yaml --ruleset api/.spectral.yaml --fail-severity error
- name: Lint AsyncAPI
run: npx @asyncapi/cli validate api/asyncapi.yaml
- name: Bundle for distribution
run: npx @redocly/cli bundle api/openapi.yaml --output dist/openapi.bundled.yaml
- name: Backward-compatibility check
run: |
git show origin/${{ github.base_ref }}:api/openapi.yaml > /tmp/base-openapi.yaml || exit 0
npx oasdiff breaking /tmp/base-openapi.yaml api/openapi.yaml --fail-on ERR
- name: Validate codegen compatibility
run: npx @openapitools/openapi-generator-cli generate -i dist/openapi.bundled.yaml -g typescript-axios -o /tmp/sdk-out
- name: Build portal docs
run: npx @redocly/cli build-docs api/openapi.yaml --output dist/index.html
Each step in this workflow earns its place. Linting and validation reject malformed or off-style documents. Bundling resolves every $ref into a single self-contained file, which is what most downstream tools actually consume and what the compatibility diff and generator both run against. The oasdiff breaking step compares the bundled spec against the version on the base branch and fails only on consumer-breaking changes, so additive evolution flows through while a removed field or a tightened enum is blocked. Running the SDK generator in CI is a deliberate smoke test: a spec can lint cleanly yet still confuse a generator with an unnamed inline schema or a missing operationId, and catching that here is far cheaper than discovering it during a release. Finally, building the docs proves the rendering path works before anything reaches a consumer.
Govern the ruleset itself as a first-class artifact. Custom Spectral rules accrete over time as a team encodes hard-won conventions, so version them alongside the spec and review changes to .spectral.yaml with the same care as code. The companion guide on writing custom Spectral rules walks through authoring given/then expressions and custom functions for rules the built-in set cannot express.
Wire this gate to your downstream automation. A green run on the default branch is the signal to publish refreshed reference docs to a portal and to kick off SDK releases. The governance topic has its own deep dive in spec linting & governance, and the rendering and publishing side is covered across the developer portal frameworks & UI setup guide. The artifacts this pipeline emits feed directly into SDK generation & changelog automation, where the same validated bundle becomes typed clients and a generated changelog.
Common Pitfalls
Over-nesting JSON Schema definitions. Deeply chained $ref structures slow portal rendering and produce unwieldy generated types. Flatten reusable shapes into top-level components/schemas entries and reach for a discriminator rather than nesting oneOf blocks several layers deep.
# Prefer a flat, discriminated union over nested anonymous oneOf
PaymentMethod:
oneOf: [{$ref: ./CardPayment.yaml}, {$ref: ./BankTransfer.yaml}]
discriminator: {propertyName: kind}
Global security with no per-operation override. A global security block silently applies auth to every operation, so generated clients add an Authorization header to public endpoints and callers get rejected. Declare security: [] on each public operation to opt out explicitly.
Hardcoded broker and server hosts. Committing kafka.prod.internal:9092 into the AsyncAPI file ties one document to one environment and leaks infrastructure. Use a host variable and inject the real value at build time.
servers:
production:
host: '{brokerHost}'
protocol: kafka
variables:
brokerHost:
default: localhost:9092
Missing examples on AsyncAPI messages. Without concrete examples, portal sandboxes cannot simulate an event flow and consumers reverse-engineer payloads from broker logs. Attach a validated example to every entry in components.messages.
Treating linting as the whole story. Spectral confirms the spec is well-formed, not that it is compatible with what consumers already use. Always pair the linter with a backward-compatibility diff such as oasdiff so a clean-but-breaking change cannot merge.
FAQ
Should I use OpenAPI or AsyncAPI for my API?
Use OpenAPI 3.1 for request/response HTTP APIs where a client calls an endpoint and waits for a reply. Use AsyncAPI 3.0 for event-driven systems where messages flow over Kafka, AMQP, MQTT, or WebSocket. When a single product exposes both synchronous endpoints and an event stream, author both documents and share models between them.
How do I share JSON Schema components between OpenAPI and AsyncAPI files?
Extract shared models into standalone schema files under a shared directory and reference them from both specs with relative $ref pointers. Because OpenAPI 3.1 and AsyncAPI 3.0 both align on JSON Schema 2020-12, a model file can be reused verbatim across the two documents without translation.
What is the difference between contract-first and code-first API design?
Contract-first means you author the OpenAPI or AsyncAPI document before writing any handler code, then generate stubs, SDKs, and docs from it. Code-first generates the spec from annotated source, which couples the contract to one implementation language and tends to leak internal naming into the public API. Contract-first keeps the contract as the shared, language-neutral source of truth.
How do I stop a breaking change from reaching consumers?
Run a backward-compatibility diff in continuous integration on every pull request and fail the build when an incompatible change is detected. Pair the diff gate with a Spectral ruleset so structural style rules and compatibility rules both block the merge before the spec is published.
Do I need a separate document for webhooks and callbacks?
No. OpenAPI 3.1 added a top-level webhooks object that lives in the same document as your paths, and callbacks are defined inline on the operation that registers them. Keeping both in the main spec means a single linting and publishing pipeline covers your inbound and outbound contracts.
Related
- Structuring OpenAPI paths — resource modeling and route consistency.
- Defining JSON Schema components — reusable, composable models.
- AsyncAPI event-driven patterns — channels, operations, and bindings.
- Security schemes & OAuth flows and webhook & callback definitions.
- Spec linting & governance — the CI gates that keep specs trustworthy.
- Sibling guides: Developer portal frameworks & UI setup and SDK generation & changelog automation.