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