Flows API
Build, check, rehearse and ship a flow without a deploy. Full parity with the UI — this is the API the CLI and MCP server reuse.
All endpoints need a bearer token (see
Overview & auth). Flows are addressed by
id or key, so an agent that wrote
welcome_series doesn't have to look up a number to edit it.
The working loop
GET /api/v1/emailsand/sequencesto resolve the IDs the definition needs.POST /api/v1/flowsto create a draft, orPATCHan existing one.POST .../validateuntil it comes back clean. A definition can be saved while it still has problems, which is what makes iterating possible.POST .../dry_runagainst a real person to see what would happen.POST .../activate. This refuses on errors and records a version automatically.
Endpoints
| Method & path | What it does |
|---|---|
GET /api/v1/flows | Every flow with its trigger summary, step count, and how many people are inside. |
POST /api/v1/flows | Create a draft. Body: key, name, run_policy, definition. |
GET /api/v1/flows/:id_or_key | The full definition, every step's ID and one-line summary, live per-step counts, and the current validation result. |
PATCH /api/v1/flows/:id_or_key | Update name, run_policy, or definition. |
DELETE /api/v1/flows/:id_or_key | Only while nobody has ever run through it. Otherwise 409 — pause it instead, so their history survives. |
POST .../validate | Check a definition without saving. Pass definition to check an unsaved one, or omit it to check the saved one. |
POST .../dry_run | Rehearse against a real person. Zero side effects. |
POST .../activate | Go live. 409 with the validation attached if it isn't clean. |
POST .../pause | Freeze the flow and hold its queued emails. |
GET .../versions | Every checkpoint and activation version. |
POST .../versions | Save a checkpoint. Body: name, description. |
POST .../versions/:version_id/rollback | Restore a version. Re-validated first when the flow is live. |
Lookups
Read-only, so a definition can reference real records instead of guesses.
| Path | Returns |
|---|---|
GET /api/v1/emails | Active emails with labels and where each is used. Supports ?q= and ?label=. |
GET /api/v1/sequences | Sequences with their steps, so you can see what a start_sequence step would actually do. |
GET /api/v1/tags | Tags with people counts. Tags are created on demand, so this is for spelling rather than permission. |
GET /api/v1/products | Product keys for money conditions. |
GET /api/v1/queue | What's about to be sent, what's holding, and why. Filter with ?status= or ?person=. |
Creating a flow
curl -X POST https://your-instance.com/api/v1/flows \
-H "Authorization: Bearer $MIMEO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"key": "trial_journey",
"name": "Trial journey",
"run_policy": "single_run",
"definition": {
"trigger": {
"event": "signed_up",
"match": { "all": [{ "detail": "plan", "op": "exact", "value": "trial" }] }
},
"nodes": [
{ "id": 1, "slug": "check-member", "name": "Already a member?", "type": "if_else",
"condition": { "type": "has_tag", "tag": "member" },
"then": [
{ "id": 2, "slug": "member-exit", "name": "Done — already a member", "type": "exit_flow" }
],
"else": [] },
{ "id": 3, "slug": "start-welcome", "name": "Start the welcome sequence",
"type": "start_sequence", "sequence_id": 3 },
{ "id": 4, "slug": "wait-buffer", "name": "Quiet buffer", "type": "delay",
"duration": { "setting": "quiet_buffer_days" } },
{ "id": 5, "slug": "wait-for-visit", "name": "Wait for a pricing visit", "type": "gate",
"wait_for": { "event": { "name": "visited_pricing", "occurrence": "next_occurrence" } },
"expires_after": { "amount": 14, "unit": "days" },
"met": [],
"expired": [
{ "id": 6, "slug": "quiet-exit", "name": "Done — went quiet", "type": "exit_flow" }
] },
{ "id": 7, "slug": "send-pitch", "name": "Send the pitch", "type": "send_email", "email_id": 12 }
]
}
}'
How the definition is shaped
Order is the routing. Steps run in the order they appear in
nodes — the first one is where a person starts, and there are no
pointers between steps to keep in sync.
Branches nest, and both branching types have the same shape. A
branch carries exactly two arms, each a plain array of steps on the step itself:
then and else on an if_else,
met and expired on a gate that has an
expires_after. A gate with no expires_after never gives
up, so nobody can reach an "expired" path and it carries no arms at all — it is a
plain wait.
When an arm runs out, the person carries on to the step after the branch —
so an empty arm means "this side does nothing extra", both arms meet again below,
and an exit_flow at the end of an arm is how you stop the run on that
side for good. Running off the end of the top-level array completes the run.
Every step has an id, a slug and a name. The id is a
whole number assigned once and never reused — it is what the runs of people
currently standing on a step point at, so renumbering one moves people. The
slug and name are yours to change freely. Together the id
and slug make the step's handle (7-send-pitch) and its step ID
(trial_journey.7-send-pitch), which is what appears in the UI, logs and
traces.
You may leave id, slug and name out and they
will be filled in on save — ids continue from the flow's counter, and the name
falls back to the step's type. Supply them when you want to be sure which step is
which across two writes.
Step shapes, the condition vocabulary, and what each step does are all covered in the flows guide.
Validation errors are written to be acted on
Every problem names the step, the bad value, and the fix — so an agent that reads the error can usually repair the definition on its own.
{
"valid": false,
"errors": [
{
"path": "nodes[4].email_id",
"code": "email_unknown",
"node": "7-send-pitch",
"severity": "error",
"message": "7-send-pitch sends email #99, which does not exist. Look up a real one with GET /api/v1/emails."
}
],
"warnings": [
{
"path": "nodes[3].expires_after",
"code": "gate_no_expiry",
"node": "5-wait-for-visit",
"severity": "warning",
"message": "5-wait-for-visit has no \"expires_after\", so a person waits here indefinitely until the gate opens and the gate has no paths of its own. Add an \"expires_after\" duration if they should give up after a while."
}
]
}
Errors block activation; warnings never do. A step that sends a missing email would fail for real people. A gate with no expiry is a choice.
The checks run in four passes, so a later one can be trusted once the earlier one is clean:
- Shape — every step has an id, a name, a slug and a known type, plus the fields that type requires, and every branch arm is an array of steps.
- Unique ids — an id addresses a step for the whole life of the flow, so no two steps can share one.
- References — every email, sequence, field, product, flow, segment, named duration and predicate exists. An email that's archived is an error. A tag that doesn't exist yet is a warning, since tags are created on demand.
- Dead code — a step sitting after an
exit_flowin the same list is an error, because nobody can ever reach it.
Dry run
curl -X POST https://your-instance.com/api/v1/flows/trial_journey/dry_run \
-H "Authorization: Bearer $MIMEO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"person": "ada@example.com",
"event": { "name": "signed_up", "details": { "plan": "trial" } }
}'
Pass person as an id or an email address. Pass a
definition to rehearse something you haven't saved. The response
reports whether the trigger matched and then one entry per step, each carrying
its step ID, what would happen, and the actual values behind every condition.
It stops at the first place the person would be waiting.
Nothing is written. No run is created, no email is queued, no tag is added. It's a separate read-only walk through the definition, sharing the engine's evaluators so a trace and a real run can't disagree.
Live pages update themselves
A change pushed through this API appears on an open flow page, queue, or sequence page without a refresh. There's no extra call to make — editing the flow is what signals it.