Manage Email Workflows API
Create, read, replace, publish, and delete email workflows from JSON.
Author workflows outside the UI — from a script, a CI job, or an AI agent. A workflow is a JSON document, so it can live in your repository and be reviewed like code.
Endpoints
| Method | Path | Effect |
|---|---|---|
POST | /api/v1/email/workflows | Create. Requires name and a non-empty steps. |
GET | /api/v1/email/workflows/:id | Read one workflow with its full step tree. |
PUT | /api/v1/email/workflows/:id | Full replace. Requires a complete document. |
PATCH | /api/v1/email/workflows/:id | Partial update. Any omitted field is left alone. |
DELETE | /api/v1/email/workflows/:id | Delete the workflow, its steps, and its enrollments. |
GET /api/v1/email/workflows lists workflows. It returns only API request workflows unless you pass ?trigger=all (or a specific trigger).
The workflow document
Steps nest: a condition step carries its own yes and no arrays. Everything else is a flat list executed in order.
{
"name": "Onboarding",
"trigger": "tag_added",
"status": "draft",
"triggerTags": ["signup"],
"triggerTagMode": "all",
"triggerExcludeTags": ["customer"],
"steps": [
{
"kind": "email",
"subject": "Welcome",
"previewText": "Glad you're here",
"contentBlocks": [
{ "type": "heading", "props": { "text": "Welcome" } },
{ "type": "text", "props": { "markdown": "Thanks for signing up." } }
]
},
{ "kind": "delay", "delayMs": 86400000 },
{
"kind": "condition",
"branch": { "condition": "opened_previous" },
"yes": [{ "kind": "action", "action": { "type": "add_tag", "tag": "engaged" } }],
"no": [{ "kind": "email", "subject": "Second try", "contentBlocks": [] }]
}
]
}Workflow fields
| Field | Type | Description |
|---|---|---|
name | string | Required on create. |
trigger | string | manual, contact_created, tag_added, affiliate_customer_created, capture_page_submitted, api_request. Defaults to manual. |
status | string | draft, active, paused. Defaults to draft. |
triggerTags | string[] | Tags a contact must carry to enter. |
triggerTagMode | string | all (default) or any. |
triggerExcludeTags | string[] | Tags that keep a contact out — checked on entry and before every step. |
Step fields
| Field | Applies to | Description |
|---|---|---|
kind | all | email, delay, condition, action. Defaults to email. |
subject | email | Required. Other kinds get a default label. |
contentBlocks | email | Required and non-empty. |
previewText | email | Inbox preview line. |
delayMs | all | Fixed wait before the step runs. Defaults to 0. |
waitUntil | all | { "hour": 9, "dayOfWeek": 1, "timezone": "Europe/Paris" }. Overrides delayMs. Omit dayOfWeek to wait for the next occurrence of that hour on any day. |
branch | condition | Required. See below. |
action | action | Required. { "type": "add_tag" | "remove_tag" | "unsubscribe", "tag": "…" }. tag is required for the two tag types. |
yes / no | condition | Child steps. Only valid on a condition. |
branch.condition is one of opened_previous, clicked_previous, has_tag (needs tag), or day_of_week (needs dayOfWeek 0–6, optional dayComparison of before/on/after).
A workflow is capped at 100 steps.
Create
curl -X POST https://growth.example.com/api/v1/email/workflows \
-H "x-api-key: org_api_key" \
-H "Content-Type: application/json" \
-d @onboarding.json{
"workflow": { "id": "n97…", "name": "Onboarding", "status": "draft", "steps": [] },
"activeEnrollments": 0,
"enrollmentsSampleLimited": false
}Validation errors point at the exact path:
{ "message": "steps[3].no[0].action.tag is required for \"add_tag\"" }Publish or pause
PATCH only the field you want, without resending the tree:
curl -X PATCH https://growth.example.com/api/v1/email/workflows/n97… \
-H "x-api-key: org_api_key" \
-H "Content-Type: application/json" \
-d '{"status":"active"}'Delete
curl -X DELETE "https://growth.example.com/api/v1/email/workflows/n97…?force=true" \
-H "x-api-key: org_api_key"Without force, a workflow with active enrollments returns 409 rather than dropping contacts mid-flow. Send history in Emails is preserved either way.
Error responses
| Status | Meaning |
|---|---|
400 | Invalid JSON body, or a document that failed validation. |
401 | Missing or invalid API key. |
404 | Unknown workflow, or one belonging to another organization. |
409 | Delete refused: contacts are still enrolled. Retry with ?force=true. |
Notes
- Steps are renumbered on every save, in tree order. If a condition uses the legacy
onTrueGoToOrder/onFalseGoToOrderjump targets (which point at absolute step orders), re-check them after a round-trip. - Replacing the tree affects contacts mid-flow. They track their position by step order, so they resume at whatever now sits at that position. The response reports
activeEnrollmentsso you can see when this applies. - The API is stricter than the UI. The editor lets you drop an
add_tagnode and fill the tag in later; the API rejects it, because a workflow that cannot run should not be created from a script. growth-cliwraps all of this:workflows create --file,workflows get --exportfor a round-trip edit,workflows publish. See the growth-cli guide.