AsyncAPI vs OpenAPI for Event-Driven Architectures
Teams building event-driven systems hit a fork quickly: do you document the broker side of the system in OpenAPI, in AsyncAPI, or in both? Picking wrong means either bending OpenAPI’s HTTP model around message topics it was never designed for, or splitting your API surface across two specs without a plan to validate and publish them together. This task-focused guide is part of AsyncAPI Event-Driven Patterns within the larger OpenAPI & AsyncAPI Schema Authoring workflow, and it walks through the decision, the validation pipeline, and the unified portal build end to end.
Problem & Context
OpenAPI and AsyncAPI overlap just enough to be confusing. Both reuse JSON Schema for payloads, both have info, servers, and components, and both can describe an “event.” But their models diverge at the transport layer, and that difference decides which spec you should reach for.
- OpenAPI 3.1 models synchronous HTTP request/response under
paths, and it added a top-levelwebhooksobject for HTTP callbacks the API initiates. It has no concept of a topic, a partition, a consumer group, or a broker. - AsyncAPI 3.0 is built around
channelsandoperationswith protocol-specificbindingsfor Kafka, MQTT, AMQP, WebSocket, and more. It models publish/subscribe semantics and the broker topology that controls routing.
Forcing Kafka topics into OpenAPI paths breaks semantic versioning guarantees and confuses every generator and diff tool you point at the file. The reverse — describing plain REST endpoints in AsyncAPI — loses the request/response idioms that SDK generators and Swagger UI expect. The correct answer is almost always “both, kept separate,” which raises the real engineering questions this guide answers: how do you validate two specs with different JSON Schema dialects in one pipeline, and how do you publish them as one coherent portal?
Step-by-Step Solution
1. Map each transport to the right spec
Decide per integration, not per project. Use this matrix as the source of truth:
| Concern | OpenAPI 3.1 | AsyncAPI 3.0 |
|---|---|---|
| HTTP REST endpoints | Native (paths) |
Not applicable |
| HTTP webhooks | Native (webhooks) |
Via HTTP binding |
| Kafka topics | Not supported | Native (channels, bindings.kafka) |
| MQTT subscriptions | Not supported | Native (channels, bindings.mqtt) |
| AMQP queues | Not supported | Native (channels, bindings.amqp) |
| Message correlation | Possible via payload schema | Native (correlationId) |
If a call is “client sends request, waits for response,” it is OpenAPI. If a producer emits a message that a broker routes to one or more consumers, it is AsyncAPI.
2. Author the two specs as separate files
Keep openapi.yaml and asyncapi.yaml distinct. The OpenAPI side keeps HTTP webhooks for callbacks it owns:
# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Order Service API
version: 1.0.0
webhooks:
orderStatusUpdate:
post:
operationId: handleOrderStatus
summary: Fires when an order status changes
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OrderStatus'
responses:
'200':
description: Event acknowledged
The AsyncAPI side models the same domain event as a Kafka channel, which OpenAPI cannot express:
# asyncapi.yaml (AsyncAPI 3.0.0)
asyncapi: 3.0.0
info:
title: Order Events
version: 1.0.0
channels:
orderStatusUpdated:
address: order.status.updated
messages:
orderStatus:
$ref: '#/components/messages/OrderStatus'
bindings:
kafka:
topic: order.status.updated
partitions: 6
The full channel routing and consumer group conventions live in AsyncAPI Event-Driven Patterns.
3. Add a separate Spectral ruleset per spec
The two specs use different JSON Schema dialects, so a single shared ruleset will misfire. Create two files:
# .spectral-openapi.yaml
extends: ["spectral:oas"]
rules:
operation-operationId: error
# .spectral-asyncapi.yaml
extends: ["spectral:asyncapi"]
rules:
asyncapi-channel-no-empty-parameter: error
4. Validate both specs in CI
Run each linter with its own ruleset so dialect differences never collide:
npx @stoplight/spectral-cli lint asyncapi.yaml \
--ruleset .spectral-asyncapi.yaml \
--format github-actions \
--fail-severity error
Expected output for a clean spec:
No results with a severity of 'error' found!
5. Detect breaking changes before merge
OpenAPI and AsyncAPI each ship their own diff tool. For async contracts:
npx @asyncapi/cli diff old-asyncapi.yaml asyncapi.yaml --type breaking
Expected output when a channel is removed:
Breaking changes detected:
- channel 'order.status.updated' was removed
6. Generate and merge portal documentation
Build each side into its own directory, then link them from one navigation page:
# AsyncAPI HTML documentation
npx @asyncapi/cli generate fromTemplate asyncapi.yaml @asyncapi/html-template --output ./docs/async
# OpenAPI static documentation
npx @redocly/cli build-docs openapi.yaml --output ./docs/sync/index.html
Note: openapi-generator-cli generate -g static is not a valid generator name. Use @redocly/cli build-docs or @scalar/cli build for OpenAPI static documentation.
Complete Working Example
A single GitHub Actions workflow that validates both specs and builds the merged portal:
# .github/workflows/validate-and-build-specs.yml
name: Validate API Contracts and Build Portal
on:
pull_request:
push:
branches: [main]
jobs:
contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Lint OpenAPI spec
run: >
npx @stoplight/spectral-cli lint openapi.yaml
--ruleset .spectral-openapi.yaml
--fail-severity error
- name: Lint AsyncAPI spec
run: >
npx @stoplight/spectral-cli lint asyncapi.yaml
--ruleset .spectral-asyncapi.yaml
--fail-severity error
- name: Detect breaking AsyncAPI changes
if: github.event_name == 'pull_request'
run: |
git show origin/main:asyncapi.yaml > old-asyncapi.yaml || exit 0
npx @asyncapi/cli diff old-asyncapi.yaml asyncapi.yaml --type breaking
- name: Build AsyncAPI docs
run: npx @asyncapi/cli generate fromTemplate asyncapi.yaml @asyncapi/html-template --output ./docs/async
- name: Build OpenAPI docs
run: npx @redocly/cli build-docs openapi.yaml --output ./docs/sync/index.html
- name: Upload merged portal
uses: actions/upload-artifact@v4
with:
name: api-portal
path: ./docs
This produces ./docs/sync/ and ./docs/async/, both ready to serve under a shared site root with one navigation page linking each.
Gotchas & Edge Cases
JSON Schema draft mismatches across the two specs. AsyncAPI 3.0 defaults to JSON Schema draft 2020-12, while legacy OpenAPI 3.0.x uses draft-04. Running one Spectral instance against both makes $ref resolution and unevaluatedProperties behave inconsistently. Use separate ruleset files, and upgrade OpenAPI to 3.1.0 to align dialects — composition mechanics across both versions are covered in JSON Schema composition best practices.
Missing correlation IDs break distributed tracing. Event-driven tracing needs an explicit correlationId to link a producer event to a consumer outcome. Define it in AsyncAPI message objects and inject x-correlation-id at the gateway or a broker interceptor; without it, a trace stops at the channel boundary.
Trying to model one event in both specs at once. It is tempting to describe an order event as both a webhook and a Kafka channel “to be safe.” Keep one authoritative definition per transport. If the same payload genuinely ships over HTTP and Kafka, share the schema via a referenced JSON Schema file rather than duplicating the event in two specs.
FAQ
Can OpenAPI 3.1 webhooks replace AsyncAPI for Kafka topics?
No. OpenAPI webhooks model HTTP callbacks only. Kafka requires broker-specific bindings, partition configuration, and consumer group metadata that AsyncAPI’s bindings.kafka block provides natively. Webhooks are the right tool only when the broker is plain HTTP.
How do I automate portal generation for both specs simultaneously?
Run @asyncapi/cli generate and @redocly/cli build-docs against separate output directories in one CI job, then merge them under a shared navigation page. Each tool owns the spec it understands, so neither has to parse a model it was not built for.
What is the recommended approach for breaking change detection in async schemas?
Use @asyncapi/cli diff old.yaml new.yaml for AsyncAPI and openapi-diff or @redocly/cli for OpenAPI. Run both in CI and block pull requests when a breaking change is reported, so consumers are never surprised by a removed channel or a tightened payload.