Skip to main content
A workflow is a versioned JSON spec: a trigger, a few options and a list of steps. Every run of it is one subscriber going through one published version, so the waits, the branches and the sends are all decided from that person’s own attributes, topics and history. You author the spec in the dashboard or through the API, and both validate it against the same schema. Reading needs the workflows:read scope, writing needs workflows:write. Both run inside a tenant.

A complete spec

Triggers

A trigger is one of two shapes. An event trigger takes an event name, optional sources (server, ios, android, web, system) and an optional where over trigger.data.*, subscriber.attributes.* and the subscriber’s history. A schedule trigger takes schedule (either { "cron": "0 10 * * MON" } or { "daily": "19:00" }), a timezone that is an IANA name or subscriber, an optional segment slug and an optional where. One run starts per member each time a schedule fires. concurrency decides what happens when a second event arrives. per-event, the default, starts a run for every matching event. one-per-subscriber ignores a new event while a run of this workflow is already live for that subscriber. cancelOn lists the events that terminate a live run, each with an optional where over event.data.*.

Steps

Every step carries a name that is unique in the version, except exit. A branch case with no when is the fallback. There is at most one, it comes last, and without it nothing runs and taken is else. Lanes rejoin the steps after the branch unless they end with exit, which is what makes exit useful inside a case and only a marker at the top level. Branches nest at most four deep, and loops do not nest with themselves, though a repeat inside a forEach is fine. waitFor with settleFor and resetOn waits for a quiet moment instead of the bare event. The event starts a clock of settleFor, every resetOn event restarts it, and the step completes once the clock runs out untouched. { "event": "$app.backgrounded", "settleFor": "5m", "resetOn": ["$app.opened"], "timeout": "1d" } lands the next send when nobody is looking. If the event already happened more recently than any resetOn event, the clock starts from that occurrence, and if its window has already run out the step completes matched at once. The step is unmatched only when the timeout passes without a settled event.

Fetching from your own API

fetch is { method?, url, headers?, body?, timeout?, expect?, as?, onError? }. The method is GET by default, or POST by default when a body is set. url and headers may read {{ secrets.<name> }} from the tenant’s secrets, so a token never sits in the spec. Only https is allowed, plus http://localhost for self-hosters. timeout runs from 1s to 60s and is 10s by default, and expect.status lists the codes that count as success, 2xx by default. The reply lands under steps.<name> as { status, headers, data }, and with as also under vars.<as>. Every call carries webhook-id, which is {runId}:{step} and identical on every retry so your receiver can dedupe, and webhook-timestamp. Authenticate it with a header of your own.
5xx responses, timeouts and network errors retry three times. An unexpected status is final, and onError decides what happens: fail fails the run, skip records the step as skipped and continues, continue continues with data: null.

Versions and publishing

1

Create a draft

POST /v1/workflows with { slug, name, description?, spec } answers 201 with a draft at version 1. The slug new is reserved.
2

Edit it

PATCH /v1/workflows/trial-nudge takes { name?, description?, spec? }. A changed spec creates the next version as a draft, an identical one creates nothing, and the published version keeps running throughout.
3

Publish

POST /v1/workflows/trial-nudge/publish activates the latest version. status becomes active and current points at it.
4

Pause it

POST /v1/workflows/trial-nudge/pause stops new runs from starting while runs already going finish. Only an active workflow can be paused, otherwise it is a 400 workflow_not_active, and publishing resumes it.
A spec that fails validation is a 400 invalid_spec whose param names the node, such as spec.steps[0].wait. Deleting a workflow soft-deletes it, frees the slug and cancels its live runs.

Dry runs

POST /v1/workflows/trial-nudge/test runs a version through the engine without waiting, sending or writing, and returns what it would have done.
version picks a version by number, defaulting to the published one, so a draft or an old version can be tried. externalId runs it for a real subscriber with their attributes, timezone and history, while attributes runs it for a made-up one that has no history. at is the dry run’s clock: every wait and waitUntil moves it forward instead of sleeping, so each trace entry says when the step would really happen. assume keys steps by name, taking { matched, data } for a waitFor and { status, data } for a fetch. The reply is { version, trigger, subscriber, outcome, exited, error, path, steps, vars, lint }. A send records its rendered payload rather than sending it, a set records the value it would write, and a step that would fail ends the trace with outcome: "failed" exactly as a real run would. Nothing is created: no message, no attribute write, no event.
A dry run is the only way to try a spec safely. A published workflow starts real runs on the next matching event, and those runs send real notifications.

Reading runs

A run is { id, workflowId, workflow, versionId, externalId, status, step, summary, startedAt, updatedAt }, with status being running, sleeping, waiting, completed, canceled or failed. step is the current or last step and summary its outcome in words. Everything a run does is on the subscriber’s event stream as $run.started, $run.step, $run.completed, $run.canceled and $run.failed. Sends inside a run are ordinary messages carrying run: { id, step }, so a message links back to its run and a run to its messages. A step’s status on $run.step is running, sleeping, waiting, completed or skipped, where skipped is a send held back by skipIfSentWithin or a fetch whose onError is skip. A schedule workflow also has GET /v1/workflows/:slug/schedule, which returns the next fire time per zone and the last twenty fires with how many runs each started. An event workflow answers 400 not_scheduled.

Next

Events

The stream a workflow triggers on and reads its history from.

Segments

The expression grammar workflow conditions are built on.

Sending messages

Every field a send step can carry.