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 | on arrival, then repeatedly until it is true or the bound expires | the contact, right now |
repeat ... until | before each iteration’s wait, and again when the wait ends | 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 once their exit predicate becomes
true.
“Repeatedly” has a cadence. A condition that reads attributes or segment
membership is re-checked as soon as one of them changes. A condition that reads
events or activities is re-checked when the contact’s next step comes due, and at
least every 15 minutes while they wait. A wait up to that is already true on
arrival does not wait at all.
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 — add `or attr.plan is unknown` to include them, or `has attr.plan` to silence this
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.
Because it is ordinary SendQL, it is not scoped to the current run. It asks
whether the contact has ever opened welcome, including from an earlier run
or another campaign. In a workflow that contacts re-enter, bound it to this run
with a window no longer than the time since the send:
send "welcome" via topic "onboarding"
wait 2d
if not exists(open where template = "welcome" within 2d) {
send "welcome-reminder" via topic "onboarding"
}
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.