# Triggers and actions

The full catalog of workflow triggers, actions, and conditions with their inputs and outputs.

Every node in a workflow graph is one of three kinds, identified by a dotted ID
whose prefix names the kind:

| Kind      | Prefix        | Count                                                            |
| --------- | ------------- | ---------------------------------------------------------------- |
| Trigger   | `triggers.`   | 62  |
| Action    | `actions.`    | 19   |
| Condition | `conditions.` | 3 |

A workflow needs at least one trigger. Actions and conditions are optional, but
a graph with no action does nothing observable.

You pick these from the console's node palette rather than writing them, but
the IDs are what a workflow's exported JSON contains — so this is the table you
read when inspecting an export or a stored flow:

```json
{
  "nodes": [
    { "id": "n1", "kind": "trigger", "type": "triggers.form.submitted" },
    { "id": "n2", "kind": "condition", "type": "conditions.if_else" },
    { "id": "n3", "kind": "action", "type": "actions.send_email" }
  ],
  "edges": [
    { "source": "n1", "target": "n2" },
    { "source": "n2", "target": "n3", "sourceHandle": "true" }
  ]
}
```

`sourceHandle` is how a branch is chosen: condition nodes emit `true` and
`false`, and `actions.wait_for_event` emits `event_received` and `timeout`. An
edge with no handle is the default path.

Fetch a workflow's flow with `GET /api/workflows/:id/export`.

## Triggers

### Audience

Thirteen triggers covering membership, tags, lists, and compliance state.

| ID                                    | Fires when                                          |
| ------------------------------------- | --------------------------------------------------- |
| `triggers.audience.member_created`    | A new audience member is created                    |
| `triggers.audience.member_updated`    | Any member field changes                            |
| `triggers.audience.attribute_changed` | A specific custom attribute changes                 |
| `triggers.audience.added_to_list`     | Added to an audience list                           |
| `triggers.audience.removed_from_list` | Removed from an audience list                       |
| `triggers.audience.tag_added`         | A tag is applied                                    |
| `triggers.audience.tag_removed`       | A tag is removed                                    |
| `triggers.audience.unsubscribed`      | Opted out                                           |
| `triggers.audience.resubscribed`      | Opted back in                                       |
| `triggers.audience.blocked`           | Blocked                                             |
| `triggers.audience.unblocked`         | Unblocked                                           |
| `triggers.audience.email_invalidated` | Address marked invalid, usually after a hard bounce |
| `triggers.audience.import_completed`  | A bulk import finished                              |

### Email engagement

| ID                             | Fires when                           |
| ------------------------------ | ------------------------------------ |
| `triggers.email.sent`          | A message is dispatched              |
| `triggers.email.opened`        | The tracking pixel loads             |
| `triggers.email.clicked`       | A tracked link is followed           |
| `triggers.email.bounced`       | Delivery hard-fails                  |
| `triggers.email.unsubscribed`  | Recipient opts out from this message |
| `triggers.email.campaign_sent` | A whole campaign finishes sending    |

`opened` and `clicked` depend on tracking being enabled for the template.
Neither is reliable enough to gate access on — image blocking suppresses opens.

### Subscription

| ID                                       | Fires when                           |
| ---------------------------------------- | ------------------------------------ |
| `triggers.subscription.created`          | First subscription on a workspace    |
| `triggers.subscription.updated`          | Any subscription field changes       |
| `triggers.subscription.upgraded`         | Moved to a higher plan               |
| `triggers.subscription.canceled`         | Canceled immediately                 |
| `triggers.subscription.cancel_scheduled` | Cancellation set for period end      |
| `triggers.subscription.resumed`          | Reactivated after cancellation       |
| `triggers.subscription.suspended`        | Suspended, typically for non-payment |
| `triggers.subscription.trial_started`    | Trial begins                         |
| `triggers.subscription.trial_will_end`   | Trial is approaching its end         |
| `triggers.subscription.trial_expired`    | Trial ended without conversion       |

`cancel_scheduled` and `canceled` are distinct. A win-back sequence should listen
for `cancel_scheduled`, which still has time to act.

### Payment, credits, quota

| ID                                 | Fires when                        |
| ---------------------------------- | --------------------------------- |
| `triggers.payment.succeeded`       | Payment captured                  |
| `triggers.payment.failed`          | Payment declined                  |
| `triggers.payment.action_required` | Payment needs 3DS or similar      |
| `triggers.credit.purchased`        | Credits bought                    |
| `triggers.credit.granted`          | Credits issued without payment    |
| `triggers.credit.consumed`         | Credits spent                     |
| `triggers.credit.expired`          | Credits lapsed                    |
| `triggers.credit.low_balance`      | Balance crossed the low threshold |
| `triggers.quota.limit_exceeded`    | A metered quota was exceeded      |

### User and organization

| ID                                       | Fires when             |
| ---------------------------------------- | ---------------------- |
| `triggers.user.registered`               | Account created        |
| `triggers.user.email_verified`           | Address confirmed      |
| `triggers.user.logged_in`                | Successful sign-in     |
| `triggers.user.password_changed`         | Password updated       |
| `triggers.user.password_reset_requested` | Reset requested        |
| `triggers.user.profile_updated`          | Profile fields changed |
| `triggers.organization.member_invited`   | Org invite sent        |
| `triggers.organization.member_accepted`  | Org invite accepted    |
| `triggers.organization.member_removed`   | Org member removed     |

### Workspace

| ID                                       | Fires when               |
| ---------------------------------------- | ------------------------ |
| `triggers.workspace.created`             | Workspace created        |
| `triggers.workspace.updated`             | Workspace fields changed |
| `triggers.workspace.deleted`             | Workspace deleted        |
| `triggers.workspace.member_added`        | Member joins             |
| `triggers.workspace.member_removed`      | Member leaves            |
| `triggers.workspace.member_role_changed` | A member's role changes  |
| `triggers.workspace.settings_updated`    | Settings changed         |
| `triggers.workspace.features_updated`    | Feature flags toggled    |

### Forms, push, workflows

| ID                                     | Fires when                             |
| -------------------------------------- | -------------------------------------- |
| `triggers.form.submitted`              | A [form](/forms/overview) is submitted |
| `triggers.push.campaign_sent`          | A push campaign finishes sending       |
| `triggers.workflow.published`          | A workflow version is published        |
| `triggers.workflow.paused`             | A workflow is paused                   |
| `triggers.workflow.resumed`            | A workflow is resumed                  |
| `triggers.workflow.instance_completed` | An instance finishes successfully      |
| `triggers.workflow.instance_failed`    | An instance fails                      |

The `triggers.workflow.*` family lets one workflow react to another — useful for
alerting on repeated failures without polling.

## Actions

### Email and push

| ID                   | Notes                                                                                                                                                                                          |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions.send_email` | Two modes: **Use Template** or **Custom Content**. Outputs delivery status plus engagement fields (`Email Opened`, `Link Clicked`, `Email Bounced`, `Open Count`) that populate as they happen |
| `actions.send_push`  | Targets an audience member, a workspace, or a user. Outputs `Delivered`, `Status`, `Skipped`, `Skip Reason`                                                                                    |

Both report `Skipped` with a `Skip Reason` rather than failing when a recipient
is unsubscribed or has no push subscription. A skipped send is not an error and
does not consume a retry.

### Timing

| ID                       | Notes                                                                                                                                       |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions.delay`          | Three modes: fixed duration (minutes, hours, days), until a time of day, or until the next weekday — including a "next business day" option |
| `actions.wait_for_event` | Pauses the instance until a matching event arrives or a timeout fires                                                                       |

`wait_for_event` is the branching primitive for engagement flows. It exposes two
outgoing handles, `event_received` and `timeout`, so "opened the email" and
"did not open within three days" are separate paths.

Match conditions use three operators: `eq`, `exists`, and `contains`. Timeout
can be a fixed duration, a time of day, or **no timeout at all** — an instance
waiting forever stays `running` and counts against re-entry rules.

### Audience and CRM

| ID                                                            | Purpose                                                                         |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `actions.add_tag` / `actions.remove_tag`                      | Tag management. Outputs include `Was Already Tagged`-style flags                |
| `actions.audience_list_add` / `actions.audience_list_remove`  | List membership                                                                 |
| `actions.audience_subscribe` / `actions.audience_unsubscribe` | Opt-in state, with a reason on unsubscribe                                      |
| `actions.audience_block` / `actions.audience_unblock`         | Blocking, with a reason                                                         |
| `actions.audience_mark_invalid`                               | Flag an address as undeliverable                                                |
| `actions.update_audience`                                     | Update name, email, language, country, timezone, currency, or custom attributes |
| `actions.grant_credits`                                       | Grant credits to a workspace. Outputs `Amount Granted` and `Balance After`      |

Every compliance action outputs a "was already in that state" boolean, so a
workflow can branch on whether it actually changed anything rather than
re-notifying someone who was already unsubscribed.

### Lookup and data

| ID                       | Purpose                                                                                                                                                  |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions.get_audience`   | Look up a member by ID or email. Outputs the full profile — tags, lists, country, timezone, custom attributes — plus `Found`                             |
| `actions.get_workspace`  | Look up a workspace. Outputs member count, owner email, subscription status, plan name, billing currency                                                 |
| `actions.data_transform` | Extract a field, parse JSON, format a date, evaluate a math expression, replace or split a string, change case, render a template, or set a static value |

`Found` is a boolean output, not a failure. A lookup that matches nothing
continues the flow — branch on `Found` with a condition rather than assuming the
instance stops.

### Integrations

| ID                     | Purpose                                                              |
| ---------------------- | -------------------------------------------------------------------- |
| `actions.http_webhook` | Call your own API. See [webhook actions](/workflows/webhook-actions) |

## Conditions

| ID                        | Behavior                                                       |
| ------------------------- | -------------------------------------------------------------- |
| `conditions.if_else`      | Branches on a field check. Handles: `true`, `false`            |
| `conditions.filter`       | Continues only when the check passes, otherwise ends this path |
| `conditions.split_random` | Percentage split, for A/B testing a sequence                   |

`conditions.split_random` is the only non-deterministic node in a workflow. The
same subject re-entering a workflow can take a different branch, so do not use
it where a stable assignment matters.

## Merge tags

Any input marked `allowMergeTags` accepts `{{path}}` referring to the trigger
payload or an earlier node's outputs. The console offers only tags that are
actually reachable from that node.

Publishing validates every reference, and an invalid one is a **blocking**
error — not a warning. Two cases are rejected:

- the tag points at a node that has been deleted
- the tag names an output the referenced node does not produce

So a broken merge tag stops the publish rather than resolving to an empty
string at run time. Note this is stricter than the email template engine, where
an unknown tag [silently becomes an empty
string](/email/templates-and-merge-tags).

## Next Steps

- [Webhook actions](/workflows/webhook-actions) — receive an `actions.http_webhook` call.
- [Monitoring](/workflows/monitoring) — instances, retries, and the dead-letter queue.
- [Workflows overview](/workflows/overview) — lifecycle and re-entry.
