A workflow is a program in **SendFlow**, the Drip Workflow language (stored as `.flow` files when [git-backed](/audience/managed-vs-git-authoring)). The language is deliberately small: sequence, branching, bounded loops and waits — everything a real email journey needs, and nothing that would break the [canvas view](/workflows/authoring). Conditions are written in the same [rule language as Segments](/audience/segment-syntax), embedded directly.

## A complete example

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

  send "welcome" transactional
  wait 2d
  if not exists(open where template = "welcome") {
    send "welcome-reminder" via topic "onboarding"
    wait 2d
  }
  send "activation-tips" via topic "onboarding"
  wait up to 7d until count(activity.login within 7d) >= 2 {
    timeout: send "need-a-hand" via topic "onboarding"
  }
}
```

The first send goes out **transactional** — the welcome mail is lifecycle, not marketing, so it's topic-exempt (see [Consent & lifecycle mail](/sending-email/consent-and-lifecycle#sending-onboarding--transactional-mail)); the nudges that follow ride a marketing topic a recipient can leave.

## Settings (before any step)

| Setting | Meaning |
|---|---|
| `enter on segment "<key>" [where <condition>]` | Enroll when a contact enters the Segment |
| `enter on event <name> [where <condition>]` | Enroll on a SES engagement event (`open`, `click`, `delivery`, `bounce`, …) |
| `enter on activity.<name> [where <condition>]` | Enroll on a custom [activity](/workflows/triggers) you send through the Activities API (e.g. `activity.purchase`) |
| `enter on <duration> before\|after attr.<name> [where …]` | Enroll relative to a datetime attribute (e.g. `3d before attr.renewal_date`) |
| `exit "<name>" when <condition>` | Named exit, checked before **every** step; first match wins |
| `send window <from>-<to> in contact timezone` | Defer due sends outside the window (e.g. `9am-6pm`) |
| `reentry once \| on rematch \| per occurrence` | Re-entry policy (default `once`) |
| `frequency cap <N> per <duration>` \| `frequency cap off` | Override this workflow's [frequency cap](/workflows/sends-and-approval#frequency-cap) (e.g. `frequency cap 4 per 7d`); `off` exempts it. Undeclared → the org-wide default applies. Also settable from the **Flow settings** panel for managed (dashboard-edited) workflows. |
| `enroll forward` \| `enroll existing [since <duration>]` | [Enrollment scope](/workflows/triggers#enrollment-scope) on activation: `forward` = only future matches; `existing` = also back-enroll contacts who already match (a one-time backfill), optionally bounded by `since <duration>`. Undeclared → `forward`, for every trigger type. Also settable from the **Flow settings** panel. |

## Steps

| Statement | Meaning |
|---|---|
| `send "<template>" via topic "<topic>"` | Send in the **marketing** lane under a named topic — consent re-checked at send time, topic opt-outs honored |
| `send "<template>" transactional` | Send in the **transactional** lane — topic-exempt lifecycle mail (welcome, receipts, password resets) |
| `send "<template>"` | **Bare** send: inherit the template's default — its transactional default if set, else its default topic (a template with neither is a hard error) |
| `wait <duration>` | Pause the journey (`wait 2d`) |
| `wait until <date or attr.<name>>` | Pause until an absolute date or a contact's datetime attribute |
| `wait up to <duration> until <condition> { timeout: … }` | Wait for a condition with a deadline; the optional `timeout:` arm runs if it never becomes true |
| `if <condition> { … } else if … { … } else { … }` | Branch; all arms **rejoin** after the block |
| `split { 50%: { … } 50%: { … } }` | Stable random cohorts (weights must total 100) |
| `hold out <N>%` | The named percentage exits here (control group); everyone else continues |
| `repeat up to <N> every <duration> [until <condition>] { … }` | The only loop — always bounded, with a delay before each pass |
| `set attr.<name> = <value>` | Write a contact attribute |
| `add to list "<key>"` | Add the contact to a [List](/audience/lists) |
| `exit` | End the journey here |

The three `send` forms pick a message's **consent lane** by a precedence ladder — `transactional` wins, else the explicit `via topic`, else the template's own default — resolved at send time. [Consent & lifecycle mail](/sending-email/consent-and-lifecycle#sending-onboarding--transactional-mail) is the full explanation, including why a bare send of a template with no default is a loud error rather than a guess.


  A topic name in `via topic "<name>"` is a **quoted string**, like the [Segment](/audience/segments), [List](/audience/lists) and exit names elsewhere in the grammar (`enter on segment "trial-started"`, `add to list "beta"`). Quoting lets a topic name carry characters a bare word can't — notably the hyphens in names like `"product-updates"` — and it means a topic can safely be named after a keyword (`via topic "timeout"`).

  Earlier versions also accepted a bare, single-word name (`via topic onboarding`). That form was removed: every name in the language is now a quoted string, with no exceptions to remember.



  A duration is an amount followed by a unit suffix, with no space — `wait 2d`, `every 24h`, `frequency cap 4 per 7d` — and it is written exactly the same way inside a condition (`open within 7d`). Units are `s`, `m`, `h`, `d`, `w`, `mo`, `y`.

  Earlier versions also accepted a word form at the workflow level (`wait 2 days`). That form was removed, so there is only one way to write a duration; `wait 2 days` is now a parse error, and the editor will tell you to write `wait 2d`.



  `set attr.<name> = <value>` respects the attribute's registered type. If the attribute is an enum, the value must be one of its declared members — an unknown value is rejected at validation time, not silently written.


## Conditions

Everything after `where`, `when`, `until` and inside `if` is a Segment rule — the same syntax, same attributes, same event and `activity.<name>` functions as [Segment syntax](/audience/segment-syntax), evaluated against the individual contact at that moment. A few idioms:

```sendql
attr.plan = "pro" and attr.score >= 50
exists(activity.order within 30d)
not exists(open where template = "welcome")
count(click within 7d) >= 2
sum(amount of activity.order within 90d) > 500
in segment "high-value"
```

Bare names inside `exists(…)` are the built-in message events (`open`, `click`, `bounce`, …); a custom [activity](/audience/activities) is always `activity.<name>`, and filtering on its properties requires them to be [promoted](/audience/activities#promoted-properties) first.


  There is no jump/goto and no unbounded loop. That restriction is what guarantees every workflow renders as a clean, auto-laid-out canvas, and that every journey provably terminates.


## Comments

`// comments` are preserved: through the formatter, through canvas edits, and through promote-to-git. Use them freely to explain intent.