Skip to main content

AsyncAPI vs OpenAPI Webhooks for Event Delivery

Problem: the same business event — an order was paid — is delivered to an internal service over Kafka and to three partners over HTTP. You have an AsyncAPI document for the first and are about to write an OpenAPI webhooks section for the second, and it is not obvious whether that is duplication or the correct design. This guide is part of Webhook and Callback Contracts and settles the question.

The short answer: it is the correct design, provided the payload is defined once and both documents reference it. What legitimately differs between the two is not the event — it is the delivery contract.

The Distinction That Matters

Both documents describe an event. They describe fundamentally different promises about how it arrives.

Who owns the delivery With a broker, infrastructure owns retention, ordering and redelivery, and consumers pull. With a webhook, your service owns retries, signing and timeouts, and pushes to an endpoint you do not control. AsyncAPI — the broker owns delivery your service broker retention, ordering, replay consumers pull at their own pace a slow consumer lags; it does not fail you OpenAPI webhooks — you own delivery your service your delivery worker retries, signing, timeouts a receiver you do not control a slow receiver is your operational problem The event is identical. The obligations are not — which is why two documents is the right answer.

Choosing Between Them

Three questions decide it, in order.

Is there a broker between you and the consumer? If consumers subscribe to a topic you both connect to, AsyncAPI describes the situation natively — channels, servers, protocol bindings and message keys all have a place. An OpenAPI document has nowhere to record a partition key.

Can the consumer adopt your tooling? Internal teams can run an AsyncAPI generator. A partner integrating over HTTP almost certainly cannot, and will want types generated from something their toolchain already understands, which is OpenAPI’s webhooks section.

Who owns retry and delivery semantics? If the answer is “the broker”, document what the broker guarantees. If the answer is “our delivery worker”, the retry schedule, signature scheme and timeout are yours to specify — and those belong beside the HTTP operation that carries them.

Question AsyncAPI OpenAPI webhooks
Transport any broker protocol, plus HTTP HTTP only
Consumer connects pulls from a channel exposes an endpoint
Ordering and retention described, owned by the broker not applicable
Retry schedule broker configuration your contract to publish
Signature scheme rarely needed inside a trust boundary required
Typical consumer an internal service an external partner
Generated artifact models plus a consumer scaffold a typed receiver route

Share the Payload, Not the Document

The duplication worth avoiding is the payload schema. Extract it into a standalone file that neither document owns.

# schemas/order-paid.yaml — the canonical definition, referenced by both
$schema: https://json-schema.org/draft/2020-12/schema
$id: https://schemas.example.com/events/order-paid.yaml
title: OrderPaidData
type: object
additionalProperties: false
required: [order_id, amount, currency, paid_at]
properties:
  order_id: { type: string, pattern: '^ord_[a-zA-Z0-9]{16}$' }
  amount:   { type: integer, description: Minor units. }
  currency: { type: string, pattern: '^[A-Z]{3}$' }
  paid_at:  { type: string, format: date-time }
# asyncapi.yaml — internal delivery over Kafka
channels:
  orderEvents:
    address: order.events.v1
    messages:
      orderPaid:
        name: OrderPaid
        contentType: application/json
        payload:
          $ref: 'https://schemas.example.com/events/order-paid.yaml'
        bindings:
          kafka:
            key: { type: string, description: order_id, used for partitioning }
# openapi.yaml — external delivery over HTTP
webhooks:
  orderPaid:
    post:
      operationId: onOrderPaid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, type, created_at, data]
              properties:
                id: { type: string }
                type: { type: string, const: order.paid }
                created_at: { type: string, format: date-time }
                data:
                  $ref: 'https://schemas.example.com/events/order-paid.yaml'

Notice what differs and what does not. The data payload is identical because it is the same event. The envelope differs — the HTTP delivery needs an id for deduplication and a type for routing, while the Kafka message carries those as headers and a topic. The Kafka binding declares a partition key; the webhook declares nothing of the sort because HTTP has no such concept.

One payload, two envelopes A shared schema file defines the event data. The AsyncAPI message wraps it with a Kafka binding and headers; the OpenAPI webhook wraps it with an id, a type and signature headers. A field added to the payload appears in both. order-paid.yaml the payload, defined once AsyncAPI message + kafka key binding + headers schema internal consumer generated models OpenAPI webhook + id and type envelope + signature headers partner receiver generated route types

Keeping the Two Documents Honest

Only two things genuinely drift: the payload (solved by the shared file) and the list of event types. Check the second in CI:

Every webhook must have an internal event behind it The internal event list may be larger than the external webhook list, but a webhook with no matching internal event means either a naming mismatch or a delivery path that bypasses the event bus. internal events 12 message types most stay inside external webhooks 3 published both — fine 3 types webhook-only: the CI check fails An orphan webhook is a naming mismatch, or a delivery that bypasses the event bus.
#!/usr/bin/env bash
# scripts/check-event-parity.sh
set -euo pipefail

yq -r '.channels[].messages | keys | .[]' asyncapi.yaml | sort -u > /tmp/async-events.txt
yq -r '.webhooks | keys | .[]'            openapi.yaml  | sort -u > /tmp/http-events.txt

# Not every internal event needs an external webhook — but every webhook
# must correspond to an internal event that actually exists.
if ! comm -13 /tmp/async-events.txt /tmp/http-events.txt | tee /tmp/orphans.txt | grep -q .; then
  echo "event parity ok: $(wc -l < /tmp/http-events.txt) webhook(s), all backed by an internal event"
else
  echo "::error::webhooks with no internal event: $(tr '\n' ' ' < /tmp/orphans.txt)"
  exit 1
fi

An external webhook with no internal event behind it is either a naming mismatch or a delivery path that bypasses your event bus — both worth knowing about.

Verification

# Both documents resolve their shared reference to the same schema.
yq -r '.channels.orderEvents.messages.orderPaid.payload.$ref' asyncapi.yaml
# https://schemas.example.com/events/order-paid.yaml
yq -r '.webhooks.orderPaid.post.requestBody.content."application/json".schema.properties.data.$ref' openapi.yaml
# https://schemas.example.com/events/order-paid.yaml

# The same fixture validates as both an internal payload and a webhook data block.
npx ajv-cli@5 validate -s schemas/order-paid.yaml -d fixtures/order-paid-data.json --spec=draft2020
# fixtures/order-paid-data.json valid

# Event lists agree.
./scripts/check-event-parity.sh
# event parity ok: 3 webhook(s), all backed by an internal event

Edge Cases and Caveats

  • A payload that must differ externally. Internal events often carry fields partners must not see. Define the internal schema, then define the external one as a documented subset rather than as an independent copy — and never solve it by filtering fields at delivery time without a schema that says so.
  • AsyncAPI’s HTTP binding. It exists, and for an all-internal estate it is a reasonable way to describe HTTP-delivered events in one document. For external partners the generator ecosystem still favours OpenAPI.
  • Versioning drifts apart. The internal event and the external webhook will not evolve on the same schedule, because you can redeploy internal consumers and cannot redeploy partners. Version them separately and expect the external contract to lag.
  • Schema $id and offline builds. Referencing a schema by URL is clean until CI has no network. Vendor the file into the repository and resolve locally at build time, keeping the URL as the identity rather than as the fetch path.
  • Two teams, two documents. If the internal event bus and the partner API are owned by different teams, put the shared schema in a repository both own, with both as required reviewers. Ownership is what keeps a shared file shared.

Who Owns Each Document

Two documents describing one event raise an ownership question that is worth answering explicitly, because the default answer — whoever wrote it — produces divergence within a quarter.

The workable split is that the producing team owns both documents and the shared schema, with the shared schema in a repository that consumers can watch. The internal document changes at the pace of the event bus; the external one changes at the pace of partner integrations, which is slower. Keeping both under one owner is what makes it possible to add a field to the payload and know immediately whether it should be exposed externally.

The failure to avoid is splitting ownership by audience: an integration team owning the webhook document and a platform team owning the AsyncAPI document. Each then treats the shared schema as someone else’s, and the first divergence goes unnoticed because neither side is responsible for the comparison.

Where the two genuinely must diverge — internal events carrying fields partners must not see — express the external schema as a documented subset of the internal one rather than as an independent file, and generate it if you can. A subset that is derived cannot drift; a subset that is maintained separately always does.

Starting With One and Adding the Other

Most platforms do not choose both at once. They start with an internal event bus and later acquire partners, or start with partner webhooks and later build a bus. Either order works, provided the payload schema is extracted into its own file before the second document exists — the extraction is cheap while there is one consumer and awkward once there are several.

Frequently Asked Questions

Can AsyncAPI describe HTTP webhooks?

It can — HTTP is one of its supported protocol bindings — but the tooling is thinner than for brokered protocols, and external receivers rarely have AsyncAPI generators. For outbound HTTP to third parties, the OpenAPI webhooks section usually produces more usable artifacts.

Do I need both documents?

Most platforms end up with both, because most platforms have both an internal event bus and external webhook subscribers. The important thing is that both reference one shared payload schema rather than defining the event twice.

Which document owns the event schema?

Neither — a separate schema file does, and both documents reference it. Putting the canonical definition inside either document makes the other a copy, and copies drift.

How do I keep the two documents consistent?

Reference the same schema file from both, and add a CI check that the event type enumerations match. Those are the two things that actually diverge; everything else is protocol detail that legitimately differs.

Is a webhook just an event with extra steps?

It is the same event with a different delivery contract. A brokered event is delivered by infrastructure with its own retention and ordering; a webhook is delivered by your code to an endpoint you do not control, so retries, signatures and timeouts become your responsibility to specify.