# How to describe a Kafka topic with AsyncAPI

> Describe one Kafka topic in AsyncAPI 3.0, from the SASL server to the keyed message, then validate it, lint it and pin which way each application faces.

Source: https://voxgig.com/howto/describe-a-kafka-topic-with-asyncapi

- Audience: api-producer
- Level: beginner
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://www.asyncapi.com/docs/reference/specification/v3.0.0)
and the [Kafka bindings](https://www.asyncapi.com/docs/reference/bindings/kafka) at
version 0.5.0.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AsyncAPI 2.6](https://v2.asyncapi.com/docs/reference/specification/v2.6.0) | 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](https://www.asyncapi.com/docs/reference/specification/v3.0.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`](https://avro.apache.org/docs/1.12.0/specification/) | 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](https://docs.stoplight.io/docs/spectral/1e63ffd0220f3-async-api-rules) | 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](https://www.asyncapi.com/docs/reference/specification/v3.0.0#serverObject)
takes `host` with the port in it and `protocol: kafka`.

```yaml title="asyncapi.yaml"
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](https://kafka.apache.org/43/security/authentication-using-sasl/) 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.

```yaml title="asyncapi.yaml"
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](https://www.asyncapi.com/docs/reference/specification/v3.0.0#multiFormatSchemaObject)
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.

```yaml title="asyncapi.yaml"
  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](https://json-schema.org/specification). 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](https://www.asyncapi.com/docs/reference/specification/v3.0.0#operationObject)
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`.

```yaml title="asyncapi.yaml"
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.

```yaml title="fulfilment.yaml"
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](https://www.asyncapi.com/docs/tools/cli/usage) checks the document
against the specification, resolving the file reference on the way.

```bash
npx asyncapi validate fulfilment.yaml
```

```text output
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`.

```bash
npx spectral lint asyncapi.yaml
```

```text output
  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](https://studio.asyncapi.com/) 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.

```yaml title="asyncapi-avro.yaml"
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'
```

```bash
npx asyncapi validate asyncapi-avro.yaml
```

```text output
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.

```js title="asyncapi.test.mjs"
// 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'])
  })
}
```

```bash
node --test asyncapi.test.mjs
```

```text output
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`.

```bash
npx spectral --version
```

```text output
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.

```bash
npx asyncapi validate fulfilment-inverted.yaml
```

```text output
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](https://v2.asyncapi.com/docs/reference/specification/v2.6.0#channelItemObject)
defines `publish` as the messages consumed by the application from the channel,[^4] which is
the inversion the [3.0 migration guide](https://www.asyncapi.com/docs/migration/migrating-to-v3)
exists to remove. The converter applies it correctly.

```bash
npx asyncapi convert legacy-2.6.yaml --target-version 3.0.0
```

```text output
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

- [Define a gRPC service in a proto3 file](/howto/define-a-grpc-service-in-proto3)

- [Make queue consumer writes safe under redelivery](/howto/queue-consumer-writes-under-at-least-once)

## 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.

[^1]: Kafka's [SASL page](https://kafka.apache.org/43/security/authentication-using-sasl/)
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.

[^2]: The [Kafka bindings](https://www.asyncapi.com/docs/reference/bindings/kafka) say of
`bindingVersion` that 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.

[^3]: The validator's information line asks for 3.1.0. The
[3.1.0 release notes](https://www.asyncapi.com/blog/release-notes-3.1.0) 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.0` to `3.1.0` in the version field. So the
line is right, and the cost of obeying it is one edit to a string.

[^4]: The [2.6 specification](https://v2.asyncapi.com/docs/reference/specification/v2.6.0#channelItemObject)
defines `publish` as the operation for messages consumed by the application and `subscribe`
as the one for messages it produces. The
[migration guide](https://www.asyncapi.com/docs/migration/migrating-to-v3) 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.