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}/test/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, then test it and POST …/activate when the output looks right.
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.
form_submitform_id (optional)optionalSubmitting a signup form; double-opt-in forms fire on confirmation. Omit form_id to catch any form in this project. If supplied, it currently requires the form's internal UUID, not its public frm_… ID. The Forms API returns the public ID, so API-only setups should omit this filter unless they already have the internal UUID.
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.
A supplied list_id, segment_id, campaign_id, or form_id must belong to this project; an unknown or cross-project reference is a 400.
For form-driven welcome sequences, use { "type": "form_submit" }. It also covers existing contacts submitting a form. Double-opt-in submissions wait for confirmation before firing the form trigger.
Before you build one
Start with GET /v1/me using either key scope to see the selected project, email confirmation, verified domains and footer. The account-level project endpoints below require an all-access key. These setup checks do not bypass quota, suspension, consent or abuse checks at send time.
Create with {"status":"draft"} until the content is approved: omitting status creates an active flow. Test sends go to real recipients; use only a mailbox you control, then activate deliberately.
A flow sends real email to real people, repeatedly, with nobody watching. Four checks first — one request each, and skipping them is how a welcome series goes out from the wrong address with no way to unsubscribe.
- Know which project you're in.
GET /v1/projectslists them flat with the workspace each belongs to. A workspace and its first project share a name by default, so read theobjectfield rather than guessing. - Give the project a real postal address.
GET /v1/projects/{id}returnsemail_footer. Ifis_your_own_addressis false, every email prints Email Bump's address as the sender's. Set yours withPATCH /v1/projects/{id}. - Have a verified sending domain. Mail from an unverified domain doesn't land anywhere good.
- Set the sender on every send step.
from_emailandfrom_nameare optional in the schema, but set them explicitly in your request — without them the email goes from the project's verified domain with no display name, or from the shared domain if it has verified none.
Every flow email carries a CAN-SPAM footer and a one-click List-Unsubscribe header: the opt-in reason, the sender's physical address, and an unsubscribe link tied to that contact. Don't add your own — yours won't be wired to the suppression list.
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 text (a hand-written text/plain part — without it one is generated from the HTML, so the send is multipart either way), 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 field a step doesn't take is a 400 naming the valid ones — same contract as the flow's root, so a typo'd preheader can't silently vanish.
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.
mjmlstringoptionalBody override as MJML instead, compiled first. One of html or mjml, not both.
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, last_name, country, city, company, phone, subscribed, email_status, created_at. Ops: eq, neq, contains, is_set, is_not_set. Three are special: subscribed takes eq with a boolean, email_status takes eq/neq, and created_at takes in_last_days / not_in_last_days with a number of days.
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.
Test a flow
POST /v1/flows/{id}/test sends each of the flow's send steps to one address — without activating the flow or enrolling anyone, so it works on a draft. Rendering goes through the real flow pipeline: Liquid, preview text, the marketing footer with its unsubscribe link, and the generated text part. That's what a subscriber receives, which sending the same HTML through POST /v1/emails (transactional, no footer) can't show you.
curl -X POST https://emailbump.com/api/v1/flows/FLOW_ID/test \
-H "Authorization: Bearer ebk_your_key" \
-H "Content-Type: application/json" \
-d '{ "to": "[email protected]" }'Body parameters
tostringrequiredWhere the rendered emails go. Subjects arrive prefixed with [Test].
contact_idstringoptionalRender as this existing contact — real personalization and a working unsubscribe link.
contact_emailstringoptionalSame, by address. Without either, merge fields render as an empty contact (your default: fallbacks show).
Test sends are tagged stream: test, so they stay out of the flow's metrics. They do count toward your plan's monthly email allowance.
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 and its steps and returns 204 — but only if nobody has ever been through it. Once a single contact has been enrolled the flow is permanent, and the call returns 409.
Delivery events carry a flow id but survive the flow, so deleting one that has run would leave every open and click it produced counted in your totals with nothing to attribute them to — and the enrolment history, the record of who received what, would go with it. Pausing stops new contacts entering, lets the ones inside finish, and keeps the reporting readable. In the dashboard the delete option only appears on flows that have never enrolled anyone.
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": "[email protected]" }'{ "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": "[email protected]",
"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.