Flows API
Build automated journeys from code — triggers, waits, branches, and splits as a list of steps.
View as Markdown ↗Project key. Acts in the project the key belongs to. An all-access key works too — name the project with an X-Project-Id header. How keys and scopes work.
Endpoints
/v1/flows/v1/flows/v1/flows/{id}/v1/flows/{id}/v1/flows/{id}/v1/flows/{id}/activate/v1/flows/{id}/pause/v1/flows/{id}/enroll/v1/flows/{id}/enrollmentsA flow in one object
A flow is a trigger — who enters — and steps — what happens to them, in order. That's the whole model. Steps run top to bottom until the list ends or the contact exits.
curl -X POST https://emailbump.com/api/v1/flows \
-H "Authorization: Bearer ebk_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome series",
"trigger": { "type": "contact_created" },
"steps": [
{ "type": "send", "subject": "Welcome to Acme",
"html": "<p>Hi {{ contact.first_name | default: \"there\" }}, thanks for joining.</p>" },
{ "type": "wait", "days": 3 },
{ "type": "send", "subject": "Three things to try",
"html": "<p>Here is where most people start…</p>" }
]
}'A new flow is active and enrolling immediately. Pass "status": "draft" to build it without starting it.
Body parameters
namestringrequiredInternal name, shown in the dashboard.
triggerobjectrequiredWho enters the flow. See Triggers.
stepsarrayoptionalWhat happens to them. See Steps. Required unless the flow is a draft.
statusstringoptionalactive (default) or draft.
reenrollmentstringoptionalCan a contact go through again? not_while_active (default), once ever, or every_trigger.
entry_filterobjectoptionalA condition the contact must also match to enter.
goalobjectoptionalA condition that removes the contact from the flow when they match it — a purchase ending a nurture sequence, say.
{
"id": "c4d5e6f7-…",
"name": "Welcome series",
"status": "active",
"trigger": { "type": "contact_created" },
"reenrollment": "not_while_active",
"steps": [ … ],
"created_at": "2026-08-01T09:12:44Z",
"updated_at": "2026-08-01T09:12:44Z"
}GET /v1/flows/{id} gives back the same shape, steps included. A flow drawn on the dashboard canvas can have paths that rejoin, which no step list can express — those come back with "steps": null and are edited in the dashboard.
Triggers
The trigger decides who enters and when. Its fields sit alongside type:
Trigger types
contact_createdno fieldsoptionalEvery new contact, however they were created — API, import, or form.
eventevent_keyoptionalA tracked behaviour, e.g. { "type": "event", "event_key": "Purchased" }. Send events with the Events API.
list_joinlist_idoptionalJoining a list. Omit list_id to catch every list.
email_eventevent, campaign_idoptionalEmail engagement — open, click, delivery, bounce, complaint — optionally on one campaign.
segment_entersegment_idoptionalBecoming a member of a saved segment. Evaluated about once a minute.
date_basedattribute, offset_daysoptionalA date attribute coming up, e.g. renewal_date with offset_days: -3 for three days before.
manualno fieldsoptionalNobody enters on their own — you enroll them with the enroll endpoint.
The trigger says what enrolls someone; reenrollment says whether it can happen again. A birthday flow wants every_trigger; a welcome flow wants once.
Steps
Every step is an object with a type:
Step types
sendsubject + html | mjml | template_idoptionalSend an email. Give it exactly one body: html is your email, sent as you wrote it — the same contract a campaign's html has; mjml is compiled first; template_id renders a saved template. Also takes from_name, from_email, reply_to, preview_text, and variants for an A/B test.
waitseconds | minutes | hours | daysoptionalPause before the next step. Combine units freely — { "days": 1, "hours": 12 }.
wait_untiltime, weekday, timezoneoptionalHold until a local time of day, e.g. "09:30", optionally on a given ISO weekday (1 = Monday). Defaults to the project timezone.
wait_eventevent, timeout_*, timeoutoptionalWait for a tracked event. See Branching and waiting.
branchcondition, yes, nooptionalSplit the path on what's true about the contact.
splitbranchesoptionalA weighted random split for testing, e.g. two branches with weight 1 each.
add_list / remove_listlist_idoptionalChange list membership as the contact passes through.
set_attributekey, valueoptionalWrite a custom attribute on the contact, e.g. { "key": "onboarded", "value": true }.
notifyheadline, messageoptionalNotify the project's owners in the dashboard.
webhookurl, methodoptionalPOST the contact to your own endpoint, with retries.
exitno fieldsoptionalEnd the journey here.
Personalize subjects and bodies with Liquid — {{ contact.first_name }}, with {{ contact.first_name | default: "there" }} for a fallback. Contacts who are unsubscribed or bounced are skipped at send steps automatically.
A/B test an email
Give a send step two or more variants and it becomes a test: each contact is assigned one as they reach the step, and the flow report breaks that step down by variation. Every field is an override — leave it out and the base step's value stands, so a subject-line test is two variations that differ only in subject.
{
"type": "send",
"subject": "Your Acme trial",
"html": "<p>Here's what to try first…</p>",
"variants": [
{ "label": "A", "subject": "Your Acme trial starts now", "split_percent": 60 },
{ "label": "B", "subject": "3 things to try in Acme", "split_percent": 40 }
]
}Variant fields
idstringoptionalSend back the id GET gave you when you edit the flow, so the variation keeps its stats. Minted for you when absent.
labelstringoptionalName for the variation in reporting, e.g. A / B.
subjectstringoptionalSubject override.
preview_textstringoptionalInbox preview override.
htmlstringoptionalBody override — a different email, not just a different subject.
split_percentnumberoptionalShare of contacts, 1–99. Set it on every variation or none (an even split).
A flow's split runs for as long as the flow does — contacts keep arriving, so there is no point at which a winner is picked and mailed to a held-back remainder. Read the split in the flow report and edit the step when you've seen enough.
Branching and waiting
A branch asks a question about the contact and carries on down yes or no. Each path is its own list of steps, nested as deep as you like — so a branch ends the list it's in, and anything that should happen afterwards goes inside the paths.
{
"type": "branch",
"condition": {
"match": "all",
"conditions": [
{ "type": "attribute", "key": "plan", "op": "eq", "value": "pro" }
]
},
"yes": [
{ "type": "send", "subject": "Your Pro features", "html": "<p>…</p>" }
],
"no": [
{ "type": "wait", "days": 2 },
{ "type": "send", "subject": "What Pro adds", "html": "<p>…</p>" }
]
}The condition is a segment definition — the same shape segments use, so anything you can target you can branch on:
Condition types
propertyfield, op, valueoptionalA standard field: email, first_name, country, city, company, phone, subscribed, email_status, created_at. Ops: eq, neq, contains, is_set, is_not_set.
attributekey, op, valueoptionalA custom attribute. Same ops, plus gt / gte / lt / lte for numbers.
eventkey, performed, within_days, min_countoptionalDid they do the thing? { "type": "event", "key": "purchased", "performed": false, "within_days": 30 }.
emailevent, performed, within_daysoptionalEmail engagement. Add "scope": "flow_last" to ask about the last email this flow sent them.
revenueop, amount, event_key, within_daysoptionalTotal tracked revenue, e.g. spent more than 500 in the last 90 days.
{
"type": "branch",
"condition": {
"match": "all",
"conditions": [
{ "type": "email", "event": "open", "performed": true, "scope": "flow_last" }
]
},
"yes": [ { "type": "exit" } ],
"no": [ { "type": "send", "subject": "One more thing", "html": "<p>…</p>" } ]
}A wait_event step waits for something to happen instead of asking whether it already has. The contact carries on with the following steps the moment the event arrives; if it never does, the timeout steps run instead.
curl -X POST https://emailbump.com/api/v1/flows \
-H "Authorization: Bearer ebk_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Abandoned checkout",
"trigger": { "type": "event", "event_key": "Checkout started" },
"steps": [
{ "type": "wait", "hours": 4 },
{ "type": "wait_event", "event": "Purchased", "timeout_days": 2,
"timeout": [
{ "type": "send", "subject": "Still thinking it over?", "html": "<p>…</p>" },
{ "type": "wait", "days": 3 },
{ "type": "send", "subject": "10% off, until Friday", "html": "<p>…</p>" }
] },
{ "type": "set_attribute", "key": "converted_from", "value": "checkout_flow" },
{ "type": "exit" }
]
}'Paths never rejoin: what follows a branch, split, or exit belongs inside the paths that lead there. Sending steps after one of those returns 400 saying so, rather than quietly building something you didn't mean.
Activate, pause, delete
A flow is active, paused, or draft. Pausing stops new enrollments; contacts already inside carry on to the end.
curl -X POST https://emailbump.com/api/v1/flows/FLOW_ID/pause \
-H "Authorization: Bearer ebk_your_key"
curl -X POST https://emailbump.com/api/v1/flows/FLOW_ID/activate \
-H "Authorization: Bearer ebk_your_key"PATCH edits any of the create fields, steps included — a new step list replaces the old one wholesale. Editing a live flow publishes the change immediately; contacts already partway through finish on the version they entered on, so nobody gets a step twice or skips one mid-journey.
curl -X PATCH https://emailbump.com/api/v1/flows/FLOW_ID \
-H "Authorization: Bearer ebk_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome series v2",
"steps": [
{ "type": "wait", "hours": 2 },
{ "type": "send", "subject": "Welcome to Acme", "html": "<p>…</p>" }
]
}'DELETE /v1/flows/{id} removes the flow, its steps, and its enrollment history, and returns 204. Contacts partway through stop where they are, so pause instead if you only want to stop new contacts entering. In the dashboard it's the ⋯ menu on the flow's row under Automations.
curl -X DELETE https://emailbump.com/api/v1/flows/FLOW_ID \
-H "Authorization: Bearer ebk_your_key"Enroll and observe
Put one contact into an active flow yourself — the way to drive a manual flow, and the quickest way to test any other:
curl -X POST https://emailbump.com/api/v1/flows/FLOW_ID/enroll \
-H "Authorization: Bearer ebk_your_key" \
-H "Content-Type: application/json" \
-d '{ "email": "jamie@example.com" }'{ "enrolled": true, "flow_id": "c4d5…", "contact_id": "5fef…" }enrolled: false means the re-enrollment policy or the entry filter turned them away — not an error.
GET /v1/flows/{id}/enrollments shows who is in the flow and where they are, newest first. Filter with ?status= (active, processing, completed, exited, failed) and page with ?limit= and ?offset=.
{
"data": [
{
"id": "0f1e…",
"contact_id": "5fef…",
"email": "jamie@example.com",
"status": "active",
"steps_run": 2,
"next_run_at": "2026-08-04T09:12:44Z",
"last_error": null,
"enrolled_at": "2026-08-01T09:12:44Z",
"completed_at": null
}
],
"total": 1240,
"limit": 25,
"offset": 0
}Per-step performance — sends, opens, clicks, and which way contacts went at each branch — is on the flow's report in the dashboard.