Webhook and Callback Contracts
A webhook inverts the usual arrangement: your API becomes the client and your consumer becomes the server. Everything that makes a request-response contract valuable applies, but almost nothing about the tooling carries over — the receiver is code you do not control, the delivery is asynchronous, and failures are invisible unless you design for them. This guide extends API Contract Fundamentals & Tool Selection and covers the contract that makes outbound deliveries dependable.
The gap this closes is common: teams document their inbound API in careful detail and describe their webhooks in a paragraph of prose and one example payload. Receivers then guess at the retry behaviour, discover the signature scheme by trial and error, and process duplicate deliveries twice.
When to Use This Approach
Formalise the webhook contract when:
- Consumers act on your events in ways that cost money or change state — issuing refunds, releasing stock, sending mail.
- You already publish an event contract internally with AsyncAPI and need the externally-facing equivalent.
- Partners have asked, more than once, what happens when their endpoint is down.
- You have seen duplicate processing, or suspect it and cannot prove it.
- A receiver has ever verified your signature incorrectly because the scheme was described in prose rather than as an executable example.
Prerequisites
Examples use OpenAPI 3.1.1, Express 4 for the receiver, Node’s built-in crypto, and Pact JS 13 for the contract tests. No external dependencies are needed for signing.
npm install express@4
npm install -D @pact-foundation/pact@13 @stoplight/spectral-cli@6.11
Step 1: Model the Envelope, Not the Event
Every delivery shares one envelope; only data varies by event type. This gives receivers one parser and one deduplication key regardless of how many event types you add later.
components:
schemas:
WebhookEnvelope:
type: object
required: [id, type, created_at, api_version, data]
additionalProperties: false
properties:
id:
type: string
pattern: '^evt_[a-zA-Z0-9]{24}$'
description: |
Stable for the life of this event, INCLUDING across retries.
Receivers use it as the idempotency key.
type:
type: string
enum: [order.paid, order.cancelled, refund.succeeded]
created_at:
type: string
format: date-time
description: When the event occurred — not when this delivery attempt was made.
api_version:
type: string
enum: ["2026-07-01"]
description: The payload contract version, pinned per receiver endpoint.
data:
description: Event-specific payload; shape determined by `type`.
oneOf:
- $ref: '#/components/schemas/OrderPaidData'
- $ref: '#/components/schemas/OrderCancelledData'
- $ref: '#/components/schemas/RefundSucceededData'
The comment on id is the contract’s most load-bearing sentence. A sender that mints a new identifier per attempt makes receiver-side deduplication impossible, and no amount of care on the receiving end can recover from it.
Step 2: Declare the Webhook in the Document
OpenAPI 3.1 has a top-level webhooks section for exactly this. The detail is covered in documenting webhooks with OpenAPI callbacks; the shape is:
webhooks:
orderPaid:
post:
summary: Sent when an order is paid.
operationId: orderPaidWebhook
parameters:
- name: X-Signature
in: header
required: true
schema: { type: string }
description: 'v1=<hex hmac>, computed over the raw request body.'
- name: X-Signature-Timestamp
in: header
required: true
schema: { type: string, format: date-time }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookEnvelope' }
responses:
'2XX':
description: Delivery accepted. Any 2xx stops retries.
'410':
description: |
The endpoint is permanently gone. We disable it and stop retrying.
default:
description: |
Any other status, or a timeout after 10 seconds, is treated as a
failure and the delivery is retried on the published schedule.
Documenting what a 410 means is unusually valuable here: it gives a receiver a way to decommission an endpoint deliberately instead of leaving it returning 500s forever.
Step 3: Sign Every Delivery
Signing is the only way a receiver can distinguish your delivery from anyone else who learned the URL. Compute it over the raw body bytes, with a timestamp inside the signed material so an old capture cannot be replayed.
// webhooks/sign.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function sign(rawBody: Buffer, secret: string, timestamp: string): string {
// The timestamp is INSIDE the signed payload; signing only the body would
// let an attacker replay a captured delivery indefinitely.
const mac = createHmac("sha256", secret);
mac.update(`${timestamp}.`);
mac.update(rawBody);
return `v1=${mac.digest("hex")}`;
}
export function verify(rawBody: Buffer, secret: string, timestamp: string,
presented: string, toleranceSeconds = 300): boolean {
const age = Math.abs(Date.now() - Date.parse(timestamp)) / 1000;
if (!Number.isFinite(age) || age > toleranceSeconds) return false; // stale or absurd
const expected = Buffer.from(sign(rawBody, secret, timestamp));
const actual = Buffer.from(presented);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
The failure mode this prevents on the receiving side — verifying against a re-serialised body rather than the bytes that arrived — is common enough to deserve its own guide: verifying webhook payload signatures in contract tests.
Step 4: Publish the Delivery Guarantees
Retry behaviour is part of the contract and belongs in the document, not in a support article.
info:
description: |
## Webhook delivery guarantees
* **At-least-once.** A delivery may arrive more than once. Deduplicate on
the envelope `id`, which is stable across retry attempts.
* **Timeout.** We wait 10 seconds for a response. Acknowledge first and
process asynchronously.
* **Retry schedule.** Failed deliveries are retried after 1m, 5m, 30m, 2h,
6h and 24h, then abandoned. `Retry-After` on a 429 is respected.
* **Ordering.** Not guaranteed. Use `created_at` to order, and ignore an
event whose `created_at` is older than one you have already applied.
* **Endpoint disablement.** An endpoint returning 4xx (other than 429) for
every delivery over 72 hours is disabled and the owner is emailed.
Step 5: Contract Test Both Directions
Two obligations exist and both are testable. The sender must emit the documented envelope; the receiver must accept it, including a duplicate. The receiver-side pattern — a message-style contract with no live HTTP — is worked through in testing webhook retries and idempotency contracts.
// sender side: what we emit must satisfy the published envelope schema
import Ajv from "ajv";
import addFormats from "ajv-formats";
const validate = addFormats(new Ajv({ strict: true })).compile(envelopeSchema);
test("every emitted event validates against the published envelope", () => {
for (const event of capturedOutbox) {
expect(validate(event)).toBe(true);
}
});
Delivery Contract Reference
| Guarantee | Value | Why the receiver needs it |
|---|---|---|
| Delivery semantics | at-least-once | tells them deduplication is mandatory |
| Deduplication key | envelope id |
tells them which field to key on |
| Timeout | 10 seconds | tells them to acknowledge before processing |
| Retry schedule | 1m, 5m, 30m, 2h, 6h, 24h | lets them size their outage tolerance |
| Ordering | not guaranteed | tells them to order by created_at |
| Signature | HMAC-SHA256 over timestamp.body |
lets them verify without asking |
| Timestamp tolerance | 5 minutes | tells them how strict to be about replays |
| Permanent rejection | 410 disables the endpoint |
gives them a clean decommission path |
Verification
# A delivery carries both signature headers and a well-formed envelope.
curl -sD - -X POST https://receiver.example.com/webhooks \
-H 'X-Signature: v1=…' -H 'X-Signature-Timestamp: 2026-07-29T10:00:00Z' \
--data-binary @fixtures/order-paid.json -o /dev/null
# HTTP/2 200
# The same delivery replayed produces no second side effect.
for i in 1 2; do
curl -s -o /dev/null -X POST https://receiver.example.com/webhooks \
-H 'X-Signature: v1=…' -H 'X-Signature-Timestamp: 2026-07-29T10:00:00Z' \
--data-binary @fixtures/order-paid.json
done
psql -c "select count(*) from refunds where source_event = 'evt_9tX2…';"
# count
# -------
# 1
Troubleshooting
Receivers report signature mismatches on some deliveries only
Almost always a body-parsing problem on their side: a framework parses the JSON and the verification code re-serialises it, changing key order or number formatting. Publish an executable example that verifies against raw bytes, and say explicitly in the documentation that the signature covers the bytes as received.
The same event is processed twice
Either the sender is minting a new id per attempt, or the receiver is deduplicating on something else — a payload hash, a resource id, a timestamp. Check the sender first; it is the side that can make deduplication impossible.
Deliveries stop arriving with no error
Endpoint disablement after sustained 4xx responses, usually triggered by a receiver returning 400 for an event type it does not recognise. Receivers should acknowledge unknown event types with a 2xx and ignore them — document that explicitly, because the intuitive behaviour is to reject them.
Retry storms after a receiver outage
The receiver came back and every queued delivery arrived at once. Publish the retry schedule so receivers can size for it, and consider capping concurrent deliveries per endpoint. On the receiving side, acknowledging fast and processing asynchronously turns a storm into a queue.
Events arrive out of order
Expected, and worth stating loudly. Ordering across a retrying, parallelised delivery system is not achievable at reasonable cost. Give receivers created_at and a monotonic resource version so they can discard stale updates.
Subscription Management Is Part of the Contract
Delivery gets the attention; subscription gets forgotten, and it is the half a receiver interacts with first. Four operations make an outbound integration self-service rather than a support conversation.
Register an endpoint, with the event types it wants and the URL to deliver to. Validate the URL at registration — reachable, HTTPS, not a private address — because a registration that fails only at first delivery is a much worse experience.
Rotate the signing secret, with an overlap. A receiver needs to accept both secrets for a window while it deploys the new one, which means your side must be willing to sign with the old one until they confirm the switch, or sign with the new one while they accept both. Say which in the documentation.
List and inspect deliveries. A receiver debugging a missed event needs to see what you sent, when, what status came back, and how many attempts remain. Without it, every investigation starts with an email asking you to check your logs.
Replay a delivery. Once a receiver has fixed the bug that made them return 500 for a day, the natural next question is how to get those events. A replay endpoint — or a documented statement that events cannot be replayed — is the difference between a self-service recovery and a data-loss conversation.
None of these are delivery mechanics, and all four belong under paths in the same document as the webhooks themselves.
Security Beyond the Signature
Signing proves the delivery came from you. Three further concerns are worth stating in the contract because receivers will otherwise assume the wrong thing.
Publish your egress addresses if you have stable ones. Receivers behind a firewall need them, and the alternative — opening an endpoint to the world and relying on signature verification alone — is a weaker posture than it needs to be.
Never put secrets in the payload. A webhook body is stored, logged and replayed by systems you do not control. Carry identifiers and let the receiver fetch anything sensitive over an authenticated API call.
Say what you do with a redirect. A receiver whose URL 301s to another host is either a routine deployment artifact or an exfiltration attempt, depending on who set it up. Most senders refuse to follow redirects on webhook deliveries; whichever you choose, document it, because the behaviour is invisible otherwise.
Observability the Receiver Cannot Provide
The sender sees things a receiver cannot, and publishing a little of it converts a class of support conversation into self-service.
Per-endpoint delivery health — the success rate over the last day, the current consecutive-failure count, and whether the endpoint is approaching disablement — tells a receiver whether their integration is healthy without asking. It is also the single most requested piece of information in webhook support tickets.
The last response you received is the other half. A receiver debugging a 500 they cannot reproduce needs to know what your side actually saw: the status, the elapsed time, and whether the connection was refused rather than answered.
Attempt counts and next-attempt times turn “did you send it?” into a lookup. Exposing them through the same subscription API as endpoint registration keeps the whole integration self-describing.
On your own side, three metrics are worth alerting on rather than merely recording. A rising rate of endpoints approaching disablement usually means something changed on your side, not on theirs. Delivery latency at the ninety-ninth percentile catches a slow receiver before it becomes a queue backlog. And the age of the oldest undelivered event is the number that tells you whether a backlog is draining or growing, which is the question during an incident.
Publishing this data has a second benefit that is easy to miss: it makes the delivery guarantees falsifiable. A receiver who can see the attempt history can verify that the retry schedule matches what the contract promised, and a promise that can be checked is one that gets kept.
Testing Your Own Deliveries End to End
The sender-side tests above prove the envelope validates. One further test proves the whole delivery path works, and it is worth the setup: stand up a throwaway receiver in CI, register it through your own subscription API, trigger a real domain event, and assert that a signed, well-formed delivery arrives within the documented timeout. That single test exercises the parts unit tests cannot — the subscription lookup, the signing key retrieval, the delivery worker, and the timeout — and it fails loudly when any of them is misconfigured in a new environment.
The Shortest Useful Version of This Contract
If a full specification is more than the integration warrants, four sentences cover most of the value: deliveries are at-least-once and carry a stable id to deduplicate on, the body is signed over the raw bytes with a timestamp, we wait ten seconds and retry on a published schedule, and a 410 retires the endpoint. Everything else in this guide elaborates those four promises.
Frequently Asked Questions
What is the difference between webhooks and callbacks in OpenAPI?
A callback is a request the API makes in response to an earlier request from that same consumer — it hangs off the operation that started it. A webhook is a request the API makes because something happened, with no triggering request to attach it to. OpenAPI 3.1 added a top-level webhooks section for the second case; callbacks predate it and live inside an operation.
Which HTTP status should a receiver return?
2xx for accepted, and as fast as possible — do the work asynchronously. Return 4xx only for a payload you will never accept, because a well-behaved sender will stop retrying. Return 5xx or time out when you want the delivery retried.
How long should a receiver have to respond?
Publish a timeout and hold to it — five to ten seconds is typical. Receivers should acknowledge and process asynchronously; a receiver doing real work inside the request is the most common cause of retry storms.
Do webhooks need versioning?
Yes, and the same rules apply as for a request-response API: additive changes are safe, removals are breaking. Because you cannot force receivers to upgrade, carry a version in the event envelope and keep emitting the old shape for a full deprecation window.
Should the payload contain the full resource or just an identifier?
Carry enough to act on — an identifier, an event type, a timestamp and the fields that changed — and let the receiver fetch the rest. A full-resource payload is convenient until the resource grows, at which point every delivery carries data nobody reads.
How do receivers verify the request really came from us?
Sign the raw body with a shared secret or an asymmetric key and send the signature in a header alongside a timestamp. The receiver recomputes the signature over the exact bytes it received and rejects anything that does not match or is too old.