Project API

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

GET/v1/flows
POST/v1/flows
GET/v1/flows/{id}
PATCH/v1/flows/{id}
DELETE/v1/flows/{id}
POST/v1/flows/{id}/activate
POST/v1/flows/{id}/pause
POST/v1/flows/{id}/enroll
GET/v1/flows/{id}/enrollments

A 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.

Welcome series
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

namestringrequired

Internal name, shown in the dashboard.

triggerobjectrequired

Who enters the flow. See Triggers.

stepsarrayoptional

What happens to them. See Steps. Required unless the flow is a draft.

statusstringoptional

active (default) or draft.

reenrollmentstringoptional

Can a contact go through again? not_while_active (default), once ever, or every_trigger.

entry_filterobjectoptional

A condition the contact must also match to enter.

goalobjectoptional

A condition that removes the contact from the flow when they match it — a purchase ending a nurture sequence, say.

201 created
{
  "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 fieldsoptional

Every new contact, however they were created — API, import, or form.

eventevent_keyoptional

A tracked behaviour, e.g. { "type": "event", "event_key": "Purchased" }. Send events with the Events API.

list_joinlist_idoptional

Joining a list. Omit list_id to catch every list.

email_eventevent, campaign_idoptional

Email engagement — open, click, delivery, bounce, complaint — optionally on one campaign.

segment_entersegment_idoptional

Becoming a member of a saved segment. Evaluated about once a minute.

date_basedattribute, offset_daysoptional

A date attribute coming up, e.g. renewal_date with offset_days: -3 for three days before.

manualno fieldsoptional

Nobody enters on their own — you enroll them with the enroll endpoint.

Re-enrollment is a separate question

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_idoptional

Send 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 | daysoptional

Pause before the next step. Combine units freely — { "days": 1, "hours": 12 }.

wait_untiltime, weekday, timezoneoptional

Hold 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_*, timeoutoptional

Wait for a tracked event. See Branching and waiting.

branchcondition, yes, nooptional

Split the path on what's true about the contact.

splitbranchesoptional

A weighted random split for testing, e.g. two branches with weight 1 each.

add_list / remove_listlist_idoptional

Change list membership as the contact passes through.

set_attributekey, valueoptional

Write a custom attribute on the contact, e.g. { "key": "onboarded", "value": true }.

notifyheadline, messageoptional

Notify the project's owners in the dashboard.

webhookurl, methodoptional

POST the contact to your own endpoint, with retries.

exitno fieldsoptional

End 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.

Subject-line test, 60/40
{
  "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

idstringoptional

Send back the id GET gave you when you edit the flow, so the variation keeps its stats. Minted for you when absent.

labelstringoptional

Name for the variation in reporting, e.g. A / B.

subjectstringoptional

Subject override.

preview_textstringoptional

Inbox preview override.

htmlstringoptional

Body override — a different email, not just a different subject.

split_percentnumberoptional

Share of contacts, 1–99. Set it on every variation or none (an even split).

Unlike a campaign test, there's no winner send

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.

Branch on a contact attribute
{
  "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, valueoptional

A 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, valueoptional

A custom attribute. Same ops, plus gt / gte / lt / lte for numbers.

eventkey, performed, within_days, min_countoptional

Did they do the thing? { "type": "event", "key": "purchased", "performed": false, "within_days": 30 }.

emailevent, performed, within_daysoptional

Email engagement. Add "scope": "flow_last" to ask about the last email this flow sent them.

revenueop, amount, event_key, within_daysoptional

Total tracked revenue, e.g. spent more than 500 in the last 90 days.

Did they open the email this flow just sent?
{
  "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.

Abandoned checkout
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" }
    ]
  }'
Every path is a tree

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
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.

Rename and slow the first email down
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
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
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" }'
200 response
{ "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=.

200 response
{
  "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.