Workflows
A Drip Workflow is a multi-step lifecycle automation written in SendFlow, the workflow language: 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. The definition is a .flow source. You can author it in the dashboard’s canvas editor, over this API, or in your own repository and push it with POST /v1/import — all three write the same row through the same services.
workflow "Post-purchase" v1 {
enter on activity.purchase
reentry once
send "thank-you" transactional
wait 3d
send "cross-sell" via topic "product-updates"
} The full language reference lives at SendLang
SendFlow has its own home — sendlang.com/docs/sendflow — where the complete grammar, every statement, and the parser are documented, alongside the query language it embeds, SendQL. For how workflows behave in the product, see Drip Workflows in the main docs.
A step can also reach outside SendOps. call webhook "<key>" [as "<label>"] { failed: … } POSTs a signed envelope about the contact to an endpoint you configured in the dashboard, waits for the answer, and forks down its failed: arm if the answer is not a 2xx. The statement’s syntax is in the SendFlow reference; the wire contract your endpoint implements — the payload, the headers, the signature, what each response code does to the run — is Workflow webhook.
workflow "Cart abandonment" v3 {
enter on activity.cart_abandoned
call webhook "mobile-push" as "cart-reminder" {
failed: send "cart-reminder" via topic "marketing"
}
} Two scopes gate the surface. api.workflows.view reads: list and inspect definitions, page through a workflow’s runs, pull aggregate analytics (exit reasons, per-node funnel), read one contact’s step-by-step run history, lint a .flow source with POST /v1/workflows/validate, and dry-run the current definition against a real contact without enrolling them. api.workflows.manage writes: create, edit, archive, activate, pause, go live from a shadow, set the send-approval gate, and restore a version. See Authoring a workflow.
The `.flow` source is a manage-scope read
The collection and detail reads return identifying metadata — name, key, status, send_mode, current_version, run_counts. The source and content_hash are withheld from a api.workflows.view caller and returned to one holding api.workflows.manage: the scope that can rewrite the definition is the scope that gets to read it.
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 and every call webhook it makes 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. |
send_mode | live, or shadow — ACTIVE and enrolling real contacts on the real clock, but every externally visible effect recorded on the run timeline instead of performed. See Shadow mode. |
source / content_hash | The .flow program and its optimistic-concurrency token. Returned only to a caller holding api.workflows.manage. |
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, and in shadowshadowed·shadowed_filtered) and awebhooks[]breakdown percall webhooknode. Defaults to the current version; pin an older one with?version=.
webhooks[] carries node_path, result and count, the same shape as sends[]:
result | Meaning |
|---|---|
delivered | The endpoint answered 2xx and the run fell through to the next statement. |
failed | It answered no, could not be reached, or the 30-minute deadline passed — the run took the failed: arm. |
shadowed | The workflow is running in shadow mode and the call was recorded rather than made. A rehearsal is deliberately never counted as delivered. |
It is a sibling of sends[] rather than a set of extra sends[].result values, because a call reaches an external system rather than a mailbox and the counts answer a different question. The array is always present, and empty for a workflow with no call webhook step.
engagement[] reports what happened to each send node’s email after SES accepted it. Each entry carries node_path and five counts:
| Field | Meaning |
|---|---|
delivered | Messages SES delivered to the recipient’s mail server. |
unique_opens | Messages opened at least once. |
unique_clicks | Messages with at least one tracked link click. |
bounces | Messages that bounced. |
complaints | Messages the recipient marked as spam. |
Every figure counts distinct messages, so a message opened twice is one unique open. It covers every send the node has made, including sends from before this field existed. A send node with no delivery, open, click, bounce or complaint yet is absent from the array. Like sends[], it is keyed by node_path and is not narrowed by ?version=.
Two different kinds of delivered
engagement[].delivered means an email reached the recipient’s mail server. webhooks[] with result: delivered means a call webhook endpoint answered 2xx. They count different things and never overlap.
To see the individual messages a workflow sent, filter GET /v1/messages by sender:
curl 'https://api.sendops.dev/v1/messages?source_type=workflow&source_id=<workflow id>' \
-H "Authorization: Bearer $SENDOPS_API_KEY" Each message carries a source (type, id, name) naming what sent it. On the same message, template is the SES template name (the value the template filter matches) and template_name is the template’s display name.
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.
The run kinds: enrolled, step_entered, wait_started, wait_met, wait_timed_out, send_filtered, send_held, send_skipped, send_failed, send_deferred, exited, completed, failed.
The call webhook kinds — see Workflow webhook for the contract behind them:
| Event | Meaning | detail |
|---|---|---|
webhook_called | The call was dispatched. | {key, label, delivery_id} |
webhook_delivered | The endpoint answered 2xx; the run fell through. | {key, label, status_code, attempts, response_time_ms} |
webhook_failed | It answered no, could not be reached, or the 30-minute deadline passed — the run takes the failed: arm. | {key, label, status_code, attempts, failure_reason}, with status_code absent when nothing answered |
webhook_held | Parked in the approvals queue, because the workflow requires review before sending. | {key, label} |
webhook_skipped | A reviewer skipped it. The run continues after the whole statement, not into the failed: arm. | {key, label} plus the reviewer’s detail |
webhook_shadowed | The workflow is running in shadow; the call was recorded, not made. | {key, label} on this API — see below |
A step writes webhook_called when it is dispatched and then exactly one of webhook_delivered, webhook_failed or webhook_skipped; webhook_held appears in between when the gate is on.
On the `webhook_*` kinds, `detail` is JSON, not a sentence
Everywhere else detail is human-readable free text. On these six it is a JSON object serialised as a string, so parse it rather than displaying it. The keys are in the table above.
`webhook_shadowed` is redacted on this API
A shadow run records the envelope it would have POSTed, and that envelope carries every one of the contact’s attributes. api.workflows.view is deliberately a scope without contact data — a run does not even carry the recipient’s email — so this surface reduces the detail to {key, label}, both of which are authored in the .flow source. Read the recorded envelope in the dashboard. The reduction fails closed: a detail that cannot be parsed is dropped rather than passed through.
The shadow kinds, written by a workflow running in shadow mode: send_shadowed, attr_set_shadowed, list_add_shadowed, webhook_shadowed.
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 activate
POST /v1/workflows/{id}/dry-run evaluates one existing contact (by contact_id — a contact id, your own external_id, or an email address) 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, would call webhook, waited, matched-branch, exited), and an optional detail. Nothing is sent and no run is enrolled; it’s a pure simulation.
node_type covers every statement kind: send, delay, wait_until, wait_for, branch, split, hold_out, repeat, set, add_to_list, call_webhook, exit and end.
This is the endpoint to call from CI before activating 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. Addressing the contact by email or external id is a contacts read, so it needs api.contacts.view on top of the workflow scope — without it those two forms are 403 invalid_scope, which keeps a workflows-only key from using this route to discover addresses. The ?contact_id= filter on enrolment decisions takes the same three handles under the same rule, as do the List and Segment membership checks.
A `call webhook` step is simulated, never called
No request leaves SendOps on a dry-run, so an endpoint is neither hit nor needed. By default the trace assumes the call succeeded and shows it falling through. Pass webhook_outcome: "failed" in the body to trace the failed: arm instead, and you can rehearse the fork without an endpoint that fails on demand. The field takes ok (the default) or failed; anything else is 422. A key naming no endpoint in the org traces as unknown webhook endpoint and forks down the failed: arm regardless, because that is what a live run against a stale pinned version does.
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": "jane@example.com", "webhook_outcome": "failed"}' {
"contact_id": "0192c0de-...",
"steps": [
{ "node_path": "0", "node_type": "trigger", "action": "matched-branch" },
{ "node_path": "0.1", "node_type": "wait", "action": "would wait", "detail": "2 days (virtual clock advanced)" },
{ "node_path": "0.1.send", "node_type": "send", "action": "would-send", "detail": "template welcome-email v4" },
{ "node_path": "0.2", "node_type": "call_webhook", "action": "would call webhook", "detail": "mobile-push as cart-reminder (call stubbed — no request sent)" }
]
} Authoring a workflow
With api.workflows.manage you can author workflows over the API. Wherever a workflow route takes {id} it accepts the workflow’s org-unique key as readily as its UUID — on reads and writes alike, so GET /v1/workflows/welcome-drip and PUT /v1/workflows/welcome-drip address the same journey. (Before 14 September 2026 only the writes resolved a key.)
| Operation | Endpoint | Notes |
|---|---|---|
| Validate | POST /v1/workflows/validate | Parses a .flow source against your segment, template, topic and attribute names and stores nothing. Every finding carries a line and column. Always 200 — valid: false is an answer, not a failure. Sits on .view, so an author linting in CI needs no permission that can arm a live journey. |
| Create | POST /v1/workflows | Body: key, name, source. 201. A key already in use is 409 conflict. A created workflow is a draft — it enrols nobody until it is activated. |
| Edit | PUT /v1/workflows/{id} | A partial write: an omitted field keeps its value, so a rename need not resend the definition. key is immutable. In-flight runs are unaffected — each stays pinned to the version it enrolled under. |
| Activate | POST /v1/workflows/{id}/activate | Arms a draft or paused workflow. Any other status is 409 conflict, and the stored source must validate clean. mode is live (default) or shadow. |
| Pause | POST /v1/workflows/{id}/pause | Stops new enrolment. Live runs freeze where they are and resume in place on the next activate — fully reversible. |
| Archive | DELETE /v1/workflows/{id} | Cancels every live run with exit_kind: workflow_archived, and re-activating does not put anybody back. Answers 200 with the archived workflow, since its analytics stay readable. Use pause when you want journeys to resume later. |
| Send-approval gate | PUT /v1/workflows/{id}/send-approval | Turns the gate ON. |
| Version history | GET …/versions · …/diff · POST …/restore | Every update snapshots the previous definition first, so a bad edit is recoverable. |
A source that does not validate is still a `201`
The row is stored with status: "invalid" and invalid_reason, and the response carries every positioned finding in diagnostics — a definition is never silently dropped, and you can see both that your work landed and why it cannot run yet. 422 is reserved for a request that could not be stored at all: no key, no name, no source, or a key that is not a slug.
Pass expected_content_hash — the content_hash a previous read returned — on an edit and a concurrent write is refused with 409 conflict carrying the current hash, rather than being overwritten. Omit it and the last writer wins.
The send-approval gate only goes one way over the API
Activation forces the gate ON when the stored source contains a step that sends email or calls a webhook at any depth — a send or a call webhook, inside an if, a split, a repeat, a wait … timeout or another call’s failed: arm — so every batch of email and every call waits for a person. PUT /v1/workflows/{id}/send-approval with enabled: true succeeds; enabled: false is refused with 403 send_approval_dashboard_only, here and on the MCP surface alike. A credential that could clear the gate could send on its own authority, so a person turns it off in the dashboard or nobody does.
A flow that reaches nothing outside SendOps — whose only steps are add to list or set attr.x — has its require_send_approval left exactly as it was, including a false a person set in the dashboard: it produces no batch for anybody to approve, and a gate would park it behind a queue nothing will ever drain. The activate response says which half applied, in require_send_approval and send_steps (the count of steps that send email or call a webhook). A source that no longer parses forces the gate on and returns no send_steps at all, which is the conservative direction.
call webhook counts because it hands the contact’s id, email and every attribute to a system outside SendOps, which may then message them itself. A webhook-only flow is a flow with something to approve.
Activation and archiving are classified destructive and are refused for a sk_test_ credential: enrolment cannot be undone — a contact enrolled into a journey is in it, and pausing afterwards stops the journey stepping rather than un-enrolling anybody.
Shadow mode: rehearse on real data
POST /v1/workflows/{id}/activate with mode: "shadow" is the staging state. The workflow goes active and enrols real contacts through the real triggers on the real clock, but every externally visible effect — a send, a set, an add to list — is recorded on the run timeline instead of performed. Nothing leaves.
cohort: {list: "<key>"} bounds who may enrol into the rehearsal, and is a 422 with mode: "live": the two mean opposite things about who is about to be enrolled. The send-approval gate is neither read nor written for a shadow — a rehearsal produces no batch for anybody to approve.
Read what it recorded with GET /v1/workflows/{id}/enrollment-decisions and the funnel, then POST /v1/workflows/{id}/go-live arms the same definition for real with no re-authoring: it ends the shadow runs with exit_kind: "shadow_ended", flips the mode, and re-registers the backfills so the back catalogue enrols for real rather than being rehearsed. A contact the shadow observed can enrol again even under reentry once — the re-entry guards ignore shadow runs entirely.
Scopes
| Scope | Grants | PII |
|---|---|---|
api.workflows.view | GET /v1/workflows, /v1/workflows/{id}, /runs, /exits, /funnel, /runs/{runId}/timeline, /enrollment-decisions, the version reads, plus POST /v1/workflows/{id}/dry-run and POST /v1/workflows/validate | Some — runs and timelines expose contact_id (not email); resolve identities separately under api.contacts.view. |
api.workflows.manage | Create, edit, archive, activate, pause, go-live, set the send-approval gate, restore a version — and the source / content_hash fields on a read | Some — same as above, plus the definition itself. |
Dry-run and validate are POSTs, but they persist nothing and send no mail — they are read-shaped simulations, which is why they sit under .view rather than .manage.
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).