Browse the docs

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.

SendFlowSyntax
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.

SendFlowSyntax
exit "converted" when attr.plan != "trial"

if — branch. Checked once, at that point in the flow.

SendFlowSyntax
if count(open within 7d) >= 1 { ... }

until — wait or loop. Checked repeatedly, while waiting or before each iteration.

SendFlowSyntax
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.

WhereEvaluatedAgainst
enter ... whereonce, at the moment the trigger firesthe contact, at that instant
exit ... whenbefore every step, continuouslythe contact, right now
ifonce, when control reaches itthe contact, right now
wait up to ... untilon arrival, then repeatedly until it is true or the bound expiresthe contact, right now
repeat ... untilbefore each iteration’s wait, and again when the wait endsthe 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:

SendFlow
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:

SendFlowSyntax
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:

SendFlowSyntax
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.

SendFlowSyntax
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:

SendFlowSyntax
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 email
  • activity.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:

SendFlowSyntax
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.