Theme

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

  1. GET /api/v1/emails and /sequences to resolve the IDs the definition needs.
  2. POST /api/v1/flows to create a draft, or PATCH an existing one.
  3. POST .../validate until it comes back clean. A definition can be saved while it still has problems, which is what makes iterating possible.
  4. POST .../dry_run against a real person to see what would happen.
  5. POST .../activate. This refuses on errors and records a version automatically.

Endpoints

Method & pathWhat it does
GET /api/v1/flowsEvery flow with its trigger summary, step count, and how many people are inside.
POST /api/v1/flowsCreate a draft. Body: key, name, run_policy, definition.
GET /api/v1/flows/:id_or_keyThe 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_keyUpdate name, run_policy, or definition.
DELETE /api/v1/flows/:id_or_keyOnly while nobody has ever run through it. Otherwise 409 — pause it instead, so their history survives.
POST .../validateCheck a definition without saving. Pass definition to check an unsaved one, or omit it to check the saved one.
POST .../dry_runRehearse against a real person. Zero side effects.
POST .../activateGo live. 409 with the validation attached if it isn't clean.
POST .../pauseFreeze the flow and hold its queued emails.
GET .../versionsEvery checkpoint and activation version.
POST .../versionsSave a checkpoint. Body: name, description.
POST .../versions/:version_id/rollbackRestore a version. Re-validated first when the flow is live.

Lookups

Read-only, so a definition can reference real records instead of guesses.

PathReturns
GET /api/v1/emailsActive emails with labels and where each is used. Supports ?q= and ?label=.
GET /api/v1/sequencesSequences with their steps, so you can see what a start_sequence step would actually do.
GET /api/v1/tagsTags with people counts. Tags are created on demand, so this is for spelling rather than permission.
GET /api/v1/productsProduct keys for money conditions.
GET /api/v1/queueWhat'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:

  1. 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.
  2. Unique ids — an id addresses a step for the whole life of the flow, so no two steps can share one.
  3. 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.
  4. Dead code — a step sitting after an exit_flow in 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.