Skip to main content

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.

Callbacks hang off a request; webhooks do not Top: a consumer request supplies a callback URL and the API posts the result back to it, so the delivery belongs to that operation. Bottom: an internal event triggers a delivery to a pre-registered endpoint, with no request to attach it to. callbacks — the delivery belongs to a request POST /authorisations body carries callbackUrl 202 Accepted work starts POST to that callbackUrl documented under the operation webhooks — the delivery belongs to an event an order is paid no consumer request involved registered endpoints configured out of band POST to each of them documented at the top level Using callbacks for event-triggered deliveries forces you to invent a fake operation for them to hang from.

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.

One document, two generated artifacts The webhooks section of the sender's document produces payload validation on the sending side and a typed receiver route on the consuming side, so both ends move together when the schema changes. webhooks: in the document published with the API sender side every emitted event validated against the published schema receiver side typed route generated from the same payload schema

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:

The outbound surface most rulesets never look at Standard rulesets inspect paths and components thoroughly and the webhooks section barely at all, so signature headers, failure responses and payload schemas need rules written specifically for it. inbound: paths covered by spectral:oas operation ids, summaries, responses parameters, schemas, examples dozens of rules out of the box outbound: webhooks almost nothing out of the box signature header — your rule default response — your rule two rules close most of the gap
# .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 webhooks section. 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 paths like 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.