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.
Definitions are metadata only
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. |
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.
curl -s 'https://api.sendops.dev/v1/workflows/0192f1a3-.../runs?status=active&limit=50' \
-H "Authorization: Bearer sk_live_..." {
"data": [
{
"id": "0193a2b1-...",
"workflow_id": "0192f1a3-...",
"definition_version": 3,
"contact_id": "0192c0de-...",
"status": "active",
"node_path": "0.1.wait",
"trigger_cause": "segment_entered",
"trigger_source_key": "eu-pro-users",
"next_action_at": "2026-07-10T09:00:00Z",
"started_at": "2026-07-09T09:00:00Z",
"completed_at": null
}
],
"counts": { "active": 812, "completed": 4310, "exited": 96, "failed": 3 }
} 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). |
Reentry once is the boundary against duplicate sends, not activity dedup
For enter on activity.<name> triggers specifically: reentry once is what actually prevents a duplicate activity (a retried POST /v1/activities call, a dedup check that failed open, a re-emitting sync) from producing a duplicate send. Activity-level dedup (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 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— terminal-outcome counts grouped byevent_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— per-node aggregates (runs= distinct journeys through the node,entries= total passes, repeats included) for one definition version, plus asends[]breakdown per send-node by outcome (sent·filtered·failed·deferred). Defaults to the current version; pin an older one with?version=.
Funnel is versioned
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 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 | 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. |
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). |
curl -s 'https://api.sendops.dev/v1/workflows/0192f1a3-.../runs/0193a2b1-.../timeline' \
-H "Authorization: Bearer sk_live_..." {
"run_id": "0193a2b1-...",
"events": [
{ "node_path": "0", "event_type": "enrolled", "occurred_at": "2026-07-09T09:00:00Z" },
{ "node_path": "0.1", "event_type": "step_entered", "occurred_at": "2026-07-09T09:00:00Z" },
{
"node_path": "0.1.send",
"event_type": "send_filtered",
"filter_tier": "topic",
"filter_reason": "opted_out",
"detail": "contact opted out of \"product-updates\"",
"occurred_at": "2026-07-09T09:00:01Z"
}
]
} Dry-run: simulate before you promote
POST /v1/workflows/{id}/dry-run 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.
curl -s -X POST 'https://api.sendops.dev/v1/workflows/0192f1a3-.../dry-run' \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"contact_id": "0192c0de-..."}' {
"contact_id": "0192c0de-...",
"steps": [
{ "node_path": "0", "node_type": "trigger", "action": "matched-branch" },
{ "node_path": "0.1", "node_type": "wait", "action": "waited", "detail": "would wait 2 days" },
{ "node_path": "0.1.send", "node_type": "send", "action": "would-send", "detail": "template welcome-email v4" }
]
} 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, get workflow).