SendFlow
Conditions
How SendQL embeds into a workflow — the four places a predicate can go, and what each one means.
SendFlow does not have its own condition syntax. Every condition in a workflow is a SendQL predicate, parsed by SendQL’s grammar and checked by SendQL’s type checker.
Not a string that gets evaluated later. Not a similar-looking dialect. The same
language, embedded — which is why a type error inside a workflow’s if reports at
the exact line and column of the offending token in the .flow file.
The four places a predicate goes
where — filter the trigger. Narrows who enters.
enter on activity.signup where attr.plan = "free" and not suppressed
when — declare a goal. Checked before every step; the contact leaves the
moment it is true.
exit "converted" when attr.plan != "trial"
if — branch. Checked once, at that point in the flow.
if count(open within 7d) >= 1 { ... }
until — wait or loop. Checked repeatedly, while waiting or before each
iteration.
wait up to 7d until exists(activity.order)
repeat up to 3 every 2d until count(click within 2d) >= 1 { ... }
When each is evaluated
This is the only thing about conditions that is genuinely SendFlow’s, rather than SendQL’s, and getting it wrong is how campaigns misbehave.
| Where | Evaluated | Against |
|---|---|---|
enter ... where | once, at the moment the trigger fires | the contact, at that instant |
exit ... when | before every step, continuously | the contact, right now |
if | once, when control reaches it | the contact, right now |
wait up to ... until | repeatedly, until it is true or the bound expires | the contact, right now |
repeat ... until | before each iteration | the contact, right now |
The one to internalise is exit ... when. It is not checked where you wrote it —
it is checked everywhere. A contact halfway through a five-day wait, inside the
second iteration of a repeat, will still leave the instant their exit predicate
becomes true.
That is what lets you write campaigns that read as goals rather than as defensive checks:
workflow "Winback" v1 {
enter on segment "inactive-90d"
exit "reactivated" when count(open within 14d) >= 1
send "we-miss-you" via topic "marketing"
wait 3d
send "last-call" via topic "marketing"
}
Nobody who opens the first email receives the second, and there is not a single
if in the file.
Absence applies here too
The absence contract does not stop at the workflow
boundary. This branch does not take the then arm for a contact who has no
plan attribute at all:
if attr.plan != "trial" { ... }
The analyzer warns about it inside a workflow exactly as it does in a standalone segment:
warning: predicate on 'attr.plan' excludes contacts where it is unknown
In a workflow this matters more, not less: an absent attribute silently routes
someone down the else arm, and “why did the free users get the pro email”
usually resolves to exactly this.
Say what you mean:
if has attr.plan and attr.plan != "trial" { ... }
Events, activities, and the workflow’s own sends
Inside a workflow, event terms have a natural and very useful reading: they let a campaign react to the mail it just sent.
if not exists(open where template = "welcome") {
send "welcome-reminder" via topic "onboarding"
}
They did not open the welcome email, so nudge them. The open event and the
template property are ordinary SendQL — there is no special “did they open the
last message” construct, and none is needed.
Remember which side of the line each source falls on:
open,click,bounce,delivery— events, about your emailactivity.login,activity.order— activities, about your product
A workflow that waits on exists(order) will fail to analyze with
unknown event "order". You wanted exists(activity.order).
One line, one predicate
The formatter prints every embedded predicate on a single line, however you wrote it. A condition long enough to want wrapping is a condition that probably wants to be a saved segment instead:
if in segment "high-value-at-risk" { ... }
That composes, it is testable on its own, and it is one place to change when the definition moves.