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. A send is a template followed by a set of independent, order-fixed optional clauses:

SendFlowSyntax
send "welcome"                              // inherit the template's defaults
send "welcome" via topic "onboarding"       // explicit topic override
send "welcome" transactional                // topic-exempt lifecycle mail
send "welcome" from "hello@acme.com"        // override the sender
send "welcome" via channel "marketing"      // pin the SES channel

The clauses stack in one fixed order — from, then via channel, then the topic/transactional arm:

SendFlowSyntax
send "receipt" from "receipts@acme.com" via channel "transactional" transactional

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. The topic and transactional arms are mutually exclusive.

from overrides the sender. The identity is a quoted string — either an exact email identity ("hello@acme.com") or a bare verified domain ("acme.com", valid for any local-part on that domain). Without it, the send inherits the sender the account resolves by default. The identity has to be one your account has verified and adopted — an unknown or unadopted one is a build error, not a silent fallback to the wrong address.

via channel pins the send to a specific channel — the SES configuration set that carries its own tracking, IP pool and event wiring. Without it the account’s default channel is used. Like from, the channel slug is checked against the channels your account has adopted.

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.

A workflow can mix defaults and overrides send by send — most steps inherit, while one routes through a different sender and channel:

SendFlow
workflow "order-receipt" v1 {
  enter on activity.order

  send "receipt" transactional
  wait 3d
  send "check-in" from "hello@acme.com" via channel "marketing" via topic "marketing"
}

The sender and channel must be ones your account has verified and adopted. An unknown name fails the build rather than silently mailing from the wrong place:

SendFlowRejected
workflow "t" v1 {
  enter on segment "trial-started"

  send "welcome" from "no-reply@acme.com"
}

unknown or unadopted sender identity "no-reply@acme.com"

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