API reference
Write webhook documentation with OpenAPI 3.1
Webhook pages generated from your OpenAPI 3.1 spec, with payloads, signature headers, and acknowledgments, plus a receiving guide with a working Node.js receiver.
By Hayden Bleasel13 min read

To document a webhook in OpenAPI 3.1, add it under the spec's top-level webhooks key: name the event, then describe the request your API sends, with its headers, its payload schema, and the responses that acknowledge it. Blume gives each webhook its own reference page, labeled as a webhook, with an example payload where an endpoint page would have its Try it panel.
By the end, the fictional Acme Messages API has two webhook pages beside its endpoints, plus a hand-written page on signatures, retries, and duplicates with a receiver your readers can run. Those delivery rules belong to the example API, which follows the Standard Webhooks spec. Blume doesn't send, sign, or retry anything: it renders what your spec says.
If you haven't mounted a spec yet, start with Generate API docs from an OpenAPI spec. If your events travel through a broker like Kafka rather than as HTTP requests to your customers' URLs, AsyncAPI fits better: see Document Kafka events with AsyncAPI.
Webhooks, callbacks, and endpoints
OpenAPI 3.1 has three places to describe an HTTP request. They differ in who sends it and where the URL comes from:
| In the spec | Who sends it | Where the URL comes from | In Blume |
|---|---|---|---|
paths | Your reader's code, to your API | Your spec's servers | A page per operation, with Try it and code samples |
webhooks | Your API, to your reader's endpoint | Registered once, outside any API call | A page per webhook, labeled Webhook, with an example payload |
An operation's callbacks | Your API, to a URL from one request | A field in that request, like callbackUrl | A Callbacks section on that operation's page |
Keep webhooks out of paths. Listed there, a webhook gets a Try it panel that sends its payload to your API, which is the opposite of what happens. The same goes for a hand-written API page with api frontmatter: it adds a Try it panel too.
Acme's customers register one endpoint that receives every message event, so this guide uses webhooks. If your API takes a URL with each request instead, see When the URL comes with the request.
Add the webhooks to your spec
Here's the whole spec: the two message endpoints from the OpenAPI guide, trimmed, plus a webhook for each event Acme sends.
openapi: 3.1.0
info:
title: Acme Messages API
version: 1.0.0
description: Send transactional email and SMS, and get told when each one is delivered or fails.
servers:
- url: https://api.acme.example/v1
security:
- bearerAuth: []
tags:
- name: Messages
description: Send a message and check whether it arrived.
- name: Webhooks
description: >-
Requests Acme sends to your endpoint when a message is delivered or
fails. See [Receive webhooks](/api/receive-webhooks) to verify and
acknowledge them.
paths:
/messages:
post:
operationId: sendMessage
tags: [Messages]
summary: Send a message
description: Queues an email or SMS for delivery and returns its ID right away.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SendMessageRequest"
responses:
"202":
description: The message is queued.
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
/messages/{id}:
get:
operationId: getMessage
tags: [Messages]
summary: Get a message
description: Returns a message and its current delivery status.
parameters:
- name: id
in: path
required: true
schema:
type: string
example: msg_8f2k
responses:
"200":
description: The message.
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
webhooks:
messageDelivered:
post:
operationId: messageDelivered
tags: [Webhooks]
summary: Message delivered
description: Sent when the recipient's mail server or carrier accepts a message.
parameters:
- $ref: "#/components/parameters/WebhookId"
- $ref: "#/components/parameters/WebhookTimestamp"
- $ref: "#/components/parameters/WebhookSignature"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MessageEvent"
example:
type: message.delivered
timestamp: "2026-09-27T14:03:11Z"
data:
id: msg_8f2k
status: delivered
channel: email
responses:
"2XX":
description: Acknowledged. Acme won't send this event again.
default:
description: >-
Any other status, or no response within 15 seconds. Acme retries
with the same `webhook-id`.
messageFailed:
post:
operationId: messageFailed
tags: [Webhooks]
summary: Message failed
description: Sent when Acme stops trying to deliver a message, for example after a hard bounce.
parameters:
- $ref: "#/components/parameters/WebhookId"
- $ref: "#/components/parameters/WebhookTimestamp"
- $ref: "#/components/parameters/WebhookSignature"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MessageEvent"
example:
type: message.failed
timestamp: "2026-09-27T14:05:42Z"
data:
id: msg_3j9q
status: failed
channel: sms
error: The number can't receive SMS.
responses:
"2XX":
description: Acknowledged. Acme won't send this event again.
default:
description: >-
Any other status, or no response within 15 seconds. Acme retries
with the same `webhook-id`.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: An API key, sent as a bearer token.
parameters:
WebhookId:
name: webhook-id
in: header
required: true
description: >-
The event's ID. It stays the same on every retry, so use it to skip
events you've already handled.
schema:
type: string
example: evt_2mYq8cLkT4vR7nWx
WebhookTimestamp:
name: webhook-timestamp
in: header
required: true
description: >-
When this attempt was sent, in Unix seconds. Reject requests more
than five minutes old.
schema:
type: integer
example: 1790517791
WebhookSignature:
name: webhook-signature
in: header
required: true
description: >-
One or more space-separated signatures. Each is `v1,` followed by the
base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`,
keyed with your endpoint's signing secret. See
[Receive webhooks](/api/receive-webhooks) for code that checks it.
schema:
type: string
schemas:
SendMessageRequest:
type: object
required: [channel, to, template]
properties:
channel:
type: string
enum: [email, sms]
to:
type: string
description: An email address, or a phone number in E.164 format.
template:
type: string
description: The ID of the template to send.
Message:
type: object
required: [id, status, channel]
properties:
id:
type: string
example: msg_8f2k
status:
type: string
enum: [queued, sent, delivered, failed]
channel:
type: string
enum: [email, sms]
error:
type: string
description: Why delivery failed. Only set when `status` is `failed`.
MessageEvent:
type: object
required: [type, timestamp, data]
properties:
type:
type: string
enum: [message.delivered, message.failed]
description: What happened.
timestamp:
type: string
format: date-time
description: When it happened. Retries keep the original time.
data:
$ref: "#/components/schemas/Message"Each part of a webhook maps to something your reader needs:
- The key, like
messageDelivered, names the event. It isn't a path, because the URL belongs to your reader. Give each webhook anoperationIdas well; without one, Blume builds the page's URL from the key. - The method,
post, is how Acme sends it. - Header parameters document the signature. They're defined once under
components.parametersand referenced from each webhook, so every page shows the same three headers. - The request body is the payload. Its
exampleis what readers copy; without one, Blume builds an example from the schema. - The responses are the acknowledgment: what the receiver sends back.
2XXcovers every success status, anddefaultcovers everything else, which is where the retry rule goes.
The webhooks declare no security, and they don't need to. The root security describes how readers call Acme, so Blume doesn't apply it to webhook pages. A webhook page lists only the security the webhook declares itself, which is why the signature lives in the header parameters instead.
Mount the reference
Mount the spec with the openapi() adapter, and add a header tab that points at its route so readers can reach it:
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
export default defineConfig({
title: "Acme Docs",
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "API reference", path: "/api" },
],
},
reference: [openapi({ route: "/api", spec: "./openapi.yaml" })],
});Run npx blume dev. The overview at /api has a Messages section and a Webhooks section, each with its tag's description. Every webhook gets a URL built from its first tag and its operation ID, like an endpoint:
| Page | URL |
|---|---|
| Send a message | /api/messages/send-message |
| Get a message | /api/messages/get-message |
| Message delivered | /api/webhooks/message-delivered |
| Message failed | /api/webhooks/message-failed |
A webhook with no tag is filed in a Webhooks group that comes after the endpoints. Declaring a Webhooks tag, as this spec does, gives that group a description on the overview.
What a webhook page shows
The page is titled from the summary and headed with the method and the webhook's name, POST messageDelivered, next to a Webhook label. Below the description come the header parameters, the request body's schema, and the responses. The right-hand rail shows a Payload panel with the example and a copy button, then the responses. There is no Try it panel and there are no generated code samples, since there's nothing for your reader to call.
Webhook pages are real pages: they're in search, llms.txt, and the MCP server, and each has a Markdown copy at its URL plus .md. That copy says the API sends this request to your endpoint, so an agent doesn't try to call it.
Each page's meta description is the webhook's description followed by a generated sentence, like "Reference for the POST messageDelivered webhook in the Acme Messages API." It's clipped to fit, so keep the description to one short sentence. That's why the link to the receiving guide sits in the tag and the webhook-signature header rather than in each webhook's description.
Decide the delivery rules
The schema tells your reader what arrives. They also need to know what to send back, what happens while their endpoint is down, and whether the same event can arrive twice. These are your API's rules, so settle them before you write them down. Acme's are:
- Acknowledgment. Any 2xx status within 15 seconds. Anything else is a failed delivery.
- Retries. Six, after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, and 10 hours. Then Acme drops the event.
- Duplicates. Every retry has the same
webhook-id, so receivers use it to skip repeats. - Order. Not guaranteed. The payload's
timestampkeeps the original time, and Get a message returns the current status. - Signatures. HMAC-SHA256 over the ID, timestamp, and body, as Standard Webhooks defines it. Receivers reject anything older than five minutes.
The short version goes in the spec, next to the fields it's about, so it appears on every webhook page: the default response says a failure is retried with the same webhook-id, and that header says what it's for. The full version, with the schedule and working code, goes on one hand-written page that the webhook pages link to.
Write the receiving guide
Put the page in a folder named after the reference route, and it joins the API reference tab's sidebar beside the generated pages. It ends with a receiver your readers can run:
---
title: Receive webhooks
description: Verify, acknowledge, and deduplicate the webhooks the Acme Messages API sends to your endpoint.
---
Acme sends a `POST` request to your endpoint when a message is
[delivered](/api/webhooks/message-delivered) or
[fails](/api/webhooks/message-failed). When you register the endpoint with
Acme, you get its signing secret, which starts with `whsec_`.
## Verify the signature
Every request carries three headers, following the
[Standard Webhooks](https://www.standardwebhooks.com/) spec: `webhook-id`,
`webhook-timestamp`, and `webhook-signature`. To check a request:
1. Reject it if `webhook-timestamp` is more than five minutes from now.
2. Compute an HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`. The
key is the part of your secret after `whsec_`, base64-decoded.
3. Accept it if the result matches any `v1,` signature in `webhook-signature`.
The header holds more than one while Acme rotates your secret.
Hash the body exactly as it arrived. Parsing and re-serializing the JSON
changes the bytes, and the signature won't match.
## Acknowledge within 15 seconds
Respond with any 2xx status within 15 seconds. Acme treats any other status,
a timeout, or a refused connection as a failed delivery. If an event takes
longer to handle, save it, respond, and do the work afterward.
## Retries and duplicates
Acme retries a failed delivery six times: after 5 seconds, 5 minutes,
30 minutes, 2 hours, 5 hours, and 10 hours. After that, it drops the event.
To catch up after an outage, call [Get a message](/api/messages/get-message)
for the messages you're waiting on.
Every retry carries the same `webhook-id`. Record the IDs you've handled, and
acknowledge a repeat without acting on it again.
Events can arrive out of order: a retry can land after a newer event for the
same message. Compare the payload's `timestamp`, or fetch the message for its
current status.
## Example receiver
This Node.js server does all of the above with no dependencies. Set
`ACME_WEBHOOK_SECRET` to your secret, then run `node receiver.mjs`.
```js receiver.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
// Your endpoint's signing secret, which starts with whsec_.
const key = Buffer.from(
process.env.ACME_WEBHOOK_SECRET.replace(/^whsec_/, ""),
"base64"
);
const TOLERANCE_SECONDS = 5 * 60;
// Event IDs you've already handled. Keep these in your database in
// production, so a restart doesn't forget them.
const handled = new Set();
function isFromAcme(id, timestamp, body, signatures) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= TOLERANCE_SECONDS)) {
return false;
}
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(body)
.digest();
return signatures.split(" ").some((entry) => {
const [version, signature = ""] = entry.split(",");
const given = Buffer.from(signature, "base64");
return (
version === "v1" &&
given.length === expected.length &&
timingSafeEqual(given, expected)
);
});
}
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/webhooks/acme") {
res.writeHead(404).end();
return;
}
// Read the raw bytes: the signature covers the body exactly as sent.
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const id = req.headers["webhook-id"] ?? "";
const timestamp = req.headers["webhook-timestamp"] ?? "";
const signatures = req.headers["webhook-signature"] ?? "";
if (!isFromAcme(id, timestamp, body, signatures)) {
res.writeHead(401).end();
return;
}
// A retry of an event you've handled: acknowledge it and do nothing.
if (handled.has(id)) {
console.log(`${id}: duplicate, skipped`);
res.writeHead(200).end();
return;
}
const event = JSON.parse(body.toString("utf8"));
console.log(`${id}: ${event.data.id} is ${event.data.status}`);
handled.add(id);
res.writeHead(200).end();
});
server.listen(3000, () => {
console.log("Listening on http://localhost:3000/webhooks/acme");
});
```The page lives at /api/receive-webhooks, the URL the spec's Webhooks tag and webhook-signature header link to. A reader who lands on a webhook page from search is one click from working code.
The receiver makes the choices a production one should:
- It hashes the raw request bytes, before any JSON parsing.
- It accepts any matching
v1signature in the header, so a secret rotation doesn't break it, and compares withtimingSafeEqualrather than===. - It rejects stale timestamps, so a captured request can't be replayed later.
- It acknowledges a duplicate with a 200 without acting on it. A non-2xx answer would make Acme retry an event that already worked.
The one shortcut is the in-memory Set of handled IDs. In production, store them in your database, so a restart doesn't forget them.
Test the receiver locally
Acme doesn't exist, so test the receiver with a script that signs events the same way. Save the receiver from the page above as receiver.mjs, and this sender beside it:
import { createHmac, randomUUID } from "node:crypto";
const key = Buffer.from(
process.env.ACME_WEBHOOK_SECRET.replace(/^whsec_/, ""),
"base64"
);
// Pass an event ID to resend it, the way Acme does on a retry.
const id = process.argv[2] ?? `evt_${randomUUID()}`;
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({
type: "message.delivered",
timestamp: new Date().toISOString(),
data: { id: "msg_8f2k", status: "delivered", channel: "email" },
});
const signature = createHmac("sha256", key)
.update(`${id}.${timestamp}.${body}`)
.digest("base64");
const response = await fetch("http://localhost:3000/webhooks/acme", {
body,
headers: {
"content-type": "application/json",
"webhook-id": id,
"webhook-signature": `v1,${signature}`,
"webhook-timestamp": timestamp,
},
method: "POST",
});
console.log(`${id}: ${response.status}`);Both scripts read the secret from the environment. In one terminal, set a test secret and start the receiver:
export ACME_WEBHOOK_SECRET=whsec_bG9jYWwtdGVzdC1zZWNyZXQ=
node receiver.mjsIn a second terminal, set the same secret and send one event twice, the way a retry would:
export ACME_WEBHOOK_SECRET=whsec_bG9jYWwtdGVzdC1zZWNyZXQ=
node send-test-event.mjs evt_test_1
node send-test-event.mjs evt_test_1Both requests print evt_test_1: 200, and the receiver handles the event only once:
Listening on http://localhost:3000/webhooks/acme
evt_test_1: msg_8f2k is delivered
evt_test_1: duplicate, skippedRun the sender with no argument for a fresh event ID each time. To see a rejection, sign with the wrong secret: ACME_WEBHOOK_SECRET=whsec_AAAA node send-test-event.mjs prints a 401. The receiver also accepts events signed by the Standard Webhooks project's own standardwebhooks library, so it works with an API that signs with that library.
When the URL comes with the request
Some APIs take a URL with each request, like a status callback on a message. That's an OpenAPI callback, and it belongs on the operation that takes the URL, not under webhooks. Here, Send a message accepts a statusCallbackUrl:
paths:
/messages:
post:
operationId: sendMessage
# tags, summary, requestBody, and responses as before
callbacks:
statusChanged:
"{$request.body#/statusCallbackUrl}":
post:
summary: Status changed
description: Sent to the message's `statusCallbackUrl` when it's delivered or fails.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MessageEvent"
responses:
"2XX":
description: Acknowledged.Add statusCallbackUrl to SendMessageRequest too, as a string with format: uri. Blume shows the callback in a Callbacks section on the Send a message page, with its URL expression, method, request body, and responses. It doesn't get a page or a Payload panel of its own. The section doesn't list a callback's parameters, so describe any signature headers in the callback's description. A callback defined under components.callbacks and referenced with $ref renders the same way.
Troubleshooting
A webhook is missing, with a BLUME_OPENAPI_REF_PATH_ITEM warning
The webhook is a $ref to a shared path item, such as one under components.pathItems. OpenAPI 3.1 allows that, but Blume doesn't resolve referenced path items, so it skips the webhook and warns. Inline the path item under webhooks. Its schemas, parameters, request bodies, and responses can stay as $refs.
Webhooks disappeared when the spec moved to 3.1
The spec still uses x-webhooks, an extension some tools read for OpenAPI 3.0. Blume upgrades a 3.0 spec as it reads it, and that upgrade turns x-webhooks into webhooks. A spec that already says openapi: 3.1.0 isn't upgraded, so the extension stays an extension and renders nothing. Rename it to webhooks.
A webhook page has a Try it panel
The webhook is listed under paths, or it's a hand-written page with api frontmatter. Move it under webhooks, and remove the old page or path.
Only one example payload shows
The Payload panel shows the media type's example, or else the first entry in examples that has a value. Put the most common case first, or give each event type its own webhook, as Acme does.
Every signature fails to verify
Something parsed the body before your check ran, so you're hashing re-serialized JSON. Read the raw body on the webhook route: in Express, that's express.raw({ type: "application/json" }) in place of express.json(). If the body is untouched, check that you strip whsec_ and base64-decode the rest before using it as the key.
Next step
Keep the spec and docs in sync
Check the spec on every pull request, so the reference can't drift from what your API actually sends.
Keep API docs in sync in CIA step here not working for you? Report a broken step.