OpenAPI callbacks vs webhooks: modeling async request/reply with runtime expressions
Long-running operations need a way to say "I am not done yet; I will call you when I am." Teams reach for the word "webhook" for every version of this and then cannot describe the contract in OpenAPI, because two different mechanisms share the name. One is a subscription you configure ahead of time. The other is a callback URL that travels inside a single request. OpenAPI models them with two different keywords, and getting the direction right is the whole game.
The direction is the difference
| Webhook | Callback | |
|---|---|---|
| Who owns the URL | The consumer registers a URL in advance | The consumer supplies the URL in the triggering request |
| Direction | Inbound to the provider's spec; the provider calls it for many events | Outbound from one operation, scoped to that request |
| Lifetime | Long-lived subscription, many deliveries | Usually tied to one job or transaction |
| OpenAPI keyword | Root-level webhooks | Operation-level callbacks |
| Typical use | "Send me every invoice that is paid" | "When this approval finishes, POST to this exact URL" |
If the server learns where to call from a field in the incoming request, you want callbacks. If operations staff configure the destination once and it receives a stream of events, you want webhooks.
A worked callback: payment authorization
A consumer requests a payment authorization that needs manual review. The server answers 202 Accepted, and later calls the callbackUrl the consumer provided with the outcome.
paths:
/payment-authorizations:
post:
operationId: requestAuthorization
summary: Request an authorization that may require review
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [paymentId, amount, currency, callbackUrl]
properties:
paymentId: { type: string, format: uuid }
amount:
type: integer
minimum: 1
description: Amount in minor currency units (cents).
currency: { type: string, pattern: '^[A-Z]{3}$' }
callbackUrl:
type: string
format: uri
description: HTTPS endpoint that receives the final outcome.
responses:
'202':
description: Accepted for review; the outcome is delivered to callbackUrl
headers:
Location:
description: Poll this job URL if callbacks are unavailable.
schema: { type: string, format: uri }
Retry-After:
description: Suggested polling interval in seconds.
schema: { type: integer, example: 30 }
callbacks:
authorizationOutcome:
'{$request.body#/callbackUrl}':
post:
summary: Server-to-server delivery of the authorization decision
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [paymentId, status]
properties:
paymentId: { type: string, format: uuid }
status: { type: string, enum: [approved, declined] }
reason: { type: string }
decidedAt: { type: string, format: date-time }
responses:
'200': { description: Outcome acknowledged; delivery stops }
'410': { description: Consumer no longer wants updates; stop retrying }The key is the callback path expression {$request.body#/callbackUrl}. It is not a literal path. At runtime the server substitutes the value from the request body, so the same operation can call a different destination for every consumer and every job.
Runtime expressions, the short reference
Expressions start with $ and navigate the triggering transaction.
| Expression | Resolves to |
|---|---|
$request.body#/callbackUrl | A field in the request body |
$request.header.X-Request-Id | A request header value |
$request.query.job | A query parameter |
$response.body#/id | A field from the immediate response |
$method / $url | The callback HTTP method and resolved URL |
Use $response.body#/... when the callback URL is not known until the server responds, such as a job created first and polled later. Expressions can also be embedded in a larger template, for example {$request.body#/baseUrl}/hooks/decision.
How it compares to polling and to webhooks
Three legitimate patterns cover asynchronous work; choose by who holds state and how fresh the client must be.
| Pattern | Client action | Best when |
|---|---|---|
Callback (callbacks) | Gives a URL, waits for one delivery | One transaction, consumer can receive calls, wants push |
Poll a job (202 + Location) | Re-GETs the job until terminal | Consumers behind firewalls that cannot receive calls |
| Webhook subscription | Registers one URL for a stream of events | Many events over time, several consumers |
The robust answer often documents both a callback and a Location job URL, exactly as above: push when the consumer is reachable, poll as a fallback. Returning 202 without either a callback contract or a documented polling contract leaves every client guessing at the terminal state.
Authenticating the callback
A callback reverses the caller and callee, so the consumer must authenticate the inbound call just as carefully as a webhook. State the mechanism in the description: a signature header over the raw body and a timestamp with replay window, a mutual-TLS connection, or a bearer token the consumer issued in the original request. Never document a callback that delivers sensitive outcomes over an unauthenticated http:// URL; require https in the format: uri field and say so. Consumers answering 410 Gone give the server a clean way to stop retrying, which is worth modeling.
Mock and test the round trip
A spec-driven mock should do two things. First, answer the triggering request with 202 and capture the supplied callbackUrl. Then make the outbound call to that URL with the callback body so you can test the consumer's inbound handler.
# Consumer starts the job and hands over a webhook-receiving URL.
curl -X POST https://api.example/payment-authorizations \
-H 'content-type: application/json' \
-d '{"paymentId":"3f1c...","amount":4200,"currency":"USD",
"callbackUrl":"https://app.test/hooks/decision"}'
# 202, Location: /jobs/8821
# The mock (or server) later delivers the decision to that URL.
curl -X POST https://app.test/hooks/decision \
-H 'x-signature: t=1760000000,v1=9f...' \
-d '{"paymentId":"3f1c...","status":"approved","decidedAt":"2026-10-07T17:00:00Z"}'Assert the consumer returns 200 on success, validates the signature, and handles a declined body. On the producer side, assert that delivery retries on a 5xx, honors Retry-After, and stops permanently on 410. Without the callback modeled in the spec, none of this is generated for you and the async path goes untested.
For AI agents orchestrating long tasks, a documented callback turns "poll forever" into an event the agent can wait on. Agents are poor at guessing polling intervals and strong at reacting to a typed, signed delivery; the contract makes that possible.
Common mistakes
- Using root
webhooksfor a URL that actually arrives per-request, so the per-job destination cannot be expressed. - Putting
callbackUrlin the body but never describing the call the server makes back. - Writing a literal path instead of a
{$request...}runtime expression. - Returning
202with noLocationfallback and no callback, leaving no way to reach a terminal state. - Ignoring callback authentication and replay protection.
- Forgetting the consumer's
410"stop calling me" response, so the server retries forever.
Checklist
- Per-request destinations use
callbacks; long-lived streams use rootwebhooks. - The callback key is a runtime expression resolving the real URL.
- The triggering response is
202with aLocation/Retry-Afterfallback where possible. - Callback request and response bodies, including
200and410, are fully modeled. - Authentication, signatures, and the
httpsrequirement are documented. - A mock performs the outbound call so both sides of the round trip are tested.
Describe the full round trip and async stops being the part of the API nobody can test.
Model a callback, capture the URL in a mock, and exercise the server-to-client delivery, all in the browser app. Callbacks are the outbound sibling of inbound subscriptions; the signature, retry, and delivery rules for long-lived webhooks are covered in the webhook documentation guide.