How-to › Integrate beyond REST

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.

Audience
API producer
Level
beginner
Topic
Consume and publish events
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
AsyncAPI 2.6A generator or renderer you depend on reads 2.x onlypublish describes what the application consumes, so every reader has to know the inversionA document you are starting, since 3.0 reads the same topic without it
AsyncAPI 3.0One channel shared by several applications, each with its own documentAn action that is right or wrong only from one application’s point of view, and one document per applicationA toolchain that has not moved past 2.x
Avro schema through schemaFormatThe bytes on the wire are Avro and a registry holds the schemaA second file or a registry fetch to read the document, and Spectral stops validating the payloadJSON on the wire, where an inline JSON Schema is the whole truth
Spectral asyncapi rulesetGovernance rules beyond validity, on 2.x and 3.x documents alikeWarnings about tags, contacts and licenses a validator never raises, and no payload check once schemaFormat is setYou 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.

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

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

  2. The Kafka bindings 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. ↩︎ Back to text

  3. 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.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. ↩︎ Back to text

  4. The 2.6 specification defines publish as the operation for messages consumed by the application and subscribe as 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.