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 (the NestJS module's webhookUrl option). If no builder is
configured, url is undefined and you build your own URL from token.
Configure the builder once, on the module:
DurableModule.forRoot({
store,
transport,
// Renders ctx.webhook().url. The dashboard's POST webhooks/:token receives the callback.
webhookUrl: (token) => `https://api.example.com/durable/api/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(): 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.
Full example — a payment that calls back
import { remoteStep } from '@dudousxd/nestjs-durable-core';
import { z } from 'zod';
interface PaymentResult {
status: 'paid' | 'failed';
providerRef: string;
}
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
constructor(private readonly psp: PaymentProviderService) {}
async run(ctx: WorkflowCtx, 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.step('start-payment', async () => {
await this.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.step('fulfill', () => this.fulfil(order, result.providerRef));
return { orderId: order.id, providerRef: result.providerRef };
}
}When the payment provider POSTs to the callback URL, the dashboard's POST webhooks/:token endpoint
receives it and delivers the body as engine.signal(token, body). That signal wakes the exact run
suspended on that token, and hook.wait() resumes with body as its typed payload. The body you POST
becomes the PaymentResult the workflow receives.
Delivering the callback yourself
The dashboard endpoint is just a thin wrapper. If you don't mount the dashboard, deliver the callback
yourself by calling engine.signal(token, body) from your own controller — the token is in the URL:
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly engine: WorkflowEngine) {}
@Post(':token')
async receive(@Param('token') token: string, @Body() body: unknown) {
await this.engine.signal(token, body);
return { ok: true };
}
}Because the token already encodes the run and position, there is nothing else to look up — the signal routes straight to the waiting run.
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
Reading a live run's state with ctx.setEvent + engine.getEvent (side-effect-free queries), and steering it with ctx.onUpdate + engine.registerUpdateValidator + engine.update (validated, Temporal-style updates that can be rejected before they touch the run).
Versioning & determinism
Keeping in-flight runs replay-safe across code changes — workflow versions for breaking changes, the NonDeterminismError guard, the deterministic now/random/uuid sources, and ctx.patched for guarding an in-place change without a new version.