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:
-
Idempotency key. Every event has a unique
id; receivers must handle duplicate delivery (retries make it inevitable) by recording processed ids. - 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.
-
Payload shape policy. State whether
datais 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-SignatureorStripe-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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.