suspend

← All operations

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())
OptionTypeDefaultRequiredDescription
expectStandardSchemaV1--YesWhat 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.
ttlDuration: milliseconds, or "<n><unit>" with unit ms / s / m / h / dnoneNoHow 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:

SourceRendering
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(), filenothing on the wire, a log line only; completion is simply deferred
queue sourcesack, 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:

ConcernWhere it goes
Suspend only sometimesa .choice() branch contains the suspend
Notify the approverordinary 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 rejectionthe last step of the branch consumes the verdict, e.g. .filter()
Authorize the answererthe resume ingress route: .authorize(), sender verification, a per-approver link
Expiry handlingttl 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.

FieldAvailableDescription
idalwaysThe suspension id this exchange would park as.
tokenalwaysSigned, single-use resume token for that id. Mintable before the suspend runs, which is what makes a notification step useful.
sequencealwaysHow many times this exchange has already parked.
resultafter a resumeThe validated answer, typed by expect.
resumedByafter a resumeWho answered, when the resume ingress had an authenticated principal.
resumedAtafter a resumeWhen 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 with RC5003. 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(), also RC5003. 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 no suspension block fails at startup with RC5052.
  • An exchange holding anything that is not plain JSON data (a function, a class instance, a Secret) fails at suspend time, with RC5042 naming 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.
Warning

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.