suspend
suspend(options: { expect: StandardSchemaV1; ttl?: Duration }): RouteBuilder<Current>
Park the exchange durably and exit the pipeline. The run that reaches a .suspend() ends there and answers immediately; the exchange continues from the next step later, when .resume() arrives with the answer.
craft()
.id('payout')
.input({ body: PayoutRequest })
.from(http({ path: '/payouts', method: 'POST' }))
.choice(
when(
(ex) => ex.body.amountCents >= 50_000,
(b) =>
b
.tap(direct('notify-approver'))
.suspend({ expect: Approval, ttl: '72h' })
.filter((ex) =>
ex.suspension.result.approved
? true
: { reason: `rejected by ${ex.suspension.resumedBy?.subject}` },
),
),
)
.transform((payout) => executePayout(payout))
.to(log())
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
expect | StandardSchemaV1 | -- | Yes | What a valid answer looks like. Types ex.suspension.result for every step after the suspend, and is what the candidate answer is validated against at resume time. |
ttl | Duration: milliseconds, or "<n><unit>" with unit ms / s / m / h / d | none | No | How long the suspension stays resumable, for example "500ms", "30s", "72h", "7d". Omit for no expiry. |
Execution one always answers
A durable suspend cannot hold a caller: the answer arrives in hours or days and the process will be restarted first. So the run terminates at the suspend and returns a Suspended value instead of the route's declared output.
{
"status": "suspended",
"suspensionId": "3f1c…~0",
"token": "eyJ2Ijox…", // signed, single use
"expect": { "type": "object", … }, // when the schema renders one
"expiresAt": "2026-08-13T09:00:00.000Z"
}
A route with a reachable suspend therefore has output type Output | Suspended. Each source renders that its own way:
| Source | Rendering |
|---|---|
http() | 202 Accepted, the Suspended value as the body, Retry-After from the ttl |
direct() | the value itself; the caller narrows with isSuspended(result) |
cron(), simple(), file | nothing on the wire, a log line only; completion is simply deferred |
| queue sources | ack, never nack: the work is parked in the suspension store, and a redelivery would ask the approver twice |
The route's real output flows to its destinations on execution two, not back to the original transport.
Everything else is existing grammar
.suspend() takes two options because everything else a suspend appears to need is already a verb in the DSL:
| Concern | Where it goes |
|---|---|
| Suspend only sometimes | a .choice() branch contains the suspend |
| Notify the approver | ordinary steps before it, e.g. .tap(direct('notify-approver')). ex.suspension.token is readable BEFORE the suspend runs, so the message can carry a working link |
| Handle a rejection | the last step of the branch consumes the verdict, e.g. .filter() |
| Authorize the answerer | the resume ingress route: .authorize(), sender verification, a per-approver link |
| Expiry handling | ttl plus a route-scope .error(); an expired answer arrives as RC5047 |
The branch-rejoin rule
A suspend branch rejoins the main flow only if it restored the main flow's contract; otherwise it must leave the flow entirely (drop, or complete). The body crosses the park untouched, so the fast path and the approved path are indistinguishable downstream, and ex.suspension is never read by the main flow.
ex.suspension
ex.suspension is readable anywhere in a pipeline.
| Field | Available | Description |
|---|---|---|
id | always | The suspension id this exchange would park as. |
token | always | Signed, single-use resume token for that id. Mintable before the suspend runs, which is what makes a notification step useful. |
sequence | always | How many times this exchange has already parked. |
result | after a resume | The validated answer, typed by expect. |
resumedBy | after a resume | Who answered, when the resume ingress had an authenticated principal. |
resumedAt | after a resume | When the answer was accepted. |
What is refused
.suspend() is refused at craft() build time where the framework could not revive the exchange, with RC5051:
- Inside
.split(). A durable aggregator would have to track N outstanding children across restarts. Split the work into per-item child capabilities instead: each is its own exchange and suspends independently. - Inside a
.multicast()path or a.dispatch()target. Those exchanges are isolated side flows, so a resumed continuation would have nowhere to rejoin. - Under a step-scope wrapper (
.retry(),.timeout(),.cache(), …), which fails withRC5003. Parking is not a failure to re-attempt. Put.error()at route scope instead, where it also catches revival failures. - On a route with route-scope
.cache(), alsoRC5003. The cache filters wrap the user pipeline, which a park exits and a resume re-enters partway down, so neither the check nor the store would ever run and the cache would silently do nothing. Use a step-scope.cache()on the expensive step instead.
Two more refusals happen outside build time, each as early as it can be known and both well before a resume:
- A context whose routes can reach a suspend (or a
.resume()) but which configured nosuspensionblock fails at startup withRC5052. - An exchange holding anything that is not plain JSON data (a function, a class instance, a
Secret) fails at suspend time, withRC5042naming the offending path. Deliberately at the park rather than at the resume: the deploy that introduced the value is what should fail, not the approver's click days later.
Durable from the suspend point onward
Suspension guarantees durability from a declared suspend point. It is not general crash recovery: if the process dies at step 4 of a route that never reached a .suspend(), that exchange is gone. Routecraft does not checkpoint at every step boundary.
Resilience on the continuation
A resume runs the continuation below the pre-from filter chain: route-scope .error() still applies, the rest of the chain does not, because the exchange entered the route once and that was execution one. Declare resilience inside the continuation (step-scope wrappers, optionally grouped by wrapping a .choice()), or hand the work to a route that has its own chain with .to(direct('...')).
Two limits are worth knowing before you rely on this for money:
A resume is spent whether or not the continuation succeeds. .resume() wins the store's compare-and-swap before running anything, so a continuation that throws records a failed terminal and a second answer with the same token receives that cached failure rather than a second run. This is what makes "did this already run?" answerable across a restart, and it means a failing continuation needs a .error() handler or its own retry, not a re-click from the approver.
A process that dies mid-continuation does not resume itself. The record reads resumed with no terminal outcome, and nothing re-drives it; a later answer with the same token is told the first resume never recorded an outcome. Recovering that automatically needs a lease on the resumed state, which is not implemented. Until then, treat a resumed record with no terminal as needing an operator, and keep continuations short where the work is not idempotent.
Expiry is discovered lazily. A ttl is enforced when a late answer arrives, not by a background sweeper: an unanswered suspension sits in the store past its deadline and its route is not told until someone presents a token. The sweeper that fires it on time is slice 3 of the epic. Until it lands, do not build a "nobody approved in 72 hours, escalate" flow on ttl alone: the escalation only runs if the approver eventually clicks a dead link.
Related
.resume()-- the other half.- Configuration → suspension -- where parked exchanges are stored and how tokens are signed.
- Events --
route:exchange:suspended,:resumed,:expired.