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 eventname, 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 aname 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.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.
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.