Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

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 thi

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:

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:

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).
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 is available in the browser.

What to read next: how to document webhooks pairs with REST error responses for the receiver's failure codes, and publishing API docs and an MCP endpoint from one spec covers exposing the webhook catalog to partners.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.