Browse the docs

SendFlow

SendFlow

A workflow language for lifecycle email. One file is one campaign, read top to bottom.

A SendFlow file is one campaign. It reads top to bottom, it always terminates, and it maps losslessly onto a flowchart.

SendFlow
workflow "Trial onboarding" v1 {
  enter on segment "trial-started"
  exit "converted" when attr.plan != "trial"

  send "welcome" via topic "onboarding"
  wait 2d
  if not exists(open where template = "welcome") {
    send "welcome-reminder" via topic "onboarding"
    wait 2d
  }
  send "activation-tips" via topic "onboarding"
  wait up to 7d until count(activity.login within 7d) >= 2 {
    timeout: send "need-a-hand" via topic "onboarding"
  }
}

Anatomy

Every workflow has the same three-part shape.

SendFlowSyntax
workflow "<name>" v<N> {
  <settings>      // who enters, who leaves, when you may send

  <statements>    // what happens, in order
}

The header names the workflow and pins the language profile it was written against. v1 is not a version of your campaign — it is the version of SendFlow itself, so a file keeps meaning what it meant when you wrote it. See versioning.

Settings describe the shape of the campaign: exactly one trigger, any number of named exits, and optional rules about send windows, re-entry, frequency and back-enrolment. Settings must all come before the first statement — a setting after a statement is a parse error, not a lint.

Statements are the campaign itself, executed in order for each contact who enters.

Conditions are SendQL

Every where, when, if and until in the file above is a real SendQL predicate — parsed by the same grammar, checked by the same type checker, reported with the same errors. Not a string. Not a mini-language. The same language.

SendFlowSyntax
enter on segment "x" where attr.plan = "pro"      // a predicate
exit "converted" when attr.plan != "trial"        // a predicate
if count(open within 7d) >= 1 { ... }             // a predicate
wait up to 7d until exists(activity.order) { }    // a predicate

If you can write a segment, you can already write every condition SendFlow has.

Structured, with no goto

The control flow is deliberately small:

  • sequence — one statement after another
  • branchif / else if / else, whose arms rejoin afterwards
  • split — a weighted random fork, for A/B tests and holdouts
  • bounded repeatrepeat up to N every <duration>, the only loop
  • timed waits — for a duration, until a date, or until a condition

There is no goto, no jump, no unbounded loop and no recursion. A repeat must declare a maximum count. This is not a limitation you will run into; it is the property that makes a workflow drawable as a diagram, checkable before it runs, and impossible to accidentally turn into an infinite mail loop.

Exits happen everywhere

A named exit is not a statement you place somewhere. It is a setting, and it is re-evaluated before every step:

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

The moment that predicate becomes true, the contact leaves — whichever step they were on, whether they were mid-wait, mid-repeat, or about to receive the next send. The name is what shows up in conversion reporting.

This is why you rarely need to guard your sends with if conditions checking whether the goal has already been met. Declare the goal once, at the top, and the engine handles the rest.

The file on a canvas

Because the control flow is structured, a .flow file and a flowchart are two views of one thing. Every workflow projects to a graph and back with nothing lost — including your comments. A visual editor is not a separate source of truth that has to be reconciled with the text; it is the text.

Next