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.
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.
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.
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.
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.
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
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
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 want | Reach for |
|---|---|
| Stop mailing once they convert | exit "<name>" when <predicate> at the top |
| Nudge only the people who ignored you | if not exists(open where template = "…") |
| Give them time, then help | wait up to <d> until <predicate> { timeout: … } |
| Chase, but not forever | repeat up to N every <d> until <predicate> |
| Measure whether the campaign works at all | hold out 10% right after the trigger |
| Act on each occurrence, not just the first | reentry per occurrence |
| Act relative to each contact’s own date | enter on <d> before attr.<date> |
| Not mail your whole history on day one | leave enroll alone, or bound it with since |