Documenting Webhooks with OpenAPI Callbacks
Problem: your API sends outbound HTTP requests to consumer endpoints, and the OpenAPI document describes none of it. Consumers implement receivers from a wiki page and an example payload; when the payload gains a field, nobody’s types change and nobody notices. This guide is part of Webhook and Callback Contracts and covers the two OpenAPI constructs that fix it — and which one applies when.
Root Cause: Two Constructs That Look Interchangeable
OpenAPI has two ways to describe a request your API makes, and they are not alternatives — they describe different situations.
Step 1: Declare a Webhook Per Event Type
The webhooks section is a map of names to Path Item Objects — the same structure as an entry under paths, so everything you already know about operations applies.
openapi: 3.1.1
info: { title: Orders API, version: 2.15.0 }
webhooks:
orderPaid:
post:
operationId: onOrderPaid
summary: Delivered when an order is paid.
description: |
Sent to every endpoint registered for the `order.paid` event on the
account that owns the order. Deduplicate on `id`; deliveries are
at-least-once.
parameters:
- $ref: '#/components/parameters/WebhookSignature'
- $ref: '#/components/parameters/WebhookTimestamp'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OrderPaidEvent' }
examples:
typical:
value:
id: evt_9tX2mLq7Rk3vBn5cWd8pYs1a
type: order.paid
created_at: "2026-07-29T10:00:00Z"
api_version: "2026-07-01"
data: { order_id: ord_42, amount: 4200, currency: EUR }
responses:
'200': { description: Accepted. Any 2xx stops retries. }
'410': { description: Endpoint permanently gone; we disable it. }
default: { description: Treated as a failure; retried on schedule. }
orderCancelled:
post:
operationId: onOrderCancelled
summary: Delivered when an order is cancelled.
parameters:
- $ref: '#/components/parameters/WebhookSignature'
- $ref: '#/components/parameters/WebhookTimestamp'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OrderCancelledEvent' }
responses:
'200': { description: Accepted. }
default: { description: Retried on schedule. }
Declaring the signature headers as reusable parameters rather than repeating them keeps every event type consistent — and makes it a one-line change if the scheme ever gains a version.
Step 2: Use Callbacks Where the URL Came From a Request
When the receiver’s URL arrived in a request, the callback object documents the relationship. The key is a runtime expression describing where the URL comes from.
paths:
/authorisations:
post:
operationId: createAuthorisation
summary: Begin an asynchronous payment authorisation.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, currency, callback_url]
properties:
amount: { type: integer }
currency: { type: string }
callback_url:
type: string
format: uri
description: Where the result will be posted once known.
responses:
'202': { description: Authorisation started; the result follows by callback. }
callbacks:
authorisationResult:
# The expression says WHERE the URL came from — it is documentation,
# not a template the tooling expands at runtime.
'{$request.body#/callback_url}':
post:
operationId: onAuthorisationResult
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AuthorisationResult' }
responses:
'200': { description: Result acknowledged. }
Common runtime expressions are $request.body#/field, $request.query.param, $request.header.X-Callback-Url and $response.body#/links/self. Rendering tools use them to explain the flow; none of them are evaluated by a mock server.
Step 3: Generate the Receiver Stub
The practical payoff of declaring webhooks is that consumers can generate the endpoint instead of writing it from a wiki page.
# Emit TypeScript types for every webhook payload in the document.
npx openapi-typescript@7 openapi.yaml -o src/api.d.ts
// receiver.ts — the payload type comes from the sender's document
import type { webhooks } from "./api";
import express from "express";
type OrderPaid = webhooks["orderPaid"]["post"]["requestBody"]["content"]["application/json"];
const app = express();
// Raw body: signature verification needs the exact bytes, so JSON parsing
// must happen after verification, never before it.
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.body, req.get("X-Signature")!, req.get("X-Signature-Timestamp")!)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8")) as OrderPaid;
res.sendStatus(202); // acknowledge first
void queue.enqueue(event); // then do the work
});
When the sender adds a field to OrderPaidEvent and republishes, regenerating produces a changed type — which is exactly the drift signal a wiki page cannot give.
Step 4: Lint the Outbound Surface
Webhooks are easy to under-document because they are not what most linting rulesets look at. Two rules cover the gaps that matter:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
webhook-requires-signature-header:
description: Every webhook must document its signature header.
given: "$.webhooks[*][post]"
severity: error
then:
field: parameters
function: schema
functionOptions:
schema:
type: array
contains:
type: object
properties:
$ref: { type: string, pattern: "WebhookSignature" }
webhook-documents-failure-behaviour:
description: Every webhook must document a default response.
given: "$.webhooks[*][post].responses"
severity: error
then:
field: default
function: truthy
Verification
# The document declares the webhooks and they resolve.
npx @redocly/cli@1.25 lint openapi.yaml
# ✓ no errors
yq -r '.webhooks | keys | .[]' openapi.yaml
# orderPaid
# orderCancelled
# The generated types actually contain the webhook payloads.
grep -c 'webhooks' src/api.d.ts
# 1
# A payload fixture validates against the published schema.
npx ajv-cli@5 validate -s <(yq -o=json '.components.schemas.OrderPaidEvent' openapi.yaml) \
-d fixtures/order-paid.json --spec=draft2020
# fixtures/order-paid.json valid
Edge Cases and Caveats
- OpenAPI 3.0 has no
webhookssection. On 3.0 you must model outbound deliveries as callbacks on a subscription operation, which produces awkward documentation. Upgrading to 3.1 is usually the smaller job — see the OpenAPI deep dive for what else changes. - Mock servers ignore webhooks. A mock serves inbound requests; it will not call you. Testing the delivery direction needs a real sender or a fixture replay, which is why the signature verification tests matter so much.
- Runtime expressions are not templates.
{$request.body#/callback_url}is a description of provenance. Tools that try to resolve it at request time are misreading the specification. - One route, many event types. Most receivers expose a single URL for all events. Declare one webhook per type anyway: the generated union tells the receiver which shapes may arrive, and a new type added by the sender shows up as a compile change rather than as an unrecognised payload at runtime.
- Subscription management is a separate API. How a receiver registers a URL, rotates its signing secret, or lists its endpoints belongs under
pathslike any other operation. Documenting the delivery without documenting the subscription leaves half the integration undescribed.
Documenting the Subscription Alongside the Delivery
A webhooks section describes what you send. It says nothing about how a receiver comes to be sent anything, and a document that omits the subscription half leaves an integrator with no starting point.
Model the subscription as ordinary operations under paths, because that is what they are: creating an endpoint registration is a POST, listing them is a GET, rotating a secret is a POST to a sub-resource. The one addition worth making is a cross-reference in the descriptions — the webhook entry says which subscription operation enables it, and the subscription operation lists the event types it accepts, drawn from the same enumeration the envelope uses.
That shared enumeration is what keeps the two halves consistent. When a new event type is added, it appears in the envelope’s type enum, in the subscription request’s allowed values, and as a new webhooks entry. If it appears in only two of the three, the document describes an event a receiver can be sent but cannot subscribe to.
Publishing the Document Where Receivers Will Find It
A webhooks section only pays off if receivers know it exists. Link it from the same page as the subscription instructions, and make the raw document retrievable at a stable URL rather than only rendered in a documentation site — the generator a receiver runs needs the file, not the page.
Frequently Asked Questions
When should I use webhooks rather than callbacks?
Use webhooks when the delivery is triggered by something happening in your system and the subscription was configured out of band — the usual case for partner integrations. Use callbacks when the receiver’s URL arrived in a specific request and the delivery belongs to that request, such as a payment authorisation whose result is posted back.
What does the runtime expression in a callback key mean?
It says where the callback URL comes from in the triggering request. $request.body#/callbackUrl means the URL was supplied in that field of the request body. It is documentation, not templating: tools use it to explain the flow rather than to build a URL at runtime.
Can I generate a receiver stub from the webhooks section?
Yes. A webhook entry is a full operation object, so a generator can emit a server route with the payload type already bound. That is the strongest practical reason to declare webhooks in the document at all.
Does OpenAPI 3.0 support the webhooks section?
No — top-level webhooks arrived in 3.1. On 3.0 the usual workaround is to describe outbound deliveries as callbacks attached to a subscription operation, which is clumsy but generates usable documentation.
How do I document more than one event type on one endpoint?
Declare one webhook per event type, each with its own operationId and payload schema. Receivers still get a single route, but the document tells them exactly which shapes may arrive on it, and generators can emit a discriminated union.