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 ... untilrepeatedly, until it is true or the bound expiresthe contact, right now
repeat ... untilbefore each iterationthe 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:

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

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.

Remember which side of the line each source falls on:

  • open, click, bounce, deliveryevents, about your email
  • activity.login, activity.orderactivities, 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.