SendFlow
SendFlow cookbook
Complete, working campaigns — onboarding, cart abandonment, push with an email fallback, 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.
Two things it does not do. A cart abandoned while the contact is still being
chased about the previous one does not start a second chase — a contact is in a
workflow once at a time. And the repeat waits its 24h before the first nudge,
so the chase is the reminder at one hour, then a nudge a day later, then another
a day after that.
A push notification, with an email fallback
SendOps does not send push notifications. Your app does — so the campaign calls out to it, and mails only the people it could not reach that way.
workflow "Cart abandonment with push" 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
// Try the phone first. The receiver refuses when there is no device for this
// contact, and the email goes instead.
call webhook "mobile-push" as "cart-reminder" {
failed: send "cart-reminder" via topic "marketing"
}
repeat up to 2 every 24h until exists(activity.order) {
call webhook "mobile-push" as "cart-nudge" {
failed: send "cart-nudge" via topic "marketing"
}
}
}
Three things are doing the work.
"mobile-push" is a key, not a URL. It names an endpoint registered in
SendOps under Connections → Webhooks with the Workflows purpose. The address,
the signing secret and the headers live in the account, so this file is safe to
commit and safe to hand to an agent.
The failed: arm is the fallback, not the ending. When your receiver answers
“no device for this contact”, the email goes instead — and then both paths rejoin
and the flow carries on into the repeat. A failed call never fails the run.
Each call has its own label. Your receiver gets cart-reminder on one and
cart-nudge on the other, so one endpoint serves both steps and can still tell
which is which. Drop the labels and both calls become one address, which the
linter warns about.
The email half is unchanged by any of this: via topic "marketing" still means
the contact can opt out of it. The push half is yours, and so is the consent
behind it.
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> |
| Reach a system that is not SendOps | call webhook "<key>" as "<label>" |
| Fall back to email when that system says no | a { failed: send … } arm on the call |
| Not mail your whole history on day one | leave enroll alone, or bound it with since |