SendFlow
Statements
Send, wait, branch, split, hold out, repeat, set, add to list, exit — the whole executable surface.
Nine statements. That is the entire executable surface of the language.
send
The point of the whole exercise. Three forms, mutually exclusive.
send "welcome" // inherit the template's default topic
send "welcome" via topic "onboarding" // explicit topic override
send "welcome" transactional // topic-exempt lifecycle mail
The template is always a quoted string, and it has to exist.
The topic is the consent grouping the message is sent under. A contact who
has opted out of that topic does not receive it. via topic overrides whatever
the template’s own default topic is.
transactional marks mail that is exempt from topic consent — a receipt, a
password reset, a legally-required notice. It is not a way to bypass someone’s
preferences for marketing, and treating it as one is how a sending domain gets
blocked.
A topic is a quoted string, always. Writing it bare is the single most common
mistake in a send:
workflow "t" v1 {
enter on segment "trial-started"
send "welcome" via topic onboarding
}
topic names are quoted: use via topic "onboarding", not via topic onboarding
Quoting them means a topic can be called anything at all — including a reserved
word. via topic "timeout" is legal.
wait
Three forms, plus a fourth that waits on a condition rather than a clock.
Wait a fixed duration.
wait 2d
Wait until a date, absolute or from the contact’s own attribute:
wait until 2026-12-01
wait until attr.renewal_date
The attribute must be a datetime.
Wait for a condition, with a bound. This is the one that makes drip campaigns feel alive:
wait up to 7d until count(activity.login within 7d) >= 2 {
timeout: send "need-a-hand" via topic "onboarding"
}
Read it as: wait for up to seven days for them to log in twice. If they do, carry straight on. If seven days pass and they have not, send the fallback, then carry on.
The timeout: arm is optional. Without it, the flow simply continues when
the bound expires:
wait up to 3d until exists(open)
The timeout arm can hold several statements, not just one:
wait up to 7d until exists(activity.order) {
timeout:
send "last-call" via topic "marketing"
wait 1d
add to list "lapsed"
}
if / else if / else
Branching. Both arms are always brace-delimited, so there is no dangling-else ambiguity to reason about.
if attr.plan = "pro" {
send "pro-tips" via topic "onboarding"
} else if attr.plan = "team" {
send "team-tips" via topic "onboarding"
} else {
send "free-tips" via topic "onboarding"
}
Arms rejoin. After the block ends, control continues at the next statement —
a branch is a fork in the path, not an ending. To actually end the flow inside an
arm, say exit.
split
A weighted random fork — the A/B test.
split {
30%: {
send "a" via topic "marketing"
}
70%: {
send "b" via topic "marketing"
}
}
The weights must sum to exactly 100. Anything else is an error, caught before the campaign ever runs:
workflow "t" v1 {
enter on segment "trial-started"
split {
30%: { send "a" via topic "marketing" }
30%: { send "b" via topic "marketing" }
}
}
Like if, split arms rejoin afterwards. A split with a single arm is a warning —
you almost certainly meant hold out.
hold out
A control group. The named percentage leaves the workflow here; everyone else carries on.
hold out 10%
That is the difference from split: a split sends everyone down some path,
while a holdout removes people from the campaign entirely so you can measure what
would have happened without it. Ten percent of contacts get nothing, on purpose,
and the lift you measure against them is the only honest number you will get.
Must be between 1% and 99%.
repeat
The only loop, and it is always bounded.
repeat up to 2 every 24h until exists(activity.order) {
send "cart-nudge" via topic "marketing"
}
Up to twice, a day apart, stopping early if they order.
up to Nis mandatory. Arepeatwith no bound is a parse error. There is no way to write an unbounded loop in SendFlow, which is why a workflow cannot turn into a mail bomb.every <duration>is the spacing between iterations.until <predicate>is optional, and is checked before each iteration.
set
Write to a contact attribute.
set attr.nudged = true
set attr.tier = "vip"
set attr.score = 10
set attr.last_contacted = now
The value’s type must match the attribute’s — set attr.score = "high" is an
error — and an enum attribute may only be set to an allowed value.
add to list
Add the contact to a named list.
add to list "engaged"
exit
Leave the workflow, immediately and terminally.
exit
Note the difference from the exit "name" when ... setting: that one declares
a goal checked everywhere, while this one is a statement that ends the flow right
where it stands. The bare exit takes no name.
Anything after it in the same block is unreachable, and you will be told so:
warning: unreachable: this statement follows an exit
Everything at once
workflow "Cart abandonment" v1 {
enter on activity.cart_updated where not exists(activity.order within 1h)
exit "purchased" when exists(activity.order)
reentry per occurrence
send window 9am-8pm in contact timezone
wait 1h
hold out 10%
if attr.plan = "pro" {
send "cart-reminder" via topic "billing"
} else {
send "cart-reminder" via topic "marketing"
}
repeat up to 2 every 24h until exists(activity.order) {
send "cart-nudge" via topic "marketing"
}
set attr.nudged = true
add to list "engaged"
}