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. The
parser reads the stray setting’s keyword where it expected a statement, so the
message names that keyword — for an enter after a send:
"enter" is a reserved word and cannot be used as a name here
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.
The offset must be at least one unit: enter on 0d after attr.trial_ends_at is
an error. To act at the moment of the date itself, use the smallest offset that
reads right, such as enter on 1m after attr.trial_ends_at.
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 once 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. When several exits are true at once, the first one declared is the one recorded.
Exits are checked before the first step too, so a contact who already meets a goal when they enter leaves straight away, having been sent nothing.
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.
How quickly an exit notices depends on what it reads. A change to an attribute or
to segment membership is noticed straight away, even mid-wait. A goal that reads
events or activities (exists(activity.order), count(open within 14d) >= 1) is
noticed when the contact’s next step comes due, and checked again at least every
15 minutes while they wait, so allow for a short lag.
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
The contact’s timezone is their timezone attribute, an IANA name such as
"Europe/London" or "America/New_York". A contact with no timezone, or one
that is not a valid IANA name, is judged in UTC.
A send that comes due outside the window is held until the window next opens, and then goes out. A window may run overnight:
send window 8pm-6am in contact timezone
The opening time is inclusive and the closing time is not: 9am-6pm sends at
5:59pm but holds a send that comes due at 6pm until 9am the next day.
Times are 12-hour with an am/pm suffix, hours 1 to 12; 12am is midnight and
12pm is noon. 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, such as 9am-9am, is an error: because the closing time
is exclusive, it would never open. To send at any hour, leave the setting out.
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, even after the first run has finished. The default when undeclared. |
reentry on rematch | Re-enter when the trigger fires again after the previous run has finished. |
reentry per occurrence | The same rule as on rematch, spelled for event and activity triggers. |
per occurrence is what you want for a cart-abandonment flow — a contact can
abandon many carts. once is what you want for onboarding.
Today on rematch and per occurrence behave identically; pick the one that
reads right for your trigger. Two rules apply to both:
- One run at a time. A contact is never in the same workflow twice at once. If the trigger fires again while their run is still going, that occurrence is ignored, not queued. A second abandoned cart during the first cart’s chase does not start a second chase.
- A met goal is not repeated. If the contact’s last run ended through a named
exit and they still match the trigger and its
wherefilter, they are not enrolled again. Someone who left as"purchased"is not chased about the cart they already bought.
frequency cap
At most one. Overrides your organisation-wide sending cap for this workflow.
frequency cap 4 per 7d
frequency cap 4 per 7d across workflows
frequency cap 2 per 14d in this workflow
frequency cap off
A cap has a threshold, a window and a scope: whose messages count against it.
| Form | What counts |
|---|---|
frequency cap 4 per 7d | Every message the contact got from any workflow. The default. |
frequency cap 4 per 7d across workflows | The same, spelled out. |
frequency cap 2 per 14d in this workflow | Only this workflow’s own messages. |
frequency cap off | Nothing: the workflow is exempt. |
Four subtleties worth getting right:
-
The default counts across workflows. The bare form changes this workflow’s threshold and window, not what gets counted against it. Messages from your other campaigns still fill the contact’s quota, so a secondary flow can find the budget already spent by your onboarding.
-
in this workflownever removes the org-wide protection. It counts only this workflow’s sends against its own cap, and your organisation’s default cap, which always counts across workflows, still applies on top. Use it for a flow that should pace itself without being starved by the others. -
offexempts the workflow from being capped, which is what a time-critical transactional stream needs. Use it sparingly and deliberately. -
offstill counts. It stops this workflow from being capped, but its sends still count against every other workflow’s cap.
When a send is held back by a cap, the contact’s timeline names the workflows that used up the budget.
The window can be at most 400d. A longer one cannot be counted, so a workflow
declaring it is rejected when you save it, rather than quietly capping less than
you asked.
Undeclared, the organisation-wide default applies: 20 messages per 24 hours, across workflows. It is a guard against a runaway flow, not a marketing cadence.
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 |