Browse the docs

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.

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.

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.

SendFlow
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.

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>
Reach a system that is not SendOpscall webhook "<key>" as "<label>"
Fall back to email when that system says noa { failed: send … } arm on the call
Not mail your whole history on day oneleave enroll alone, or bound it with since