Concepts

Triggers

What starts a workflow - event triggers with CEL filters and wildcards, and cron schedules.

A workflow declares what starts it with a triggers array. A trigger is either an event trigger (fires on a matching event) or a cron trigger (fires on a schedule). A workflow can mix several.

const fulfillment = defineWorkflow<OrderData>({
  name: "fulfillment",
  triggers: [
    { event: "order.created" },
    { event: "order.reprocessed", if: "event.data.total > 100" },
    { cron: "TZ=UTC 0 9 * * *" },
  ],
  handler: async (ctx) => {
    // ctx.event.name tells you which event (or cron) started this run
  },
});

If you omit triggers, the workflow is started by an event whose name equals the workflow name - the default, so nothing changes for workflows that don't opt in.

Event triggers

An event trigger fires when an incoming event's name matches event, optionally gated by an if filter. One event fans out to every matching workflow, and a workflow with several matching triggers runs once.

An event reaches the engine two ways - from inside another workflow with the SDK, or from outside over the REST API:

await ctx.step.emit("reprocess", {
  name: "order.created",
  app: "order-app",
  data: { orderId: "A1", total: 250 },
});
curl -X POST $DURABLEX_ENGINE_URL/events \
  -d '{"name":"order.created","app":"order-app","data":{"orderId":"A1","total":250}}'

You can also fire an event by hand from the console's Events view with Trigger event: enter the event name, an optional app, and a JSON payload. It posts the same /events request, so every workflow whose trigger matches the name runs - handy for exercising a workflow without wiring up a producer. The dialog reports the run it woke and each workflow the event fanned out to.

Wildcards

An event name can end in a single trailing * to match a prefix:

triggers: [{ event: "order.*" }]   // order.created, order.shipped, order.refunded, ...

The * is allowed only as the final character. There is no mid-string match and no multi-segment ** - the rule stays simple and predictable.

Filters

if is a CEL expression evaluated against the event. It sees one variable, event, with event.name and event.data:

triggers: [{ event: "order.created", if: 'event.data.total > 100 && event.data.tier == "pro"' }]

The filter is an admission gate: if it isn't true, the workflow doesn't start for that event. It runs once at ingest and never again on replay, so it must not depend on anything but the event.

Cron triggers

A cron trigger fires the workflow on a schedule - no event needed.

triggers: [{ cron: "0 9 * * *" }]              // every day at 09:00 (engine's local zone)
triggers: [{ cron: "TZ=Europe/Paris 0 9 * * *" }]  // 09:00 Paris time
triggers: [{ cron: "@every 30m" }]             // every 30 minutes

The schedule is a standard 5-field cron expression or an @every <duration> descriptor, with an optional TZ=/CRON_TZ= timezone prefix. A cron run receives { cron, scheduledFor } as its event data and the cron spec as event.name.

Missed ticks are not backfilled: if the engine is down across several ticks, it fires once on recovery and moves on (skip-and-forward), and overlapping schedules fire a given tick only once. Cron runs flow through the same retries and flow control as event-triggered runs - pair a frequent cron with singleton to keep a slow job from overlapping itself.

A scheduled workflow is marked in the console with a clock badge and its next run time, and each cron run carries triggerKind: "cron" (event-triggered runs are "event"). The runs list can be filtered to one kind with ?runType=cron / ?runType=event.

Which trigger fired

Because a trigger can start a workflow whose name differs from the event, the handler always dispatches by the workflow, while ctx.event.name tells you what triggered this run (the event name, or the cron spec for a scheduled run).

Limits

Wildcards and filters apply to triggers only - step.waitForEvent still rendezvouses on an exact event name. step.emit publishes through the same matching path, so an emitted event can fan out to wildcard-matching workflows; watch for accidental cycles. A cycle is bounded - an emit-triggered run inherits the emitting run's depth, so it hits the same invoke-depth cap as step.runWorkflow and fails rather than looping forever - but a tight cycle still burns runs up to that cap, so avoid it by design.

On this page