Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

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 13 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 specWho sends itWhere the URL comes fromIn Blume
pathsYour reader's code, to your APIYour spec's serversA page per operation, with Try it and code samples
webhooksYour API, to your reader's endpointRegistered once, outside any API callA page per webhook, labeled Webhook, with an example payload
An operation's callbacksYour API, to a URL from one requestA field in that request, like callbackUrlA 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 an operationId as 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.parameters and referenced from each webhook, so every page shows the same three headers.
  • The request body is the payload. Its example is what readers copy; without one, Blume builds an example from the schema.
  • The responses are the acknowledgment: what the receiver sends back. 2XX covers every success status, and default covers 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:

PageURL
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 timestamp keeps 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 v1 signature in the header, so a secret rotation doesn't break it, and compares with timingSafeEqual rather 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.mjs

In 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_1

Both 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, skipped

Run 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 CI

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init