# Workflows

A **Drip Workflow** is a multi-step lifecycle automation defined in the `.flow` DSL: a contact enrolls (on a Segment entering, an activity, or another trigger), then moves through wait steps, branches, and sends until it exits or completes. Workflows are authored either in the dashboard's canvas editor or **as code**, via the `workflows` key in a connected git repository's manifest — the same managed-vs-git split as Segments, Lists, and attributes.

The Public API exposes Workflows **read-only**, all under a single `api.workflows.view` scope: list and inspect definitions, page through a workflow's runs, pull aggregate analytics (exit reasons, per-node funnel), read one contact's full step-by-step run history, and dry-run the current definition against a real contact without enrolling them. There is no create/edit/send surface here — authoring stays in the dashboard or the repo.


  `GET /v1/workflows` and `GET /v1/workflows/{id}` never expose the `.flow` source, canvas layout, or content hash — just identifying metadata (`name`, `key`, `status`, `origin`, run counts). If you need to inspect or change the logic itself, do it in the dashboard or the connected repo.


## What's in a definition

| Field | Meaning |
|---|---|
| `status` | `draft`, `active`, `paused`, `archived`, or `invalid` (with `invalid_reason` set — the `.flow` source failed to parse). |
| `require_send_approval` | When `true`, every send this workflow schedules is held for manual review in the dashboard instead of dispatching automatically. |
| `current_version` | The live definition version. Runs and the funnel overlay are pinned to a specific version; a workflow edit bumps it. |
| `origin` / `source_path` / `source_url` | `managed` (authored in-app) or `git` (defined in a connected repo, read-only) — the same provenance model as [Lists, Segments, and attributes](/api-reference/lists-segments#git-backed-lists-segments-and-attributes). |
| `run_counts` | By-status tally of every contact run against this workflow, e.g. `{ "active": 812, "completed": 4310, "failed": 3 }`. Always present. |

## Runs

A **run** is one contact's journey through one workflow. `GET /v1/workflows/{id}/runs` returns a page of runs plus the workflow's by-status `counts`, and accepts a comma-separated `status` filter (`?status=active,completed`). Recipient email is deliberately not included on a run — `contact_id` is the join key, resolved separately under `api.contacts.view`.





## Reentry

Every `.flow` definition has a `reentry` setting controlling whether — and how — a contact can go through the same workflow more than once. `run.trigger_cause` on each run (e.g. `segment_entered`, `activity_triggered`) reflects which trigger fired for that particular enrollment.

| Mode | Behavior |
|---|---|
| `once` (default) | A contact enrolls **at most once, ever**. Durable and permanent — checked against the full run history, not a time window, so it survives restarts and holds regardless of how long ago the first run was. |
| `on rematch` | A contact may re-enroll after leaving the trigger Segment and re-entering it. |
| `per occurrence` | Event and activity triggers enroll on every occurrence, subject to the platform's standing guardrails (one live run per contact per workflow; a contact who left through a named exit isn't re-enrolled while still matching the trigger). |


  For `enter on activity.<name>` triggers specifically: `reentry once` is what actually prevents a duplicate *activity* (a retried [`POST /v1/activities`](/api-reference/activities) call, a dedup check that failed open, a re-emitting sync) from producing a duplicate *send*. [Activity-level dedup](/api-reference/activities#once-per-lifetime-milestones-dedup_mode) (`idempotency_key` / `dedup_mode`) reduces how often a duplicate activity happens in the first place, but the durable, permanent guarantee against a duplicate send sits here, at enrollment. Worst case with `reentry once`: an extra timeline row, never an extra email.


`reentry` is set in the `.flow` source (authoring only — not exposed on `GET /v1/workflows`, per the "definitions are metadata only" note above) and is reflected in run behavior via this API: a rejected re-enrollment attempt simply produces no new run, so absence of a new `active` run for a contact you expected to re-trigger is the signal to check the workflow's `reentry` mode.

## Enrollment scope

Another authoring-only `.flow` setting, `enroll`, decides who a workflow enrolls **when it activates**. `enroll forward` (or a forward-only trigger's default) enrolls only contacts who match from activation onward. `enroll existing [since <duration>]` additionally **back-enrolls** contacts who already match, via a one-time backfill that runs once on activation. See [Enrollment scope](https://help.sendops.dev/workflows/triggers#enrollment-scope) for the full behavior and per-trigger defaults.

This is visible through the runs API: activating an `enroll existing` workflow produces a **burst of runs** whose enrollment was the backfill, all timestamped around activation rather than trailing live trigger activity — worth accounting for when you monitor run volume or drive analytics off enrollment time.

## Analytics: exits and funnel

Two aggregate endpoints give you the reporting views the dashboard's analytics tab renders from:

- [`GET /v1/workflows/{id}/exits`](/api-reference/endpoints/workflows/get-workflow-exits) — terminal-outcome counts grouped by `event_type` (`exited` · `completed` · `failed`) and, for named exits, `exit_kind`/`exit_name`. This is the conversion read model: how many contacts left the workflow, and through which door.
- [`GET /v1/workflows/{id}/funnel`](/api-reference/endpoints/workflows/get-workflow-funnel) — per-node aggregates (`runs` = distinct journeys through the node, `entries` = total passes, repeats included) for one definition version, plus a `sends[]` breakdown per send-node by outcome (`sent` · `filtered` · `failed` · `deferred`). Defaults to the current version; pin an older one with `?version=`.


  A workflow edit bumps `current_version`, and node paths can shift between versions. The funnel overlay defaults to the current version so it always matches what's live; pass `?version=` explicitly to compare an older shape (e.g. before/after a workflow change).


## The per-run timeline

[`GET /v1/workflows/{id}/runs/{runId}/timeline`](/api-reference/endpoints/workflows/get-workflow-run-timeline) is the full, ordered step history for **one** contact's run — the same detail the dashboard's run-inspector panel shows. Each event carries a `node_path`, an `event_type`, and `occurred_at`, plus `repeat_iteration`/`split_arm` when the node was a loop or a branch.

`event_type` values: `enrolled`, `step_entered`, `wait_started`, `wait_met`, `wait_timed_out`, `send_filtered`, `send_held`, `send_skipped`, `send_failed`, `exited`, `completed`, `failed`.

The three "send didn't happen" events tell a different story each:

| Event | Meaning |
|---|---|
| `send_filtered` | The send was gated out by a consent/frequency rule. Carries `filter_tier` ([`frequency_cap`](https://help.sendops.dev/workflows/sends-and-approval#frequency-cap) \| `account` \| `topic`) and `filter_reason` (`frequency_cap` \| `suppressed` \| `unsubscribed_all` \| `opted_out`) — exactly which gate fired and why. `frequency_cap` applies even to `transactional` sends — see [Consent & lifecycle mail](https://help.sendops.dev/sending-email/consent-and-lifecycle#two-consent-lanes-marketing-and-transactional). |
| `send_held` | `require_send_approval` is set on the workflow; the send is queued for manual review rather than dispatched. |
| `send_skipped` | The send step itself decided there was nothing to do (e.g. a conditional send whose condition didn't match). |





## Dry-run: simulate before you promote

[`POST /v1/workflows/{id}/dry-run`](/api-reference/endpoints/workflows/dry-run-workflow) evaluates one **existing** contact (by `contact_id`) against the workflow's **current** definition and returns the step-by-step path they'd take — `node_path`, `node_type`, `action` (e.g. `would-send`, `waited`, `matched-branch`, `exited`), and an optional `detail`. Nothing is sent and no run is enrolled; it's a pure simulation.

This is the endpoint to call from CI before promoting a workflow change — pick a handful of representative sample contacts in a staging org and dry-run each one against the edited definition to confirm the routing and send steps look right, with no risk of mailing anyone or creating real run state.

Returns `422` if the workflow's `.flow` source doesn't currently parse, and `404` if the `contact_id` doesn't exist in the org.





## Scopes

| Scope | Grants | PII |
|---|---|---|
| `api.workflows.view` | `GET /v1/workflows`, `/v1/workflows/{id}`, `/v1/workflows/{id}/runs`, `/v1/workflows/{id}/exits`, `/v1/workflows/{id}/funnel`, `/v1/workflows/{id}/runs/{runId}/timeline`, and `POST /v1/workflows/{id}/dry-run` | **Some** — runs and timelines expose `contact_id` (not email); resolve identities separately under `api.contacts.view`. |

There is no `api.workflows.manage` scope. Dry-run is a `POST`, but it persists nothing and sends no mail — it's a read-shaped simulation, which is why the whole surface, dry-run included, sits under `.view`.

## Full endpoint reference

This page is conceptual — request/response schemas, every field, and every error response live on the auto-generated endpoint pages under the **Workflows** tag, linked throughout this page (e.g. [list workflows](/api-reference/endpoints/workflows/list-workflows), [get workflow](/api-reference/endpoints/workflows/get-workflow)).