> ## Documentation Index
> Fetch the complete documentation index at: https://docs.haloagents.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Triggers & Conditions

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

## Entry tab

The Entry tab answers two questions in order:

1. **Trigger**: when does enrollment happen?
2. **Audience**: among the contacts the trigger fires for, who is allowed in?

Plus an **Options** block for re-enrollment and company priority.

### Triggers

Every series has an **entry trigger** stored in `settings.entry_trigger`. The four options in the dashboard map to these values:

| In-app label                    | `entry_trigger` value | When it fires                                                         |
| ------------------------------- | --------------------- | --------------------------------------------------------------------- |
| **Manual activation**           | `manual`              | You activate the series; everyone in the audience is enqueued at once |
| **User signs up**               | `user_created`        | Each new contact, the moment Halo first inserts the row               |
| **Triggers an event**           | `event`               | When a named event (`event_type`) fires from the SDK or API           |
| **Matches the audience filter** | `segment_match`       | When a contact starts matching the audience filter set above          |

#### Manual activation

The simplest trigger. When you click **Activate**:

1. Halo evaluates the audience filters
2. Every matching contact is added to the queue with `scheduled_for` based on the first email's send window + delay
3. 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`).

#### User signs up

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.

<Note>
  **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.
</Note>

#### Triggers an 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.

#### Matches the audience filter

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

### Audience

The Audience section narrows down who the trigger applies to. Two dropdowns drive everything:

#### Contact type (`settings.audience_contact_type`)

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.

#### Source (`audience_type` on the automation row)

Picks how the audience is defined:

| In-app label                | `audience_type` | What it means                                                                                                                    |
| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **All contacts with email** | `all`           | Every contact at the org with a non-null email                                                                                   |
| **Filter by criteria**      | `segment`       | Build a filter set inline (or pick a saved segment)                                                                              |
| **Subscription list**       | `subscription`  | Send only to contacts subscribed to a specific list, with consent rules. See [Subscription Lists](/outreach/subscription-lists). |

#### Quick filters

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.

<Warning>
  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 outreach campaigns and segments read `signed_up_at`](/contacts/user-traits#how-outreach-campaigns-and-segments-read-signed_up_at).
</Warning>

#### Filter by criteria

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](/outreach/troubleshooting). A common case is filtering on **Role** without passing `role` in `identify()`.

### Options

#### Allow re-enrollment

| Setting                                       | Behavior                                                                                                                                                                    |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `settings.reenroll_enabled = false` (default) | 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 **active** `pending` or `processing` recipient row, new enrollment is skipped (no reset, no duplicate) regardless of `reenroll_enabled`.

#### Company priority (`settings.company_priority`)

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

## Goal tab

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 tab

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 (mid-series branches)

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**? | The event name                                                                                         |

### `user_property` operators

| Operator             | Meaning                                                         |
| -------------------- | --------------------------------------------------------------- |
| `=`                  | string equality                                                 |
| `!=`                 | string inequality                                               |
| `>`, `>=`, `<`, `<=` | numeric comparison (returns false if either side isn't numeric) |
| `contains`           | case-insensitive substring (note the spaces around the keyword) |

Behavior on missing values:

* `=`, `>`, `<`, `>=`, `<=`, and `contains` evaluate to **false** when the field is unset
* `!=` evaluates to **true** when the field is unset and the expected value is non-empty

### `event_fired` scoping

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

### Condition evaluation outcome

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.

## Activation blockers

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

## Combining triggers and conditions

A common pattern:

1. **Trigger:** `event` with `entry_event_name = trial_started`
2. **Step 1:** "Welcome to your trial" email
3. **Step 2:** Wait 3 days
4. **Step 3:** Condition `event_fired` with `condition_value = paid_subscription_started`
   * **True** → next step (e.g. "Welcome to the Pro plan")
   * **False** → recipient exits with `condition_not_met`

The combination of trigger + audience + waits + conditions + content + exit rules + goal is what makes a series flexible.

## Where to go next

<CardGroup cols={2}>
  <Card title="Series" icon="layers" href="/outreach/series">
    Build the steps that triggers and conditions live within.
  </Card>

  <Card title="Subscription Lists" icon="envelope-open" href="/outreach/subscription-lists">
    Consent management for the unsubscribed exit rule.
  </Card>

  <Card title="User Traits" icon="user" href="/contacts/user-traits">
    Reference for the trait fields available in audience filters and conditions.
  </Card>

  <Card title="Troubleshooting" icon="circle-question" href="/outreach/troubleshooting">
    Why matched count is 0 and how to verify filters against real data.
  </Card>

  <Card title="Company Traits" icon="building" href="/contacts/company-traits">
    Reference for the company-prefixed fields in audience filters.
  </Card>
</CardGroup>
