SendFlow
SendFlow
A workflow language for lifecycle email. One file is one campaign, read top to bottom.
A SendFlow file is one campaign. It reads top to bottom, it always terminates, and it maps losslessly onto a flowchart.
workflow "Trial onboarding" v1 {
enter on segment "trial-started"
exit "converted" when attr.plan != "trial"
send "welcome" via topic "onboarding"
wait 2d
if not exists(open where template = "welcome") {
send "welcome-reminder" via topic "onboarding"
wait 2d
}
send "activation-tips" via topic "onboarding"
wait up to 7d until count(activity.login within 7d) >= 2 {
timeout: send "need-a-hand" via topic "onboarding"
}
}
Anatomy
Every workflow has the same three-part shape.
workflow "<name>" v<N> {
<settings> // who enters, who leaves, when you may send
<statements> // what happens, in order
}
The header names the workflow and pins the language profile it was written
against. v1 is not a version of your campaign — it is the version of SendFlow
itself, so a file keeps meaning what it meant when you wrote it. See
versioning.
Settings describe the shape of the campaign: exactly one trigger, any number of named exits, and optional rules about send windows, re-entry, frequency and back-enrolment. Settings must all come before the first statement — a setting after a statement is a parse error, not a lint.
Statements are the campaign itself, executed in order for each contact who enters.
Conditions are SendQL
Every where, when, if and until in the file above is a real
SendQL predicate — parsed by the same grammar, checked by the same
type checker, reported with the same errors. Not a string. Not a mini-language.
The same language.
enter on segment "x" where attr.plan = "pro" // a predicate
exit "converted" when attr.plan != "trial" // a predicate
if count(open within 7d) >= 1 { ... } // a predicate
wait up to 7d until exists(activity.order) { } // a predicate
If you can write a segment, you can already write every condition SendFlow has.
Structured, with no goto
The control flow is deliberately small:
- sequence — one statement after another
- branch —
if/else if/else, whose arms rejoin afterwards - split — a weighted random fork, for A/B tests and holdouts
- bounded repeat —
repeat up to N every <duration>, the only loop - timed waits — for a duration, until a date, or until a condition
There is no goto, no jump, no unbounded loop and no recursion. A repeat
must declare a maximum count. This is not a limitation you will run into; it is
the property that makes a workflow drawable as a diagram, checkable before it
runs, and impossible to accidentally turn into an infinite mail loop.
Exits happen everywhere
A named exit is not a statement you place somewhere. It is a setting, and it is re-evaluated before every step:
exit "converted" when attr.plan != "trial"
The moment that predicate becomes true, the contact leaves — whichever step they
were on, whether they were mid-wait, mid-repeat, or about to receive the next
send. The name is what shows up in conversion reporting.
This is why you rarely need to guard your sends with if conditions checking
whether the goal has already been met. Declare the goal once, at the top, and the
engine handles the rest.
The file on a canvas
Because the control flow is structured, a .flow file and a flowchart are two
views of one thing. Every workflow projects to a graph and back with nothing
lost — including your comments. A visual editor is not a separate source of
truth that has to be reconciled with the text; it is the text.
Next
- Settings — triggers, exits, windows, caps, enrolment
- Statements — send, wait, branch, split, repeat
- Conditions — how SendQL embeds
- Cookbook — complete, working campaigns