How to document webhooks so integrators don't guess
Webhook docs are usually one happy-path payload and a shrug at security. Here is the anatomy of a webhook doc integrators actually trust.
Ask any integrations engineer which page in a vendor’s docs they trust least, and they will name the webhooks page. Not because webhooks are conceptually hard — a POST request to a URL is not a hard idea — but because the docs almost always describe the happy path and stop there. One sample payload, one sentence about signatures, no mention of retries. Everything else gets reverse-engineered from server logs at 2am during an incident.
This is backwards. REST endpoints are forgiving to document badly because a developer can open Postman, hit the endpoint, and see the real response in five seconds. Webhooks don’t offer that shortcut. The integrator doesn’t control when the event fires, can’t easily inspect what you send, and has to build a receiving endpoint on faith that your docs describe reality. If the docs are wrong or incomplete, they find out in production, from a customer, weeks later.
Why webhook docs are the most skipped (and most needed) page
Webhook docs get shortchanged for a predictable reason: the delivery system was hard to build, so the team feels done. Signing, retries, dedup, dead-letter handling — real engineering effort goes into that infrastructure. Documenting it feels like an afterthought once it works, so it becomes a single page with one payload example and a link to a signing library.
The result is a support pattern every API team recognizes:
- “What does the payload look like when a refund is partial?”
- “Why did we get the same event three times?”
- “Is the signature header base64 or hex?”
- “What happens if our endpoint is down for ten minutes?”
None of these are hard questions. They’re all questions the docs should have answered before the integrator had to ask. Every one of them, unanswered, turns into a support ticket, a Slack message to your engineers, or — worse — a silently broken integration nobody notices until money or data is wrong.
Webhook docs matter more than most reference pages precisely because the integrator can’t test their way to understanding. A REST endpoint is discoverable. A webhook is something that happens to the integrator’s system, on your schedule, and the docs are the only spec they get.
The anatomy of a webhook doc
A webhook page that actually prevents support tickets covers three things, at minimum: what events exist, what the payload looks like for each one, and what happens when delivery doesn’t go smoothly on the first try.
Event types. List every event your system emits, not just the popular ones. Use a consistent naming scheme (resource.action, past tense — invoice.paid, invoice.payment_failed) and document it explicitly so integrators can predict names for events you haven’t invented yet. If you version your webhook payloads independently from your API version, say so and show which header or field carries the version.
Payload schema. Every field, its type, and whether it’s nullable — not just an example JSON blob. Examples are necessary but not sufficient; integrators writing strongly typed consumers need to know that discount_cents is integer | null, not just that it happened to be 500 in your sample.
{
"id": "evt_1PbQx2A9kL3mN7pR",
"type": "invoice.payment_failed",
"created_at": "2026-07-09T14:32:11Z",
"api_version": "2026-05-01",
"data": {
"invoice_id": "inv_8f3kLp2Q",
"customer_id": "cus_44vN2xLp",
"amount_due_cents": 129900,
"currency": "usd",
"attempt_count": 2,
"next_retry_at": "2026-07-10T14:32:11Z",
"failure_reason": "card_declined"
}
}
Retry and backoff behavior. This is the part teams skip most often, and integrators need it most. Document, explicitly:
- How many times you retry a failed delivery (a non-2xx response, a timeout, a connection error).
- The backoff schedule — fixed interval or exponential, and the actual numbers (e.g., 1 min, 5 min, 30 min, 2 hr, 12 hr).
- What counts as a failure on your end (timeout threshold, which status codes you treat as “retry” vs “give up”).
- What happens after the last retry — is the event dropped, logged, or surfaced in a dashboard the customer can see?
- Whether events can arrive out of order or more than once, and what field the integrator should use to deduplicate (usually the event
id).
That last point is the one that causes the most subtle bugs. If your docs don’t say “processing may not be idempotent unless you dedupe on event.id,” every integrator has to learn it by getting duplicate charges processed twice.
This is also where keeping docs current matters most — payload schemas drift every time someone adds a field to the event object, and a stale webhook doc is worse than no doc because it actively misleads. GitDoc watches the code that defines your event schemas and flags a pending docs update the moment a field changes, so the payload table doesn’t quietly fall out of sync with what you actually send.
Documenting signature verification so integrators don’t skip security
Every webhook provider signs payloads. Most integrators skip verifying the signature anyway, and the reason is almost always the docs, not laziness. If verification requires reading a paragraph of prose and assembling the logic yourself, it gets deprioritized until “later,” and later doesn’t come.
❌ Bad: “For security, we sign each webhook request. Verify the signature using HMAC-SHA256 and your webhook secret before processing the event.”
That sentence is technically correct and practically useless. It doesn’t say which header carries the signature, whether the signature covers the raw body or the parsed JSON, what encoding to expect, or whether there’s a timestamp to check against replay attacks. The integrator now has to guess, test against your servers, and hope they got it right — so a lot of them just skip the check and process the payload unverified.
✅ Good: name the exact header, the exact signing scheme, and paste working code.
// Verify the X-Webhook-Signature header on an incoming webhook request.
// signatureHeader looks like: "t=1720549200,v1=5257a869e7ecebeda32affa62cdca3fa..."
const crypto = require('crypto')
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
)
const { t: timestamp, v1: signature } = parts
// Reject events older than 5 minutes to mitigate replay attacks.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false
}
const signedPayload = `${timestamp}.${rawBody}`
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
)
}
Three details separate a doc that gets used from one that gets skipped:
- Sign the raw body, not the re-serialized JSON, and say so explicitly — JSON key ordering isn’t guaranteed, so signing a re-parsed object produces a different signature than the one you sent, and integrators lose hours to that mismatch.
- Use a constant-time comparison in your example code, and explain why (
===on strings leaks timing information an attacker can exploit). Copy-pasted example code is the code that ships, so make it the secure version. - Document what to return on failure — a
401with no body, not a200that lets the integrator’s own logic silently swallow a forged event.
If your example code isn’t runnable against a real payload, don’t publish it as an example.
Example payloads for every event, not just the happy path
Most webhook docs show exactly one payload per event type: the clean, successful, fully-populated case. Then production sends the integrator a payload with a null customer because the order was a guest checkout, or a refunds array with two partial entries instead of one full one, and their code throws because they only ever tested against the one shape the docs showed them.
Document the range, not just the center. For each event type, show:
- The minimal payload — only required fields populated, everything optional left
nullor absent. - The maximal payload — every optional field populated, so integrators can see the full shape at once.
- A failure or edge-case variant — a declined payment, a partial refund, a cancelled-before-fulfilled order — anything your system can actually emit that isn’t the default success case.
- An example of the field values that change across retries (e.g.,
attempt_countincrementing,next_retry_atshifting).
If an event type has three meaningfully different shapes in production, three examples cost you twenty extra minutes to write and save every integrator downstream from writing a parser that only works for the shape you happened to show them. This is a case where the documentation effort should roughly track the complexity of the real data, not the complexity of the demo you used to build the feature.
Testing tools: give developers a way to trigger and inspect events
Docs answer “what will happen.” Integrators still need a way to answer “did it happen, and what did you actually send.” Without that, every webhook integration gets debugged by staring at application logs and guessing what arrived.
At minimum, ship:
- A way to trigger a synthetic event on demand — a dashboard button or CLI command that fires a real webhook for a given event type against the integrator’s configured endpoint, without requiring them to create a real order or manipulate production data to test
order.refunded. - A delivery log per endpoint showing every attempt: timestamp, event type, payload, response status code, response time, and how many retries occurred. This is the single highest-leverage tool you can ship — it turns “why didn’t we get that webhook” from a support ticket into a five-second self-serve check.
- A manual redeliver action on any past event, so an integrator who fixed a bug in their receiving endpoint doesn’t have to wait for the next real occurrence of that event to confirm the fix works.
- A local tunneling recommendation in the docs (ngrok or similar) with the exact command, so developers building against localhost aren’t stuck guessing how to receive events before they deploy anywhere.
Document these tools alongside the payload reference, not in a separate “getting started” page nobody finds. The point where someone is reading about invoice.payment_failed is exactly the point where they want to know how to fire one against their own endpoint right now, not later.
Webhook docs that include a working test path get used correctly on the first integration attempt. Webhook docs without one get used correctly on the third attempt, after two rounds of “why isn’t this working” — and that gap is entirely closable with documentation, not new infrastructure.
GitDoc keeps your docs in sync with your codebase on every push. Start free →