Durable webhooks
ctx.webhook() mints a durable callback handle with a deterministic token and a public url; hand the url to a third party inside a step, then await handle.wait() to suspend with zero compute until the callback arrives as engine.signal(token, body).
Plenty of third-party APIs are asynchronous: you ask them to do something, they hand you back a reference, and later they call you back at a URL you gave them. Done by hand, that means standing up a callback endpoint, persisting "which run is waiting for which callback", parking the run, and matching the inbound POST back to it. ctx.webhook() is the durable, replay-safe, first-class version of exactly that pattern — "expose a callback URL and wait for it" collapsed into two calls.
Minting a webhook — ctx.webhook()
Calling ctx.webhook<TPayload>() reserves a logical position now and returns a DurableWebhook handle:
const hook = ctx.webhook<PaymentResult>()
hook.token // "wh:<runId>:<seq>" — deterministic, stable across replay
hook.url // public callback URL, if a webhookUrl builder is configuredThe token is wh:<runId>:<seq> — derived from the run and the call's logical position, so it is deterministic and stable across replay. The url is that token rendered into a public callback URL by the engine's webhookUrl builder. If no builder is configured, url is undefined and you build your own URL from token.
Configure the builder once, in config/durable.ts:
import { defineConfig } from '@adonis-agora/durable'
export default defineConfig({
// … plus your transport/store (see Getting Started)
// Renders ctx.webhook().url. Your route turns the callback POST into engine.signal(token, body).
webhookUrl: (token) => `https://api.example.com/durable/webhooks/${token}`,
})The handle
interface DurableWebhook<TPayload = unknown> {
/** Deterministic signal token (`wh:<runId>:<seq>`) the callback delivers on. */
readonly token: string
/** Public callback URL for `token`, built by the engine's webhookUrl option. */
readonly url?: string
/** Suspend until the callback arrives, then resume with its payload. */
wait(opts?: { timeoutMs?: number }): Promise<TPayload>
}The shape matters: minting the handle and waiting on it are separate steps. You mint it (which fixes the token/url), hand the url to the third party inside a ctx.step so that handoff is itself checkpointed, and only then await hook.wait(). wait() parks the run on the same logical position the mint reserved — so it suspends with zero compute until the callback lands, and is replay-safe.
Not waiting forever
With no timeoutMs, wait() suspends indefinitely. That is the right default for a callback that is guaranteed to arrive eventually, but it is a trap for anything else: a third party that quietly drops the request leaves a run parked with no wake timer, invisible to both the timer poller and crash recovery until someone goes looking.
Give the wait a deadline and handle the timeout as the business outcome it is:
import { SignalTimeoutError } from '@adonis-agora/durable'
const hook = ctx.webhook<{ status: 'settled' | 'rejected' }>()
await ctx.step(requestSettlement, { orderId, callbackUrl: hook.url })
try {
const result = await hook.wait({ timeoutMs: 30 * 60 * 1000 }) // 30 minutes
return { status: result.status }
} catch (error) {
if (!(error instanceof SignalTimeoutError)) throw error
// The provider never called back. Reconcile instead of hanging.
const settlement = await ctx.step(pollSettlementStatus, { orderId })
return { status: settlement.status }
}The deadline is computed once and checkpointed, so a replay reuses the recorded wake time rather than restarting the clock — a run resumed after a crash still times out when it originally would have. Note that passing timeoutMs claims an extra logical position for the deadline, so add or remove it as a versioned change on a workflow with runs in flight.
Full example — a payment that calls back
import { FatalError } from '@adonis-agora/durable'
interface PaymentResult {
status: 'paid' | 'failed'
providerRef: string
}
engine.register('checkout', '1', async (ctx, order: Order) => {
// 1. Mint the webhook: fixes a deterministic token and (with a builder) a public url.
const hook = ctx.webhook<PaymentResult>()
// 2. Hand the url to the third party INSIDE a step, so the handoff is checkpointed and
// happens exactly once — even across replay/recovery.
await ctx.localStep('start-payment', async () => {
await psp.createPayment({
orderId: order.id,
amountCents: order.total,
callbackUrl: hook.url, // the provider POSTs here when the payment settles
})
})
// 3. Suspend with zero compute until the provider calls back. No polling, no held thread.
const result = await hook.wait()
if (result.status !== 'paid') {
throw new FatalError(`payment ${result.providerRef} failed`, 'payment_failed')
}
await ctx.localStep('fulfill', () => fulfil(order, result.providerRef))
return { orderId: order.id, providerRef: result.providerRef }
})1async run(ctx: WorkflowCtx, order: Order) {2 // mint a durable webhook: deterministic token + public callback url3 const hook = ctx.webhook<PaymentResult>();4 5 // hand the url to the provider INSIDE a step (checkpointed, fires once)6 await ctx.step(this.psp.startPayment, { orderId: order.id, callbackUrl: hook.url });7 8 // suspend with zero compute until the provider POSTs the callback9 const result = await hook.wait();10 11 if (result.status !== 'paid') {12 throw new FatalError(`payment ${result.providerRef} failed`, 'payment_failed');13 }14 await ctx.step(this.orders.fulfil, { order, providerRef: result.providerRef });15 return { orderId: order.id, providerRef: result.providerRef };16}Delivering the callback
When the payment provider POSTs to the callback URL, deliver the body as engine.signal(token, body) from your own controller — the token is in the URL, and it already encodes the run and position, so there's nothing else to look up:
import router from '@adonisjs/core/services/router'
import engine from '@adonis-agora/durable/services/main'
router.post('/durable/webhooks/:token', async ({ params, request, response }) => {
await engine.signal(params.token, request.body())
return response.noContent()
})That signal wakes the exact run suspended on that token, and hook.wait() resumes with body as its typed payload.
The token embeds
runId:seq, so treat it as a secret — this endpoint is reachable by external systems. Front it with signature verification in your own middleware.
Why this is better than rolling it yourself
Done by hand, the "callback URL + wait" pattern leaks durability concerns into your application: you have to persist the run↔callback mapping, re-establish the wait after a crash, and guard the handoff against double-dispatch on recovery. ctx.webhook() folds all of that into the engine's checkpoint machinery. The token is deterministic, so it survives replay; the handoff lives in a step, so it fires exactly once; and wait() suspends durably, so a process restart costs nothing — the run resumes the instant the callback arrives, whenever that is.
Queries & updates
Read a live run's state with ctx.setEvent + engine.getEvent (side-effect-free queries), steer it with ctx.onUpdate + engine.registerUpdateValidator + engine.update (validated, Temporal-style updates that can be rejected before they touch the run), and index runs with search attributes for typed/range engine.listRuns filtering.
External tasks & control primitives
ctx.task pairs with engine.completeTask/failTask to call a foreign system, suspend with zero compute, and resume on its callback (async completion). Plus ctx.transaction for a step that checkpoints in the same DB transaction as its write, and ctx.breakpoint to pause a run for a human from the dashboard.