Documenting Webhook Callbacks with OpenAPI Extensions
Outbound webhooks are easy to ship and hard to document, because the API is the client and the subscriber is the server — the reverse of every other operation in the spec. OpenAPI 3.1 finally gives webhooks a first-class home with a top-level webhooks object, but the parts subscribers care about most (retry policy, signature verification) still live in x- extensions because the core specification has no field for them. This guide covers both the native object and the extension metadata, plus CI validation and portal rendering. It is part of the Webhook & Callback Definitions section of the OpenAPI & AsyncAPI Schema Authoring pillar.
Problem & Context
Before OpenAPI 3.1, anyone documenting an event a service emits had two awkward choices: shoehorn the event into a callbacks block attached to a registration operation, or invent x- extensions that no generator understood. OpenAPI 3.1 adds a top-level webhooks object that sits beside paths, which removes the need for extension hacks to describe the request and response shapes. That covers the HTTP contract — what the body looks like and what status code the subscriber should return.
What it does not cover is operational behavior. The spec cannot express “we retry five times with exponential backoff” or “every delivery carries an HMAC signature in X-Hub-Signature-256,” yet those are exactly the facts a subscriber needs before going to production. Those facts go in x-webhook-metadata extensions, and the catch is that default portal templates strip unknown x- keys, so the metadata is invisible unless you render it deliberately. This guide threads both needs: a clean native contract plus discoverable operational metadata, validated in CI.
Step-by-Step Solution
1. Define webhooks with the native object
Add a top-level webhooks object. Each entry is keyed by an event name and describes the operation the API will call on a subscriber:
# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Order Service API
version: 1.0.0
webhooks:
order.completed:
post:
operationId: onOrderCompleted
summary: Fires when an order reaches the completed state
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OrderEvent'
responses:
'200':
description: Event acknowledged
'204':
description: Event accepted, no body
Reuse payload schemas through $ref so the same OrderEvent definition backs both the webhook and any path that returns the same shape.
2. Express retry and signature metadata
Attach an x-webhook-metadata extension to document behavior the core spec cannot model:
x-webhook-metadata:
retry_policy: exponential_backoff
max_retries: 5
signature_header: X-Hub-Signature-256
This tells a subscriber to expect up to five retries and to verify each delivery against the X-Hub-Signature-256 header. The portal will not show these keys by default — step 4 and the Gotchas section explain how to surface them.
3. Validate completeness in CI
A webhook with no declared response or request body is a documentation gap. Enforce both with custom Spectral rules:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
webhook-has-response:
description: All webhook operations must define at least one response
given: $.webhooks[*][post,put,patch]
severity: error
then:
field: responses
function: truthy
webhook-has-request-body:
description: Webhook POST operations must define a requestBody
given: $.webhooks[*].post
severity: error
then:
field: requestBody
function: truthy
Run the linter before merging:
npm install -g @stoplight/spectral-cli
spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
When responses is missing, Spectral exits non-zero and prints:
openapi.yaml
12:5 error webhook-has-response All webhook operations must define at least one response
✖ 1 problem (1 error, 0 warnings)
4. Render the docs in the portal
Generate static documentation that includes the webhook definitions:
npx @redocly/cli build-docs openapi.yaml --output dist/index.html
Redoc renders webhooks as a distinct section in the sidebar alongside paths, so subscribers can find the event contract without hunting through registration operations. The output confirms the section was built:
Prerendering docs
Created a docs file dist/index.html
To enumerate webhook definitions programmatically — for example to build an event catalog table — read the bundled document with @redocly/openapi-core:
const { loadConfig, bundle } = require('@redocly/openapi-core');
async function listWebhooks(specPath) {
const config = await loadConfig();
const { bundle: { parsed } } = await bundle({ ref: specPath, config });
return Object.keys(parsed.webhooks || {});
}
Complete Working Example
A single self-contained openapi.yaml combining a native webhook, the metadata extension, a reusable payload schema, and the pre-3.1 callbacks pattern for a registration-driven event:
# openapi.yaml (OpenAPI 3.1.0) — lint with the .spectral.yaml above
openapi: 3.1.0
info:
title: Order Service API
version: 1.0.0
webhooks:
order.completed:
post:
operationId: onOrderCompleted
summary: Fires when an order reaches the completed state
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OrderEvent'
responses:
'200':
description: Event acknowledged
'204':
description: Event accepted, no body
x-webhook-metadata:
retry_policy: exponential_backoff
max_retries: 5
signature_header: X-Hub-Signature-256
paths:
/subscriptions:
post:
summary: Register a webhook endpoint
operationId: registerWebhook
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookRegistration'
responses:
'201':
description: Subscription created
callbacks:
onEvent:
'{$request.body#/callbackUrl}':
post:
operationId: receiveEvent
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/OrderEvent'
responses:
'200':
description: Callback received
components:
schemas:
OrderEvent:
type: object
required: [id, status, occurredAt]
properties:
id:
type: string
format: uuid
status:
type: string
enum: [completed]
occurredAt:
type: string
format: date-time
WebhookRegistration:
type: object
required: [callbackUrl]
properties:
callbackUrl:
type: string
format: uri
The callbacks block uses the runtime expression {$request.body#/callbackUrl} to resolve the subscriber URL from the registration request, which keeps the same definition valid across every environment.
Gotchas & Edge Cases
Default templates silently drop x- extensions. Redoc and Scalar strip unknown vendor keys, so x-webhook-metadata never reaches the rendered page out of the box. To surface retry and signature details, write a small Redoc plugin or a post-processing step that reads the spec and injects the values into the generated HTML; otherwise document them in prose alongside the webhook.
Circular $ref in payload schemas crashes parsers. A webhook payload that recursively references the root document can overflow the stack in JSON Schema parsers and hang the portal build. Isolate shared types in components.schemas and reference them from both the webhook and any path that reuses them, rather than nesting the document inside itself.
Missing subscriber response codes leave the contract ambiguous. Subscribers usually return 200 OK or 204 No Content, and omitting these makes strict linting fail and leaves the portal response table empty. Always declare at least one success response per webhook operation so the subscriber knows exactly what acknowledgement the API expects.
FAQ
Can I use OpenAPI extensions alongside AsyncAPI for webhook docs?
Yes, but keep a single source of truth. Use the OpenAPI webhooks object for HTTP-triggered events and AsyncAPI for broker-based streams, and cross-reference both in the portal navigation.
How do I automate example payload generation for documented webhooks?
Add examples entries to each webhook operation’s requestBody content and reference external JSON files with $ref. Enforce their presence with a Spectral truthy rule in CI.
Why does my CI pipeline fail when validating webhook extensions?
Strict validators reject unknown x- keys when additionalProperties is false on the operation schema. Allow additional annotations in Spectral or define your extensions in a custom dialect to avoid false positives.