Browse the docs

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.

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

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

SendFlowSyntax
wait 2d

Wait until a date, absolute or from the contact’s own attribute:

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

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

SendFlowSyntax
wait up to 3d until exists(open)

The timeout arm can hold several statements, not just one:

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

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

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

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

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

SendFlowSyntax
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 N is mandatory. A repeat with 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.

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

SendFlowSyntax
add to list "engaged"

exit

Leave the workflow, immediately and terminally.

SendFlowSyntax
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

SendFlow
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"
}