Browse the docs

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.

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

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

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

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

SendFlowSyntax
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:

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

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

SendFlowSyntax
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:

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

FormMeaning
reentry onceNever re-enter, even after the first run has finished. The default when undeclared.
reentry on rematchRe-enter when the trigger fires again after the previous run has finished.
reentry per occurrenceThe 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 where filter, 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.

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

FormWhat counts
frequency cap 4 per 7dEvery message the contact got from any workflow. The default.
frequency cap 4 per 7d across workflowsThe same, spelled out.
frequency cap 2 per 14d in this workflowOnly this workflow’s own messages.
frequency cap offNothing: 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 workflow never 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.

  • off exempts the workflow from being capped, which is what a time-critical transactional stream needs. Use it sparingly and deliberately.

  • off still 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.

FormMeaning
enroll forwardOnly contacts who match from activation onward. The default.
enroll existingAlso back-enrol everyone who already matches — a one-time backfill.
enroll existing since 30dBack-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:

SendFlowSyntax
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

SettingCardinalityDefault when undeclared
enter on …exactly one— (required)
exit "<name>" when …any numberno goals
send window …at most onesend at any hour
reentry …at most oneonce
frequency cap …at most oneyour org-wide cap
enroll …at most oneforward