Webhooks & Callbacks in OpenAPI 3.1

Asynchronous notifications are where API documentation most often goes stale: the outbound order.shipped event your service fires lives nowhere in the spec, so consumers reverse-engineer the payload from logs. OpenAPI 3.1 fixes this with two distinct mechanisms — a top-level webhooks object for events your API sends independent of any request, and a callbacks key on an operation for events that a specific request sets in motion. This guide is part of OpenAPI & AsyncAPI Schema Authoring and covers defining both, validating them in CI, securing them, and rendering them in your developer portal.

Scope: this page covers the OpenAPI 3.1 webhooks object, the callbacks key, runtime expressions for dynamic URL resolution, and the difference from AsyncAPI event channels. It does not cover signature verification mechanics or retry semantics in depth — those have dedicated guides: documenting webhook callbacks with OpenAPI extensions, securing webhooks with signature verification, and retry and idempotency for webhooks.

Webhook object versus callbacks key A top-level webhook is sent independently while a callback is registered by a request and fired back to a runtime URL. webhooks (out of band) callbacks (request-driven) Your API Subscriber event POST Client Your API register + callback

Choose the right construct before writing YAML:

Need Construct Target URL Lives under
Event sent independent of any request webhooks object Subscriber-configured top-level webhooks
Event triggered by a specific operation callbacks key Runtime expression paths[op].callbacks
Bidirectional / streaming channel AsyncAPI 3.0 Channel binding asyncapi.yaml
Outbound event with custom metadata webhooks + extensions Subscriber-configured webhooks + x-*

Prerequisites & Environment Setup

Pin the toolchain locally and in CI. The webhooks object requires OpenAPI 3.1.0, so lock that version:

npm install --save-dev @stoplight/spectral-cli@6
npm install --save-dev @redocly/cli@2     # v2.x build-docs; redoc-cli is deprecated
npm install --save-dev ajv-cli@5          # validates example payloads against schemas
node --version                             # expect v20.x

Pin the openapi field to 3.1.0 — the webhooks object did not exist before 3.1, and a silent downgrade strips your event documentation. Define payloads as reusable components following Defining JSON Schema Components, and resolve callback URLs with runtime expressions rather than hardcoded hosts so the same spec works in every environment.

Core Configuration

A top-level webhooks object documents events your API initiates. Each entry is a path-item object keyed by event name; the HTTP method is the verb the subscriber’s endpoint must accept:

# openapi.yaml
openapi: 3.1.0                          # 3.1 required for the webhooks object
info:
  title: Example API
  version: "1.0.0"
webhooks:
  orderShipped:                         # event name shown in the portal sidebar
    post:                               # method the subscriber endpoint must implement
      summary: Fired when an order ships
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderShippedEvent' }
      responses:
        '200': { description: Acknowledged }
        '410': { description: Subscriber gone  stop sending }

A callbacks key, by contrast, hangs off an operation and uses a runtime expression to resolve the destination from the originating request — never a hardcoded URL:

paths:
  /subscriptions:
    post:
      summary: Register a webhook endpoint
      operationId: registerWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                callbackUrl: { type: string, format: uri }
      callbacks:
        onEvent:
          '{$request.body#/callbackUrl}':   # runtime expression: URL from the request body
            post:
              operationId: receiveEvent
              requestBody:
                required: true
                content:
                  application/json:
                    schema: { $ref: '#/components/schemas/WebhookPayload' }
              responses:
                '200': { description: Callback received }
                '204': { description: Accepted, no content }

Common runtime expressions are {$request.body#/callbackUrl}, {$request.query.callbackUrl}, and {$request.header.X-Callback-Url}. Use them or servers variables; a hardcoded callback host breaks staging and local deployments.

Integration Pattern

Validate webhook and callback completeness on every pull request, and validate example payloads against their schemas. This workflow lints structure with Spectral and checks a sample payload with ajv-cli:

# .github/workflows/validate-webhooks.yml
name: Validate Webhook Definitions
on:
  pull_request:
    paths: ['openapi.yaml', '.spectral.yaml', 'examples/**']
jobs:
  validate:
    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 webhook + callback definitions
        run: npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
      - name: Validate an example payload against its schema
        run: npx ajv validate -s schemas/OrderShippedEvent.json -d examples/order-shipped.json --spec=draft2020
      - name: Build portal docs (smoke test)
        run: npx @redocly/cli build-docs openapi.yaml --output dist/index.html

The ruleset requires responses on webhook operations, payload schemas on callbacks, and explicit security so callbacks do not silently bypass authentication:

# .spectral.yaml
extends: ["spectral:oas"]
rules:
  webhook-must-have-response:
    description: Webhook operations must define responses.
    severity: error
    given: $.webhooks[*][post,put,patch]
    then: { field: responses, function: truthy }
  callback-must-have-requestbody:
    description: Callback operations must define a request payload.
    severity: error
    given: $.paths[*].*.callbacks[*][*][post,put,patch]
    then: { field: requestBody, function: truthy }
  callback-must-have-security:
    description: Callbacks must declare explicit security (signature, mTLS, or key).
    severity: error
    given: $.paths[*].*.callbacks[*][*][post,put,patch]
    then: { field: security, function: defined }

Advanced Options

Signed payloads via a security scheme. Callbacks bypass global security by default, so declare an explicit scheme on each callback operation. Document the HMAC header (for example X-Hub-Signature-256) as an apiKey scheme in: header and explain the canonicalization in the description. The mechanics are covered in securing webhooks with signature verification.

Retry and idempotency metadata. Subscribers need to know your retry schedule and how to deduplicate redelivered events. Document an Idempotency-Key or event id field and a Retry-After-aware backoff in the operation description; see retry and idempotency for webhooks.

Custom extensions for delivery metadata. Use x-* extension keys to annotate delivery guarantees, event versioning, or dead-letter behavior that the core spec cannot express. Portal generators ignore unknown extensions safely, and custom rendering can surface them — see documenting webhook callbacks with OpenAPI extensions.

For event streams that need richer modeling than HTTP callbacks, run a parallel AsyncAPI spec and map both into one portal navigation:

# asyncapi.yaml — HTTP webhook channel
asyncapi: 3.0.0
channels:
  userSignedUp:
    address: user/signedup
    messages:
      userSignedUp:
        payload: { $ref: '#/components/schemas/UserCreatedEvent' }
    bindings:
      http: { method: POST }

Webhooks reverse the direction of every assumption

The reason outbound events are hard to document is that they invert the usual roles: your API becomes the client and the subscriber becomes the server. Everything the rest of the specification assumes — that you receive requests, that the consumer chooses when to call, that failures are theirs to retry — is backwards, and readers arriving from the request/response part of the reference bring the wrong mental model with them.

Direction reversal in a webhook In a normal operation the consumer calls your API, while in a webhook your API calls an endpoint the consumer operates. a normal operation consumer (client) your API (server) request a webhook consumer (server) your API (client) delivery roles swap

That reversal is why webhook documentation has to answer questions no other operation does. What does the subscriber’s endpoint have to return for a delivery to count as accepted? What happens when it does not — how many retries, over what period, with what backoff? How does the subscriber know the request genuinely came from you? And what happens if the same event arrives twice, which under any retry policy it eventually will?

The parts the specification has no field for

OpenAPI 3.1 gives webhooks a first-class home, so the payload shape and the expected response are expressible in the document like any other operation. The operational contract around them mostly is not: there is no standard field for a retry schedule, a signature scheme, or a delivery-ordering guarantee. Those live in x- extensions and in prose, which means they are the parts most likely to be omitted and the parts subscribers most need.

Expressible versus extension territory Payload shape and expected response are native to the webhooks object, while retry policy, signature verification and ordering guarantees need extensions and prose. native to the spec payload schema expected response code headers you send validated and rendered automatically extensions and prose retry schedule and backoff signature scheme ordering and duplicate delivery omitted most often, needed most

Treat the right-hand column as required content rather than optional detail. A subscriber cannot build a correct receiver without it: without a documented retry policy they cannot size their endpoint’s timeout budget, without a signature scheme they cannot distinguish your delivery from anyone else’s request, and without an explicit statement about duplicates they will assume exactly-once delivery and build something that breaks the first time a retry succeeds after a timeout.

Verification & Testing

Confirm the spec lints clean, payloads validate, and the portal renders both sections:

npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
echo $?                                  # 0 = clean
npx ajv validate -s schemas/OrderShippedEvent.json -d examples/order-shipped.json --spec=draft2020
npx @redocly/cli build-docs openapi.yaml --output dist/index.html

Open the generated portal: Redoc renders the webhooks object as its own sidebar group separate from paths, and each callback appears nested under the operation that registers it. Verify the runtime expression shows in the callback URL field rather than a literal host. For a final end-to-end check, point a test subscriber (such as a request-bin) at the callback URL and confirm the payload matches the documented schema.

Retries make duplicate delivery certain

Any delivery mechanism with retries will eventually deliver the same event twice: a subscriber times out after processing the payload but before responding, the delivery is recorded as failed, and it is retried. There is no configuration that prevents this, which means the only workable contract is to state plainly that duplicates occur and to give subscribers what they need to handle them.

That is one field — a stable event identifier that is the same across every delivery attempt of the same event. With it, a subscriber can record processed identifiers and ignore repeats, which is a few lines of code. Without it they have to infer identity from the payload, which is unreliable and which every subscriber will get slightly wrong in a different way.

Ordering deserves the same explicit treatment. Most delivery systems make no ordering guarantee across events, and subscribers routinely assume one because events describe a sequence of things that happened in an order. Say whether ordering is guaranteed; if it is not, include a timestamp or a sequence number so a subscriber can detect and handle out-of-order arrival rather than silently applying a stale update over a newer one.

Signature verification is not optional detail

A webhook endpoint is a publicly reachable URL that accepts unauthenticated requests, which makes it the most exposed surface in an integration. Subscribers need a way to establish that a delivery genuinely came from you, and the only workable answer is a signature over the raw request body using a shared secret.

Two details in the documentation matter more than the scheme itself. The signature must be computed over the raw bytes, not over a re-serialised parse — a subscriber whose framework parses the body before they can read it will produce a different signature and conclude your signatures are broken. And a timestamp must be included in the signed material, or a captured delivery can be replayed indefinitely.

Document the exact construction, including what is concatenated in what order, and provide a worked example with a known secret so a subscriber can verify their implementation against something rather than against silence. This is the single piece of webhook documentation where an example is worth more than prose, because everything about it is unforgiving of small mistakes and nothing about it produces a useful error message when wrong.

Troubleshooting

webhooks section missing from the portal — The openapi field is below 3.1.0, so the parser ignores the unrecognized webhooks key. Set openapi: 3.1.0 and add a CI rule to reject downgrades.

Callback “Try It” form is empty — The callback operation has no requestBody or its $ref does not resolve. Add a requestBody pointing to a schema under components/schemas and bundle $refs before linting. The callback-must-have-requestbody rule catches this.

Callback fires to the wrong host across environments — A literal URL was used instead of a runtime expression. Replace it with {$request.body#/callbackUrl} or a servers variable so the destination derives from the request at runtime.

Subscriber receives unsigned, spoofable payloads — The callback inherited no security because callbacks bypass the global requirement. Declare an explicit signature scheme on the callback operation and verify the signature server-side; enforce presence with the callback-must-have-security rule.

Giving subscribers something to test against

The hardest part of building a webhook receiver is that a developer cannot easily make one arrive. They have to trigger a real event in your system, wait for delivery, and hope their endpoint was reachable and correct — a slow loop with poor feedback that gets slower every time something is wrong.

Three things make that loop dramatically shorter, and all of them are documentation or tooling rather than protocol design. A way to send a test delivery on demand, so a subscriber can exercise their endpoint without producing a real event. A visible delivery log showing what was sent, what came back, and which attempt it was, so a failed delivery is diagnosable from your side rather than guessed at from theirs. And a documented payload example per event type that is guaranteed to match the schema, so a receiver can be built and unit-tested before any delivery is attempted at all.

The delivery log is the highest-value of the three by some distance. Nearly every webhook support conversation is a subscriber asking whether an event was sent, and a log that answers that question without a human resolves most of them before they are raised.

It is also worth documenting what a subscriber should return and how quickly. A receiver that does its processing synchronously before responding will eventually exceed your timeout under load and start receiving retries for events it actually handled — which is the most confusing failure mode in this area, because from the subscriber’s side everything worked. Say explicitly that the endpoint should acknowledge quickly and process asynchronously, and state the timeout so they can design to it.

FAQ

What is the difference between webhooks and callbacks in OpenAPI 3.1?

The top-level webhooks object describes outbound events your API sends that are not tied to a specific request, such as a subscription configured out of band. The callbacks key sits on a path operation and describes an event triggered by that operation, with the target URL resolved at runtime from the request via a runtime expression.

How do I validate webhook payloads across multiple environments?

Define the payload with JSON Schema Draft 2020-12 and validate example payloads against it with ajv-cli in CI. Inject environment-specific values such as endpoint URLs and signing secrets at runtime through gateway middleware rather than hardcoding them in the spec.

Can OpenAPI 3.1 document bidirectional WebSocket connections?

No. OpenAPI 3.1 does not natively model bidirectional WebSocket semantics. Use AsyncAPI 3.0 for event-driven WebSocket channels because it supports the ws protocol binding and can describe both inbound and outbound message flows.

How do I regenerate the portal when webhook schemas change?

Trigger a CI job on changes to openapi.yaml that runs redocly build-docs and deploys the static output to your CDN. Use openapi diff tooling in the same job to generate a changelog entry for any breaking change to a webhook payload.

Taken together, the pattern is that outbound events need more documentation than inbound operations, not less. A subscriber is building a server against your client, with no ability to retry on their own terms, no synchronous error to inspect, and no way to ask your system what it thinks happened. Everything you can tell them in advance — the retry schedule, the signature construction, the duplicate and ordering guarantees, the timeout — is something they would otherwise have to discover by experiment in production.

Documenting it up front costs a page; leaving it undocumented costs every subscriber the same investigation, repeatedly, forever.

The asymmetry is worth internalising: a subscriber can read your reference, but they cannot ask your system a question, so anything undocumented becomes an experiment they run against production.

Assume they will run it, and write the page that makes the experiment unnecessary.