How series start (entry triggers), who is eligible (audience), how they branch (conditions), and how recipients exit. Mirrors the Series Rules side panel in the dashboard exactly.
This page is the canonical reference for everything inside the Series Rules side panel (open it from any series detail page). It has three tabs: Entry, Goal, and Exit. Each section below maps one-to-one to a tab and a setting key on automations.settings.
Every matching contact is added to the queue with scheduled_for based on the first email’s send window + delay
Contacts who match later (e.g. via a segment update) are not added; manual is one-shot at activation
Use this for one-time campaigns where you want explicit control.When a manual series has no pending or processing recipients, it auto-completes (status flips to completed).
Halo enrolls every newly-identified contact automatically. Useful for welcome sequences:
New signup → “Welcome” email
1 day → “Get started” email
3 days → “Have you tried [feature]?”
This trigger fires on Halo’s row creation, specifically the first time any of these happens for a given external ID:
A first SDK identify() call
The chat-widget lead form captures a new email
A teammate adds the contact from the dashboard
It is not keyed off the signed_up_at trait. You do not need to send signed_up_at for “User signs up” series to work, and the trigger fires once per new row regardless.
Welcome series with imported users. If you bulk-import users via CSV, the Series API, or an integration sync, every imported contact is “newly created” from Halo’s point of view. Either keep the welcome series in paused until your import completes, or use an event trigger keyed on a real product signup event.
Enroll when a specific event type fires. Configure:
Event name (settings.entry_event_name): the event_type to listen for, e.g. trial_started, integration_connected, plan_upgraded
Events come from:
The SDK’s automatic activity tracking
Manual POST /api/sdk/events calls from your backend
Some integrations (e.g. Stripe webhook events can map to internal event types)
This is the most flexible trigger. Anything you can track as an event can start a series.The activate route returns 400 if you pick this trigger without setting an event name.
Enroll when a contact newly matches the audience filter set in the Audience section below. Requires:
Audience source set to Filter by criteria (audience_type: "segment")
At least one filter
Example: contact’s plan changes from free to pro → enroll in a “Welcome to Pro” series.Segment matches are evaluated:
On every SDK identify() call (when traits or company traits change)
By periodic background re-evaluation
When you activate a segment_match series, existing matches enroll immediately. After that, new matches enroll on their next SDK identify. The “On activate” card in the side panel shows the current match count for this reason.The activate route returns 400 if entry_trigger is segment_match and audience source isn’t “Filter by criteria”.
Filters the underlying end_users rows by contact_type:
In-app label
Value
Behavior
Users and leads (default)
both
Every contact, whether they have an external ID or not
Users only
user
Only contacts where contact_type = 'user'
Leads only
lead
Only contacts captured as leads (no external ID, lead form / sales agent / etc.)
This filter applies on every trigger path: the activate route honors it for manual sends, and the enrollment runtime honors it for user_created / event / segment_match so a lead can’t slip into a “Users only” series.
When source is All contacts with email or Filter by criteria, the side panel shows one-click presets that seed the filter set:
Preset
Filter applied
On trial
stripe:subscription_status is trialing
Active subscribers
stripe:subscription_status is active
Past due
stripe:subscription_status is past_due
Set to cancel
renewal_status is set_to_cancel
From SDK
source is sdk
Signed up in 30 days
signed_up_at after <today minus 30 days>
Inactive 30 days+
last_seen before <today minus 30 days>
Clicking a preset switches Source to Filter by criteria if it isn’t already, and appends the preset filter into the first AND group. Repeat clicks are idempotent.
The Signed up in 30 days preset reads the signed_up_at column directly. Contacts who do not have signed_up_at set are excluded. There is no fallback to created_at. If you want this preset to behave correctly across imports, make sure your identify() calls send signed_up_at. See User Traits → How automations and segments read signed_up_at.
When source is Filter by criteria, the audience builder is a list of AND/OR filter groups. Each filter is { field, operator, value }. The full list of fields is defined in getAllAudienceFields() and combines:
All user fields (identity, lifecycle, revenue, activity, renewal, Stripe)
All company fields, prefixed with company.
All lead fields, prefixed with lead:
Any custom fields discovered on end_users.custom_fields and companies.custom_fields
Operators by field type:
Field type
Available operators
string
is, is_not, contains
number
is, gt, lt, gte, lte
boolean
is
date
before, after
Date operators expect ISO 8601 (YYYY-MM-DD is fine for date-only comparisons; full timestamps work too).The recipient count under “On activate” recomputes whenever the filter set changes. Click View who will enroll / View who currently matches to inspect the actual contacts.If the count stays at 0 but you expect matches, see Troubleshooting. A common case is filtering on Role without passing role in identify().
Once a contact enrolls, they can’t enroll again, even after completing or exiting
settings.reenroll_enabled = true
Contacts can re-enroll after completing or exiting
settings.reenroll_delay_days
Optional minimum days between completing the series and re-entering. Skips re-enrollment when Date.now() - latest_enrollment.created_at < reenroll_delay_days * 86400000.
When re-enrolling, prior automation_recipients rows are deleted to satisfy the (step_id, end_user_id) unique index. Aggregate stats stay counter-based.If a contact has an activepending or processing recipient row, new enrollment is skipped (no reset, no duplicate) regardless of reenroll_enabled.
When enabled, at most one contact per company is enrolled. If another seat from the same company already has an active recipient row, this contact is skipped. Contacts without a company are unaffected.Use this to avoid emailing the same company multiple times from a series like “Renewal coming up” or “Trial ending soon”.
The Goal tab is purely for measurement. Toggling it on does not change who gets enrolled.
Setting
Type
Default
Purpose
settings.goal_enabled
boolean
false
Master toggle. Goal tracking only runs when this is true.
settings.goal_filters
filter set
[]
Match conditions that count as a conversion
settings.goal_window_days
number
30
How long after enrollment a recipient is eligible to convert
A recipient counts as “converted” when their traits, custom fields, Stripe data, or events start matching goal_filters within goal_window_days of their enrollment timestamp.Common goals:
Subscription activated (stripe:subscription_status is active)
Plan upgraded (plan is_not free)
Ticket resolved (custom event filter)
Adding goal_enabled = true without any goal filters is flagged in the side panel. No conversions will be tracked because there’s nothing to match.
Exit rules remove recipients from the series before they reach the final step. Multiple rules can be active at once; any one match is enough to exit. They live on settings.exit_rules as an array of strings.
In-app label
Value
When it fires
Completes the series
completed
Recipient finishes the last step. Always implicitly active; cannot be removed.
Unsubscribes
unsubscribed
Recipient unsubscribes from the linked subscription list (only meaningful when audience source is Subscription list)
Replies to an email
replied
Recipient replies to any email step. Their reply lands in the inbox as a normal ticket.
No longer matches the audience filter
segment_leave
Recipient stops matching the audience filter. Only meaningful when audience source is Filter by criteria with at least one filter.
Triggers an event
event
A specific event fires for the recipient. Configure with settings.exit_event_name.
Matches an exit filter
filter_match
Recipient starts matching settings.exit_filters (independent from the audience filter). Multiple groups for OR logic.
Manual removal only
manual
Lets a teammate explicitly remove a recipient from the recipient list in the dashboard. Always available; this rule just makes the action explicit in the config.
Exit rules are evaluated:
At enrollment time (filter_match only): if the contact already matches the exit filter, they aren’t enrolled at all
On every cron tick the recipient is processed: for replied, event, segment_leave, filter_match
On the unsubscribe webhook: for unsubscribed
Recipients exited mid-flight are stamped with the matching exit_reason on automation_recipients. Recipients who reach the final step are stamped exit_reason: "completed" automatically.
Conditions are different from exit rules. They live as steps inside the series (added in the Manual Builder canvas), not as series-level settings. They check something about the recipient’s history at the moment that step runs.
Type
What it checks
Required condition_value
email_opened
Did the recipient open a previous email step?
(none; checks the most recent prior email)
link_clicked
Did they click any link in a previous email?
(none)
user_property
Does a user trait, custom field, or Stripe field match an expression?
A key=value expression like plan=pro, mrr>=100, status!=churned, or email contains @acme.com
event_fired
Has a specific event fired since the recipient reached this step?
event_fired matches events that fired at or after the recipient reached this condition step (we use the recipient row’s created_at as the lower bound). This means the pattern “trial_started → 3-day wait → paid?” only passes for users who paid during the wait, not anyone who paid in their account history.
When a condition step is reached, Halo checks the condition:
Result
Behavior
True
Status completed, recipient advances to next step
False
Status skipped with reason: "condition_not_met", recipient does not advance
There is no second branch for the false path. Conditions are gates, not splits; failed users exit the series at that step. The canvas only exposes a single output handle to make this behavior explicit. To create true branching, use multiple parallel series triggered by different segments.
When you click Activate, the activate route runs validation and returns a 400 with a specific error message if anything is wrong. The side panel pre-flags these in the “Cannot activate yet” amber card so you can fix them up front. Order matches the activate route’s own validation.
Condition
Error
audience_type = "subscription" and no subscription_id
”Pick a subscription list under Audience before activating.”
audience_type = "segment" and audience_filters is empty
”Audience source is ‘Filter by criteria’ but no filter is set.”
entry_trigger = "segment_match" and audience_type !== "segment"
“‘Matches the audience filter’ trigger needs Audience source set to ‘Filter by criteria’.”
entry_trigger = "event" and no entry_event_name
”Set an event name under Trigger before activating.”
entry_trigger = "manual" and 0 recipients post-suppression
”No contacts match this audience right now.”
Marketing series without a verified custom domain
”A verified custom domain is required to send broadcasts and email series.”
Marketing series without a company footer
”Marketing emails require a company footer.”
user_created / event / segment_match triggers are forward-only. Activating one of them with 0 currently-eligible contacts is allowed; the series sits ready to enroll new matches as they arrive. (segment_match is the exception: existing matches enroll immediately at activation time, then ongoing matches enroll on identify.)