# SendFlow cookbook

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

Source: https://www.sendlang.com/docs/sendflow/cookbook
Section: SendFlow

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 trial-onboarding.flow
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 cart-abandonment.flow
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 winback.flow
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](/docs/sendql/absence).

## Renewal

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

```sendflow renewal.flow
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 welcome.flow
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 ab-test.flow
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 re-engagement.flow
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` |
