SendFlow
Settings
Triggers, named exits, send windows, re-entry, frequency caps and enrolment scope.
Settings describe the shape of a campaign — who comes in, who goes out, and the rules that apply throughout. They all sit at the top of the workflow, before the first statement.
workflow "Renewal" v1 {
enter on 3d before attr.renewal_date where attr.plan = "pro"
exit "renewed" when exists(activity.invoice_paid within 7d)
send window 9am-6:30pm in contact timezone
reentry per occurrence
frequency cap 4 per 7d
enroll existing since 30d
send "renewal-notice" via topic "billing"
}
Putting a setting after a statement is a parse error, not a warning. Their order among themselves does not matter, and the formatter preserves whatever order you chose.
enter — the trigger
Exactly one, always. A workflow with no enter can never start, and one with two
has no defined beginning; both are errors.
There are four kinds of trigger.
A segment. The contact enters when they match a saved SendQL segment.
enter on segment "trial-started"
An activity. The contact enters when they do something in your product. This is the natural trigger for most lifecycle campaigns.
enter on activity.cart_updated
An event. The contact enters on an email event — one of the eight built-ins. Legal, but usually a deliverability alarm rather than a campaign.
enter on event bounce
A date, relative to an attribute. The contact enters a fixed duration before or after one of their own datetime attributes.
enter on 3d before attr.renewal_date
enter on 1d after attr.trial_ends_at
This is the only way to act on a date in the future, and it is the reason SendQL has no need to express one. Each contact is enrolled relative to their date, not a global one.
Any trigger can take a where filter, which is a full SendQL predicate:
enter on activity.signup where attr.plan = "free" and not suppressed
exit — named exits
Any number, including none. An exit is a goal: a predicate that is checked before every single step, and the moment it is true, the contact leaves.
exit "converted" when attr.plan != "trial"
exit "purchased" when exists(activity.order)
The name is not decoration — it is what conversion reporting counts. Two exits may not share a name.
An exit fires wherever the contact happens to be: mid-wait, inside a repeat,
between two sends. This is what makes them worth using over a defensive if
before each send. Declare the goal once.
send window
At most one. Restricts sends to a window in the contact’s own timezone, so nobody gets a 3am marketing email.
send window 9am-6:30pm in contact timezone
Times are 12-hour with an am/pm suffix; minutes are optional (9am and
9:00am are the same thing, and the formatter will normalise the second to the
first). A window that opens and closes at the same time is a warning — it is
almost certainly a typo for “all day”.
reentry
At most one. What happens when a contact who has already been through the workflow qualifies again.
| Form | Meaning |
|---|---|
reentry once | Never re-enter. The default when undeclared. |
reentry on rematch | Re-enter if they stop matching and then match again. |
reentry per occurrence | Re-enter every time the trigger fires. |
per occurrence is what you want for a cart-abandonment flow — a contact can
abandon many carts. once is what you want for onboarding.
frequency cap
At most one. Overrides your organisation-wide sending cap for this workflow.
frequency cap 4 per 7d
frequency cap off
Two subtleties worth getting right:
- Counting stays cross-workflow. The cap changes this workflow’s threshold and window, not what gets counted against it. Messages from your other campaigns still fill the contact’s quota.
offexempts the workflow entirely, which is what a time-critical transactional stream needs. Use it sparingly and deliberately.
Undeclared, your organisation’s default applies.
enroll — enrolment scope
At most one. Whether contacts who already match are back-enrolled when the workflow is switched on.
| Form | Meaning |
|---|---|
enroll forward | Only contacts who match from activation onward. The default. |
enroll existing | Also back-enrol everyone who already matches — a one-time backfill. |
enroll existing since 30d | Back-enrol, but only as far back as the window. |
The default is forward-only, for every trigger kind, and it is worth pausing
on. Switching on enter on segment "all-users" does not mail every current
member of that segment — it enrols only the contacts who match from that moment
onward. Back-enrolling your history is opt-in, precisely because the alternative
is a campaign that sends to your entire list the second you turn it on.
If you do want the backfill, bound it:
enroll existing since 30d
enroll existing with no since on a date-relative trigger is an error,
because an unbounded backfill there would enrol every contact who has ever had an
anchor date — which is all of them, forever.
The full set
| Setting | Cardinality | Default when undeclared |
|---|---|---|
enter on … | exactly one | — (required) |
exit "<name>" when … | any number | no goals |
send window … | at most one | send at any hour |
reentry … | at most one | once |
frequency cap … | at most one | your org-wide cap |
enroll … | at most one | forward |