Browse the docs

SendFlow

SendFlow cookbook

Complete, working campaigns — onboarding, cart abandonment, winback, renewal, re-engagement.

Every workflow on this page is parsed and analyzed when this site is built. They are complete files, not fragments — copy one, rename the templates and topics to match your account, and it will lint.

Trial onboarding

The canonical drip. A goal declared once at the top, a conditional nudge, and a behavioural wait with a fallback.

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

  send "welcome" via topic "onboarding"
  wait 2d

  // Only nudge the people who did not open the first one.
  if not exists(open where template = "welcome") {
    send "welcome-reminder" via topic "onboarding"
    wait 2d
  }

  send "activation-tips" via topic "onboarding"

  // Give them a week to actually use the product before offering help.
  wait up to 7d until count(activity.login within 7d) >= 2 {
    timeout: send "need-a-hand" via topic "onboarding"
  }
}

The exit "converted" line is doing more work than it looks. Anyone who upgrades at any point — mid-wait, between sends — leaves immediately and is counted as a conversion. Without it, every send would need an if in front of it.

Cart abandonment

Event-driven, re-entrant, with a control group and a bounded chase.

SendFlow
workflow "Cart abandonment" v1 {
  enter on activity.cart_updated where not exists(activity.order within 1h)
  exit "purchased" when exists(activity.order)

  // A contact can abandon many carts. Let them back in each time.
  reentry per occurrence

  wait 1h

  // Ten percent get nothing, so the lift is measurable.
  hold out 10%

  send "cart-reminder" via topic "marketing"

  repeat up to 2 every 24h until exists(activity.order) {
    send "cart-nudge" via topic "marketing"
  }
}

reentry per occurrence is the setting that makes this correct. The default, once, would chase the first abandoned cart of a customer’s life and then never again.

Winback

Short, and almost entirely made of goals.

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 up to 5d until exists(click within 5d) {
    timeout: send "last-call" via topic "marketing"
  }
}

Note what is not here: no check before the second send that they did not already open the first. The exit does that, everywhere, for free.

Take care with the segment behind it. inactive-90d almost certainly wants to be last(open) < now - 90d or count(open) = 0 — the second clause is what includes contacts who never opened anything at all, and they are the bulk of the cohort. See the absence contract.

Renewal

The date-relative trigger: each contact is enrolled relative to their own renewal date.

SendFlow
workflow "Renewal" v1 {
  enter on 14d before attr.renewal_date where attr.plan = "pro" and not suppressed
  exit "renewed" when exists(activity.invoice_paid within 30d)

  send window 9am-6pm in contact timezone
  enroll existing since 30d

  send "renewal-notice" via topic "billing"

  wait until attr.renewal_date
  send "renewal-reminder" via topic "billing"
}

Two things worth copying. wait until attr.renewal_date waits until each contact’s own date, not a fixed one. And enroll existing since 30d bounds the backfill — without a since, enroll existing on a date-relative trigger is an error precisely to stop you enrolling every customer who has ever had a renewal date.

Welcome, with a branch

Routing on plan, and a transactional first message.

SendFlow
workflow "Welcome" v1 {
  enter on activity.signup where not suppressed
  reentry once

  // A receipt-class message: it goes out regardless of topic consent.
  send "welcome" transactional
  wait 1d

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

  wait 3d
  add to list "engaged"
}

transactional is the right call for the very first message and the wrong call for the three that follow it. The tips are marketing; they belong under a topic the contact can opt out of.

An A/B test

SendFlow
workflow "Upgrade offer test" v1 {
  enter on segment "upgrade-intent"
  exit "upgraded" when attr.plan = "pro"

  hold out 10%

  split {
    50%: {
      send "upgrade-offer" via topic "marketing"
    }
    50%: {
      send "trial-ending" via topic "marketing"
    }
  }

  wait 3d
  set attr.nudged = true
}

The holdout and the split do different jobs and you usually want both. The split tells you which subject line won. The holdout tells you whether sending anything at all beat sending nothing — which is the question people forget to ask.

Re-engagement, with a graceful goodbye

SendFlow
workflow "Re-engagement" v1 {
  enter on segment "dormant-180d"
  exit "reengaged" when count(open within 30d) >= 1

  frequency cap 2 per 30d

  send "we-miss-you" via topic "marketing"
  wait 7d

  send "last-call" via topic "marketing"
  wait 7d

  // Still nothing. Stop mailing them — it protects your sending reputation.
  add to list "lapsed"
  exit
}

The frequency cap 2 per 30d is a promise to a cohort that has already stopped listening: whatever else your other campaigns are doing, this one will not pile on.

Patterns worth stealing

You wantReach for
Stop mailing once they convertexit "<name>" when <predicate> at the top
Nudge only the people who ignored youif not exists(open where template = "…")
Give them time, then helpwait up to <d> until <predicate> { timeout: … }
Chase, but not foreverrepeat up to N every <d> until <predicate>
Measure whether the campaign works at allhold out 10% right after the trigger
Act on each occurrence, not just the firstreentry per occurrence
Act relative to each contact’s own dateenter on <d> before attr.<date>
Not mail your whole history on day oneleave enroll alone, or bound it with since