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, input) => {
const order = input as 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
The callback has to become engine.signal(token, body) on the waiting run. You have two ways to receive it.
Through the dashboard route
If you mount the dashboard, it already serves POST /durable/api/webhooks/:token. Point webhookUrl at it:
webhookUrl: (token) => `https://api.example.com/durable/api/webhooks/${token}`,The route accepts only webhook tokens (wh:<runId>:<seq>). A callback for a run waiting on that token resumes it and answers { result }. One that lands before the run reaches hook.wait() is buffered and answers 202 { buffered: true }, when the run exists, is not terminal and has not consumed that webhook yet; the later wait() consumes it. A callback for an unknown or terminal run, or a duplicate of one already delivered, answers 404 and buffers nothing, and the route answers 413 past the 1 MiB payload limit. It sits behind the console's authorize guard like every dashboard route, so let the provider through there (a shared header or a signature check on that path).
From your own route
Without the dashboard, or to verify the provider's signature in your own middleware, deliver the body yourself. The token is in the URL and 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 is not a secret
The token is wh:<runId>:<seq>, derived from the run id, which a caller may know or guess. Don't rely on it for authentication: this endpoint is reachable by external systems, so verify the provider's signature (or a shared secret) before calling engine.signal.
A callback that beats the wait
The provider may call back before the run reaches hook.wait(), for example while the step that handed out the URL is still waiting for its result. engine.signal then finds no waiter and buffers the body, the same signal buffering ctx.waitForSignal uses, and returns null. When the run gets to hook.wait(), the wait consumes the buffered body and resumes at once instead of suspending. The wait also re-checks the buffer after it registers, so a callback that lands in between is not lost either. With your own route, answer 2xx whether or not engine.signal returned a result.
The dashboard route buffers an early callback the same way, but only for a live run that has not consumed that webhook (see above), so a guessed or stale token can't leave a payload behind. A duplicate early callback, sent twice before the run waits, is buffered twice; the wait consumes one.
External tasks — ctx.task
ctx.webhook() is specifically for the HTTP-callback shape: the engine mints a URL, you hand it to a third party, and they deliver the callback. Not every async handoff looks like that — sometimes there is no URL at all, just a message you drop on a queue, a foreign process you kick off, or a legacy system you poke, and you report back when it is done. ctx.task(name, dispatch, options?) is the general form of "suspend until an external system finishes this":
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'
export default class VideoTranscodeWorkflow extends BaseWorkflow {
static workflow = { name: 'video-transcode', version: '1' }
async run(ctx: WorkflowCtx, input: { videoId: string; s3Key: string }) {
// `dispatch` runs ONCE (checkpointed); the run then suspends until your code reports back.
return ctx.task<{ outputKey: string }>('transcode', async () => {
await transcodeQueue.enqueue({ runId: ctx.runId, videoId: input.videoId, s3Key: input.s3Key })
})
}
}Your own code reports the result by run id and task name — no minted URL involved:
import router from '@adonisjs/core/services/router'
import engine from '@adonis-agora/durable/services/main'
router.post('/transcode-callbacks', async ({ request, response }) => {
const { runId, outputKey, error } = request.body()
if (error) await engine.failTask(runId, 'transcode', error)
else await engine.completeTask(runId, 'transcode', { outputKey })
return response.noContent()
})completeTask resumes the run with the result as ctx.task's return value; failTask makes ctx.task throw a FatalError. Both return null when no run is waiting on that task. A completion that arrives before the run reaches the wait is buffered and consumed when it gets there, and a duplicate or late delivery cannot corrupt an already-resumed run. ctx.webhook is the special case where the "external system" is an HTTP client and the engine builds the URL for you. See External tasks for the full reference.
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.