Your team runs a Kafka topic that three other teams consume, and the only description of it is a chat thread and a Java class. A consumer guesses the key is the customer id, partitions by it, and reorders every order’s events. Nobody can generate a client, because nothing machine-readable says what is on the wire.
What you get
You will end up with two AsyncAPI 3.0 documents for one topic, one per application, that validate, lint and parse, plus a test that pins which application sends and which receives. This is for you if you own a topic and want its contract in a file.
Short answer
Write one AsyncAPI 3.0 document per application. Put the broker under servers with
protocol: kafka and a scramSha512 security scheme. Describe the topic under channels
with a kafka binding naming its partitions and replicas, and the message with a JSON Schema
payload and a key in its Kafka binding. Then add one operation whose action says what
this application does, send or receive. Validate with asyncapi validate and lint with
the spectral:asyncapi ruleset that ships in Spectral.
You will need
Node 22 or later, the topic’s key and value shapes, and the SASL mechanism the broker
expects. Verified 2026-09-25 against Node 22.22.2, @asyncapi/cli 6.2.0, @asyncapi/parser
3.6.3 and @stoplight/spectral-cli 6.16.3, installed from npm in the sample directory. The
document follows the
AsyncAPI 3.0.0 specification
and the Kafka bindings at
version 0.5.0.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| AsyncAPI 2.6 | A generator or renderer you depend on reads 2.x only | publish describes what the application consumes, so every reader has to know the inversion | A document you are starting, since 3.0 reads the same topic without it |
| AsyncAPI 3.0 | One channel shared by several applications, each with its own document | An action that is right or wrong only from one application’s point of view, and one document per application | A toolchain that has not moved past 2.x |
Avro schema through schemaFormat | The bytes on the wire are Avro and a registry holds the schema | A second file or a registry fetch to read the document, and Spectral stops validating the payload | JSON on the wire, where an inline JSON Schema is the whole truth |
| Spectral asyncapi ruleset | Governance rules beyond validity, on 2.x and 3.x documents alike | Warnings about tags, contacts and licenses a validator never raises, and no payload check once schemaFormat is set | You want the specification’s rules only, which asyncapi validate applies |
The 3.0 layout separates the channel from the operations on it, so the topic is described
once and each application says what it does with it. That costs a document per application
and a word, send or receive, that nothing but a person can check. An inline JSON Schema
keeps the document self-contained. An Avro schema keeps it true to the bytes, at the cost of
a file the document no longer contains.
Describe the broker and how clients reach it
A server is a host, a protocol and the security schemes a client can satisfy. The
Server Object
takes host with the port in it and protocol: kafka.
servers:
production:
host: broker-1.example.com:9092
protocol: kafka
protocolVersion: '3.9'
description: The production cluster. Every client authenticates with SASL/SCRAM over TLS.
security:
- $ref: '#/components/securitySchemes/saslScram'
The security scheme’s type is scramSha512, one of the SASL values the specification lists
beside scramSha256, plain and gssapi. Kafka’s
SASL documentation names
SCRAM-SHA-256 and SCRAM-SHA-512 as the two SCRAM mechanisms it supports,1 so the scheme
name is the mechanism name with the hyphens gone.
Describe the topic as a channel with a Kafka binding
The channel’s address is the topic name. Everything Kafka-specific about the topic goes in
the channel’s kafka binding, and the binding is the part a consumer team cannot see from the
message alone.
channels:
orders:
address: orders.v1
description: One event per order, keyed by order id so an order's events share a partition.
messages:
orderPlaced:
$ref: '#/components/messages/orderPlaced'
bindings:
kafka:
partitions: 12
replicas: 3
topicConfiguration:
cleanup.policy: [delete]
retention.ms: 604800000
bindingVersion: '0.5.0'
partitions is the number a consumer group can scale to, which is why the binding exists.
bindingVersion pins the binding to 0.5.0, because a binding that omits it is read as the
latest version there is, whatever that becomes.2
Give the message a payload schema and a key
The message is the contract. Its payload here is a
Multi Format Schema Object
naming JSON Schema draft 7 by media type. The key lives in the message’s Kafka binding,
because the key is a Kafka concept and not part of the payload.
messages:
orderPlaced:
name: OrderPlaced
title: Order placed
contentType: application/json
headers:
type: object
properties:
traceparent:
type: string
description: W3C trace context, so a consumer can join its span to checkout's.
payload:
schemaFormat: application/schema+json;version=draft-07
schema:
$schema: http://json-schema.org/draft-07/schema#
type: object
required: [orderId, customerId, total, placedAt]
additionalProperties: false
properties:
orderId:
type: string
customerId:
type: string
total:
type: number
minimum: 0
currency:
type: string
pattern: '^[A-Z]{3}$'
default: EUR
placedAt:
type: string
format: date-time
bindings:
kafka:
key:
type: string
description: The order id. The key decides the partition, so every event for one order stays in order.
bindingVersion: '0.5.0'
Leaving schemaFormat out makes the payload an AsyncAPI Schema Object, which the
specification defines as a superset of
JSON Schema draft 7. Naming the format is more exact
and costs a Spectral check, as the lint run below shows.
Say which way this application faces
An operation names a channel and an action. The
Operation Object
defines send as the application sending to the channel and receive as the application
expecting to receive from it. Checkout produces the events, so its document has one send.
operations:
sendOrderPlaced:
action: send
channel:
$ref: '#/channels/orders'
summary: Announce an order the moment checkout commits it.
messages:
- $ref: '#/channels/orders/messages/orderPlaced'
bindings:
kafka:
clientId:
type: string
enum: [checkout]
bindingVersion: '0.5.0'
Fulfilment consumes the same topic, so it gets its own document with one receive, and it
reuses the channel by reference rather than copying it.
channels:
orders:
$ref: './asyncapi.yaml#/channels/orders'
operations:
receiveOrderPlaced:
action: receive
channel:
$ref: '#/channels/orders'
summary: Start picking as soon as an order is announced.
messages:
- $ref: '#/channels/orders/messages/orderPlaced'
bindings:
kafka:
groupId:
type: string
enum: [fulfilment]
clientId:
type: string
description: One id per replica, so a rebalance can be read from the broker logs.
bindingVersion: '0.5.0'
The consumer group id goes in the operation’s Kafka binding, because it belongs to the consuming application and not to the topic.
Validate it, then lint it
The AsyncAPI CLI checks the document against the specification, resolving the file reference on the way.
npx asyncapi validate fulfilment.yaml
File fulfilment.yaml is valid but has (itself and/or referenced documents) governance issues.
Information
fulfilment.yaml
1:11 information asyncapi-latest-version The latest version of AsyncAPi is not used. It is recommended update to the "3.1.0" version. asyncapi
✖ 1 problem (0 errors, 0 warnings, 1 info, 0 hints)
Valid, with one piece of information: the specification has a 3.1.0, whose release notes call
it a minor release.3 The spectral:asyncapi ruleset that ships in Spectral adds governance
rules on top, from a one-line .spectral.yaml that reads extends: spectral:asyncapi.
npx spectral lint asyncapi.yaml
1:11 information asyncapi-latest-version The latest version is not used. You should update to the "3.1.0" version. asyncapi
2:6 warning asyncapi-3-tags AsyncAPI document must have non-empty "tags" array. info
2:6 warning asyncapi-info-contact Info object must have "contact" object. info
2:6 warning asyncapi-info-license Info object must have "license" object. info
33:19 warning asyncapi-3-operation-description Operation "description" must be present and non-empty string. operations.sendOrderPlaced
64:23 information asyncapi-3-payload-unsupported-schemaFormat Message schema validation is only supported with default unspecified "schemaFormat". components.messages.orderPlaced.payload.schemaFormat
Four warnings are house rules about contact, license, tags and descriptions, which is what a
governance ruleset is for. The last line is the cost of naming the schema format, because
Spectral validates a payload schema only when schemaFormat is absent. To see the document rendered,
paste it into AsyncAPI Studio or run
asyncapi start studio --file asyncapi.yaml, which the CLI documents under start studio.
Keep the schema where the bytes are
When the value on the wire is Avro and a registry holds the schema, the document should say so rather than restate the schema in JSON Schema. The server binding names the registry, the payload names the Avro format and references the schema file, and the message binding says where the schema id sits in each record.
channels:
orders:
address: orders.v1
messages:
orderPlaced:
name: OrderPlaced
contentType: application/octet-stream
payload:
schemaFormat: application/vnd.apache.avro;version=1.9.0
schema:
$ref: ./order-placed.avsc
bindings:
kafka:
key:
type: string
schemaIdLocation: payload
schemaIdPayloadEncoding: confluent
bindingVersion: '0.5.0'
npx asyncapi validate asyncapi-avro.yaml
File asyncapi-avro.yaml is valid but has (itself and/or referenced documents) governance issues.
The CLI follows the reference, so a missing .avsc file fails validation, and it checks the
record’s outline against the specification, so a misspelled top-level type fails too. It does
not resolve the field types: a field whose type names no Avro type still validates. Load the
schema with an Avro library in a test, or let the registry refuse it. What the document loses is
self-containment: a reader needs the .avsc file, or the registry, to know what a total is.
Check it worked
The validator cannot know whether fulfilment sends or receives. A test can, because somebody wrote the answer down.
// Which way each application faces. A document with these swapped validates just as well.
const DIRECTION = { 'asyncapi.yaml': 'send', 'fulfilment.yaml': 'receive' }
for (const [file, action] of Object.entries(DIRECTION)) {
test(`${file}: the one operation on orders.v1 is a ${action}`, async () => {
const doc = await load(file)
const ops = doc.operations().all()
assert.equal(ops.length, 1)
assert.equal(ops[0].action(), action)
assert.deepEqual(ops[0].channels().all().map((c) => c.address()), ['orders.v1'])
})
}
node --test asyncapi.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 4721.718123
The other five tests read the parsed documents with @asyncapi/parser and assert the
partition count, the key type, the consumer group, the security scheme on every server and
the Avro variant’s registry settings. Spectral reports its version with npx spectral --version.
npx spectral --version
6.16.3
When it goes wrong
The generated consumer publishes and the generated producer subscribes. Somebody wrote
send on the fulfilment side, reading it as the topic doing the sending. The document is still
valid, because validity is about shape.
npx asyncapi validate fulfilment-inverted.yaml
File fulfilment-inverted.yaml is valid but has (itself and/or referenced documents) governance issues.
action is written from the application’s point of view and from nowhere else. The test in
the previous section is the check, and the DIRECTION table is where the answer lives.
A 2.6 document says publish and the team reads it as this application publishing. In 2.6
the Channel Item Object
defines publish as the messages consumed by the application from the channel,4 which is
the inversion the 3.0 migration guide
exists to remove. The converter applies it correctly.
npx asyncapi convert legacy-2.6.yaml --target-version 3.0.0
operations:
onOrderPlaced:
action: receive
channel:
$ref: '#/channels/orders.v1'
summary: The application consumes order events from this topic.
messages:
- $ref: '#/channels/orders.v1/messages/onOrderPlaced.message'
publish became receive. Convert before editing, and read the result against what the
application does rather than against the old verb.
The reference to ./asyncapi.yaml fails in a tool that resolves only in-document pointers.
Bundle the two documents first with asyncapi bundle, or move the channel into the
fulfilment document and accept the copy.
When not to do this
Do not describe two applications in one document. One send and one receive on the same
channel reads as an application that talks to itself, and a generator will build exactly that.
Write a document per application and share the channel by reference.
Do not write publish and subscribe in a 3.0 document by analogy with the broker’s verbs.
They are not valid actions, the validator rejects them, and the habit is the one that made 2.x
documents wrong in both directions.
Do not put the registry URL in a description and the schema in JSON Schema for convenience. A document that restates an Avro schema by hand matches the registry only until a field is added, since nothing checks the copy against it.
Do not omit bindingVersion. Absent, it means the latest version of the binding, so the
document’s meaning changes when the binding does.
Do not treat asyncapi validate as the review. It proves the document is well-formed against
the specification and the bindings, and it accepted the inverted document earlier without a
word. Direction, partition counts and key semantics are facts about the topic, and only a
test or a person who knows the topic can check them.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, @asyncapi/cli 6.2.0, @asyncapi/parser 3.6.3 and
@stoplight/spectral-cli 6.16.3, installed from npm in the sample directory. Every output block
is what the command preceding it printed. No broker was involved: the partition count, the
retention and the SASL mechanism are what the document claims about a topic, not what a
cluster reported.
Footnotes
-
Kafka’s SASL page documents PLAIN in full first. The SCRAM section follows it and opens by presenting SCRAM, a family of mechanisms defined in RFC 5802, as the answer to the security concerns of mechanisms like PLAIN and DIGEST-MD5. A reader meets the warning after the instructions. The same page says the default SCRAM implementation stores its credentials in the metadata log, so the secret that protects the cluster lives inside the cluster. ↩︎ Back to text
-
The Kafka bindings say of
bindingVersionthat if it is omitted, “latest” MUST be assumed, in capitals, and the page opens by saying the current version is 0.5.0. A document that leaves the field out is therefore correct on the day it is written. On the day the next version ships it describes a different binding, with no change to a single byte of it. ↩︎ Back to text -
The validator’s information line asks for 3.1.0. The 3.1.0 release notes describe a minor release with no breaking changes, whose addition to the specification is a ROS 2 protocol binding. They say the upgrade is changing
3.0.0to3.1.0in the version field. So the line is right, and the cost of obeying it is one edit to a string. ↩︎ Back to text -
The 2.6 specification defines
publishas the operation for messages consumed by the application andsubscribeas the one for messages it produces. The migration guide records that the pair caused confusion even among those familiar with the details, which is a generous way to describe a verb that means its opposite. ↩︎ Back to text