# How to document webhooks in OpenAPI 3.1 (with signatures, retries, and examples)

Webhooks are the most under-documented part of an API surface and the part most likely to page someone at 3 a.m. Consumers cannot discover them by making requests — the server calls them — so the document is the only thing standing between an integration and guesswork. OpenAPI 3.1 fixed the structural problem by promoting webhooks to a top-level `webhooks` map (previously they were awkward `callbacks` nested inside operations). Structure solved, the remaining work is content: signatures, retries, ordering, and examples precise enough to verify a receiver against. Here is a complete pattern.

## The top-level webhooks map

A webhook entry is a Path Item describing the request the provider sends:

``` yaml
webhooks:
  projectUpdated:
    post:
      summary: Sent when a project is created or updated.
      operationId: projectUpdatedWebhook
      tags: [webhooks]
      security:
        - webhookSignature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            examples:
              updated:
                $ref: '#/components/examples/ProjectUpdatedEventExample'
      responses:
        '200':
          description: Acknowledged. Any 2xx is treated as success.
        '400':
          description: Invalid payload; the delivery is not retried.
        '410':
          description: Subscription removed by the consumer.
        '5xx':
          description: Temporary failure; retried with backoff.
```

The method is almost always POST. Note the responses document what the *consumer's* endpoint should return, which is the inversion that makes webhooks confusing — your server is implementing someone else's client contract.

## The envelope: metadata plus the resource

A consistent envelope lets consumers version and route without parsing every payload shape:

``` yaml
WebhookEnvelope:
  type: object
  required: [id, type, created_at, data]
  properties:
    id:
      type: string
      description: Unique event id, used for idempotency and deduplication.
    type:
      type: string
      enum: [project.created, project.updated, project.deleted]
    created_at:
      type: string
      format: date-time
    api_version:
      type: string
      example: "2026-08-01"
    data:
      $ref: '#/components/schemas/Project'
```

Document the three guarantees consumers actually need:

1.  **Idempotency key.** Every event has a unique `id`; receivers must handle duplicate delivery (retries make it inevitable) by recording processed ids.
2.  **Ordering semantics.** State plainly whether events are guaranteed in order per resource. Most systems are at-least-once with best-effort ordering; consumers should reconcile against a fetch rather than assume strict sequence.
3.  **Payload shape policy.** State whether `data` is the full resource or a slim change object containing only changed fields. Full-resource snapshots are simpler for consumers; diffs are smaller but force extra fetches. Pick one and say which.

## Signature verification

This is the section integrations get wrong most often, so document it to the line:

-   Which header carries the signature (commonly a provider-prefixed `X-Signature` or `Stripe-Signature`-style header including timestamp and signature).
-   The exact signed string — typically timestamp plus `.` plus raw body. Emphasize the **raw** body: parsing and re-serializing JSON changes whitespace and breaks HMAC verification.
-   The algorithm and encoding, e.g. HMAC-SHA256 hex, and where the secret comes from (per endpoint, shown once at registration).
-   Timestamp tolerance to prevent replay (reject events older than five minutes).

``` yaml
securitySchemes:
  webhookSignature:
    type: apiKey
    in: header
    name: X-Powerduck-Signature
    description: >-
      HMAC-SHA256 of `${timestamp}.${rawBody}` using the endpoint signing
      secret, hex-encoded. The timestamp is sent in X-Powerduck-Timestamp;
      reject events older than 300 seconds.
```

Include a worked verification snippet in the docs (not in the spec itself — the spec describes, the docs portal teaches), with a known secret, a known body, and the expected signature. Without a fixture, teams burn hours on encoding mismatches (hex vs base64 is the classic).

## Retries and the response contract

Document the delivery policy as a table consumers can design against:

| Consumer response | Provider action                              |
|-------------------|----------------------------------------------|
| Any 2xx           | Mark delivered; no retry                     |
| 400 / 410         | Do not retry (410 removes the subscription)  |
| 401/403 or 404    | Retry briefly, then disable after N failures |
| 408 / 429 / 5xx   | Retry with exponential backoff and jitter    |

State the schedule (e.g. retries at 1m, 5m, 30m, 2h, 12h over 3 days), the timeout for the consumer's response (often 5–10 seconds), and that non-2xx bodies are logged but not parsed. Also document the disablement policy: after repeated failures the endpoint is paused, and how the consumer re-enables it — a silently disabled webhook is a support ticket every time.

## Registration and testing

The webhook lifecycle itself is REST: register an endpoint URL, select event types, receive the secret, test, rotate. Document those operations as normal paths (`POST /v1/webhooks`, list, rotate secret, delete) and cross-link them from the webhook section. Two features make integrations dramatically easier and belong in the spec or docs:

-   **A test event action** that sends a synthetic event with a documented fixture on demand.
-   **A delivery log** (recent attempts, status codes, response bodies) exposed via API — this is the difference between "maybe your server got it" and a one-minute diagnosis.

Examples in the spec should cover each event type, and the mock server should be able to *send* them to a local receiver URL so consumers can develop against webhooks before going live. That reverses the usual mock direction: instead of mocking the provider's responses to you, the mock plays the provider calling your endpoint.

## OpenAPI 3.1 vs the old callbacks syntax

If a document predates 3.1, webhooks may appear as `callbacks` attached to the operation that creates a subscription. They still describe outgoing requests, but they are buried where nobody rendering a webhook catalog looks, and they cannot express events unrelated to a specific subscribing call. Move them to the top-level `webhooks` map during the 3.1/3.2 upgrade; the operation that registers a subscription can reference the webhook by description.

In Powerduck's workspace, webhooks are first-class entries in the same OpenAPI document as the operations, mocks can deliver events to a local receiver for end-to-end testing, and scenario tests assert on signature validation and idempotent handling — all from the spec that renders the docs and serves the MCP endpoint. The [demo](https://www.powerduck.com/app/?ref=powerduck.com) is available in the browser.

**What to read next:** [how to document webhooks pairs with REST error responses](https://www.powerduck.com/blog/rest-api-error-response-rfc-9457/) for the receiver's failure codes, and [publishing API docs and an MCP endpoint from one spec](https://www.powerduck.com/blog/publish-api-docs-mcp-custom-domain/) covers exposing the webhook catalog to partners.

