Blog

How to document webhooks: what every webhook reference needs

The events, payloads, delivery rules and signature details a webhook reference needs, with a tested verification sample and a CI check that catches undocumented events.

When we audited our own example docs for drift, the worst gap was the webhooks. The code sent five event types, and the docs described one.

That’s common. Endpoints get a reference page because someone calls them. Webhooks go the other way: your API calls the developer’s server, so nobody hits an error when the docs are missing. The gap shows up later, as a handler that ignores an event it never heard of, accepts unsigned requests, or charges a customer twice because a delivery came in again.

This post covers what a webhook reference needs, with a running example: refund events from our example payments API, whose event types live in one file, src/webhooks/RefundEvent.ts.

The checklist

A webhook reference answers seven questions. If a developer has to guess any of them, their handler is built on the guess.

Section The question it answers
Event catalogue What events exist, and what causes each one?
Payloads What exactly arrives, field by field?
Responding What counts as success, and how fast?
Retries What happens when my server is down?
Ordering and duplicates Can events arrive out of order, or twice?
Signatures How do I know the request came from you?
Testing How do I send myself a real-looking event?

The event catalogue

One table, every event, with what triggers it. From our example code:

Event Sent when
refund.created A refund is created. It starts as pending.
refund.succeeded The money has reached the customer.
refund.failed The refund couldn’t be paid out. The amount returns to the merchant balance.
refund.cancelled A pending refund is cancelled with POST /v2/refunds/{id}/cancel.
refund.updated metadata or reason changes on an existing refund.

“Sent when” matters more than it looks. “Refund succeeded” alone doesn’t say whether it fires when the refund is approved or when the money arrives, and a merchant who emails their customer on that event needs to know which.

Payloads

Give each event a complete example payload and a field table: type, whether it’s always present, and what it means. The details that break handlers are the ones a quick example hides:

  • Optional fields. In our example, refund.succeeded carries an arn (the card network’s reference) only for card refunds. It’s absent for bank transfers. An example with a card refund never shows that.
  • Event-specific fields. refund.failed adds failed_at, failure_code and failure_message. refund.updated adds previous_attributes, the changed fields with their old values.
  • Enum values. List every value. Ours has four failure codes: expired_card, account_closed, insufficient_balance and declined.

Don’t write “the payload is the Refund object” unless it really is. In our example it isn’t: the REST API’s Refund reports a failure as failure_reason with values insufficient_funds, card_closed and expired, while the webhook sends failure_code with four different values. A developer who reuses one type for both breaks on the first failed refund.

Also say which kind of payload you send. Stripe offers both: snapshot events carry the full object as it was when the event happened, and thin events carry little more than an ID, so you fetch the current object from the API. The Standard Webhooks spec describes the same trade-off. Whichever you send, the docs should tell developers whether the payload can be trusted as current.

Responding, retries and ordering

These are the rules developers most often guess. Here’s how three large APIs document them:

Stripe GitHub Shopify
Time to respond “Quickly” (no number given) 10 seconds 5 seconds
Automatic retries Up to three days in live mode None; you redeliver failed ones Up to 8 times over 4 hours, then the subscription is removed
Order guaranteed No Not stated No
Spotting duplicates The event id X-GitHub-Delivery header X-Shopify-Webhook-Id header

Look at the spread: a handler that takes 7 seconds is fine for GitHub and failing for Shopify, and an endpoint that’s down for an hour loses GitHub deliveries but gets Shopify’s retried. None of that can be worked out from the payloads. It has to be written down.

For your own reference, state:

  • What counts as success: usually any 2xx. Say whether redirects count (Stripe treats them as failures).
  • The timeout, as a number.
  • The retry schedule: how many attempts, over how long, and what happens after the last one. If you disable the endpoint, say so and say how to turn it back on.
  • Ordering: if you don’t guarantee it, say so and say what to sort by. Stripe gives the subscription example: invoice.paid can arrive before invoice.created.
  • Duplicates: which ID stays the same across retries, so developers can skip events they’ve already handled.

Signatures

The section most worth getting right. Without a working check, anyone who finds the endpoint URL can send it a fake refund.succeeded. Document:

  1. The header and its format. Ours: DocPay-Signature: t=<unix seconds>,v1=<signature>.
  2. Exactly what is signed. Ours signs the timestamp, a ., and the raw request body. Stripe signs the same; GitHub and Shopify sign the body alone.
  3. The algorithm and encoding. All four use HMAC-SHA256, but GitHub and Stripe send hex while Shopify sends base64. Getting the encoding wrong fails every check, and the docs are the only place to find it.
  4. The time limit for replayed requests. Stripe’s libraries default to 5 minutes, and so does our example.
  5. What happens during secret rotation. Stripe can keep the old secret active for up to 24 hours and sends one signature per active secret, so the header can carry two v1 values. Your check has to accept either.

Then give developers code that works. Here’s ours for Node.js:

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

// header: "t=1761475200,v1=5f2b...,v1=9ac0..." (two v1 values while a secret is being rotated)
export function verifyDocPaySignature(rawBody, header, secret, now = Date.now() / 1000) {
  const parts = header.split(",").map((p) => p.split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!Number.isInteger(t) || signatures.length === 0) return false;
  if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return signatures.some((sig) => {
    const received = Buffer.from(sig, "hex");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

We tested it against a valid delivery, a tampered amount, a delivery 301 seconds old, the wrong secret, a header carrying an old and a new signature, a header with only a v0 signature, and a truncated signature. Only the valid delivery and the rotation case pass. timingSafeEqual throws on buffers of different lengths, which is why the length check comes first.

One more case failed, and it’s the one your docs should warn about: the body after parsing. Re-serialise the JSON (even with the same content) and the signature no longer matches. The check needs the raw bytes as they arrived, before any JSON middleware touches them. Stripe and Shopify both call this out, and it’s the first thing developers get wrong.

Testing

Developers need to send themselves events before going live. Stripe’s CLI sends test events (stripe trigger payment_intent.succeeded) and GitHub has a Redeliver button. If you don’t have tooling, give a script that produces a correctly signed delivery:

SECRET=whsec_test_4eC39HqLyjWDarjtT1zdp7dc
body='{"id":"evt_1Q2w3E","type":"refund.succeeded","data":{"id":"re_123","amount":1500}}'
t=$(date +%s)
sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4242/webhooks \
  -H "Content-Type: application/json" \
  -H "DocPay-Signature: t=$t,v1=$sig" \
  --data-binary "$body"

Against a local server using the function above, this returns 200, and 400 for a timestamp ten minutes old. We first wrote it with the body read from a file, and it failed: $(cat event.json) drops the file’s trailing newline, --data-binary @event.json sends it, and the signed bytes no longer match the sent ones. That’s exactly the raw-body problem from the section above, so keep the body in one variable used for both.

Put the webhooks in your spec

OpenAPI 3.1 added a top-level webhooks section, so events can sit in the same spec as your endpoints. (For 3.0, Redocly reads an x-webhooks extension.) Docs platforms such as Mintlify and Redocly turn it into reference pages.

openapi: 3.1.0
webhooks:
  refund.succeeded:
    post:
      operationId: refundSucceeded
      summary: Refund succeeded
      description: Sent when the money has reached the customer.
      parameters:
        - name: DocPay-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefundSucceededEvent"
            example:
              id: evt_1Q2w3E
              type: refund.succeeded
              data:
                id: re_123
                amount: 1500
                succeeded_at: "2026-10-26T09:30:00.000Z"
                # …
      responses:
        "2XX":
          description: Return any 2xx within 10 seconds to acknowledge the delivery.

The payoff is that tools can check it. Spectral validates webhook examples against their schemas: in our test, changing amount to "15.00" failed with "amount" property type must be integer, and dropping succeeded_at failed with must have required property "succeeded_at".

What Spectral can’t see is an event the spec doesn’t mention. For that, compare the code with the spec, the same way the audit did:

missing=$(grep -rhoE 'type: "[a-z]+\.[a-z_]+"' src | cut -d'"' -f2 | sort -u \
  | while read -r e; do grep -qE "^  $e:" openapi/webhooks.yaml || echo "$e"; done)
if [ -n "$missing" ]; then echo "Events sent by the code but missing from openapi/webhooks.yaml:"; echo "$missing"; exit 1; fi

Against our example, with only refund.succeeded in the spec, it fails and lists the other four. Adapt the pattern to how your code declares event types. Then run both on every pull request that touches either side:

name: Webhook docs
on:
  pull_request:
    paths: ["src/webhooks/**", "openapi/**"]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Every event the code sends is in the spec
        run: bash scripts/check-webhook-events.sh
      - name: Lint the spec, examples included
        run: npx --yes @stoplight/spectral-cli@6 lint openapi/webhooks.yaml --fail-severity=warn

More on running docs checks in CI in our docs-as-code CI guide.

Checks catch the gap; someone still has to fill it

The CI check fails when an engineer adds refund.disputed and forgets the docs. It doesn’t write the catalogue row, the payload table or the example. Someone still has to, usually after the feature has shipped and the context is gone.

That’s the part DocFit does. When a pull request merges, DocFit reads what changed, including new event types and fields in files like RefundEvent.ts, and updates the webhook reference pages in your docs, in your framework. It runs your docs checks before opening the docs pull request: the build, link checks, OpenAPI lint, and scripts like the one above if you add them to your repo’s checks. You review it like any other pull request.

See how DocFit works, or start free below and see what your last few merged pull requests should have changed in your docs.

All posts

Your next merge shouldn't create a docs ticket.

Connect the GitHub App and run a silent dry run on your last 5 merged PRs inside the dashboard. Nothing is opened or posted on your repos until you choose.