# Journeys

Source: https://molted.email/docs/journeys

Build multi-step email sequences with delays, branching, and event-triggered execution.

Journeys are automated multi-step email sequences. Define a series of steps (sends, delays, branches) and Molted Email executes them when a trigger event fires.

On `api.molted.email`, pass `tenantId` (in the body for writes, in the query string for reads); without it a key call fails with `401 missing_tenant_id`. `mailbox.molted.email`, the CLI and the MCP server fill it in. A mailbox-scoped key is refused on every write and on `GET .../runs`.

## Create a journey

```
POST https://mailbox.molted.email/v1/journeys
```

```bash title="CLI"
molted journeys create --name "Onboarding sequence" --trigger-event user_signed_up
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/journeys \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Onboarding sequence",
    "triggerEvent": "user_signed_up"
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `name` | `string` | Yes | Journey name. |
| `triggerEvent` | `string` | Yes | Event that starts the journey for a contact. |
| `triggerConditions` | `object` | No | Only enroll when every key equals the same top-level key of the event payload, e.g. `{ "plan": "pro" }`. CLI: `--trigger-conditions '<json>'`. |
| `exitOnReply` | `boolean` | No | Stop a contact's run once they reply. Defaults to `true`. CLI: `--no-exit-on-reply` to turn it off. See [Stop on a reply](#stop-on-a-reply). |

Every journey is created as `draft` (there is no `status` field on create) and returns `{ "id": "..." }`. Add its steps, then activate it with `PATCH` and `"status": "active"`.

MCP: `create_journey` (`name`, `trigger_event`, `trigger_conditions`, `exit_on_reply`). The journey tools are in the opt-in `journeys` toolset: enable it with `?toolsets=journeys` on the hosted server or `MOLTED_TOOLSETS=journeys` locally (see [MCP server](https://molted.email/docs/mcp-server.md)).

## List journeys

```
GET https://mailbox.molted.email/v1/journeys
```

```bash title="CLI"
molted journeys list
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/journeys" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Each row adds `step_count`, `active_run_count`, `total_run_count`, `agentName` and `mailbox` to the journey's own fields.

## Get journey details

```
GET https://mailbox.molted.email/v1/journeys/:id
```

```bash title="CLI"
molted journeys get JOURNEY_ID
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/journeys/JOURNEY_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The response adds `steps[]` (each with its `template` `{ slug, name, version }` and `active_run_count`), `pendingApproval` (`{ count, batchKey }`), `agentName` and `mailbox`. MCP: `journey_status` (`journey_id`).

## Update a journey

```
PATCH https://mailbox.molted.email/v1/journeys/:id
```

```bash title="CLI"
molted journeys update JOURNEY_ID --name "Updated onboarding" --status active
```

```bash title="curl"
curl -X PATCH https://mailbox.molted.email/v1/journeys/JOURNEY_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Updated onboarding",
    "status": "active"
  }'
```

`status` accepts `active`, `paused` or `archived` (`draft` is a `400`: a journey can't go back to draft). `exitOnReply` (boolean) can be changed too, and applies to runs already in flight. Returns `{ "updated": true }`. MCP: `activate_journey` (`journey_id`, `status`, default `active`, `exit_on_reply`; given only `exit_on_reply`, it changes just that setting and leaves the status alone).

### Journey statuses

| Status | Meaning |
| - | - |
| `draft` | The state every journey is created in. Not yet processing triggers. |
| `active` | Processing triggers and running steps. Steps can't be updated or deleted while active. |
| `paused` | Stops enrolling new contacts. Runs already in flight are parked (their next step is deferred and retried every 15 minutes) and pick back up automatically once the journey is active again. |
| `archived` | Stops processing and **cancels every in-flight run** for this journey. Not permanent -- set `status` back to `active` to reactivate; new triggers enroll fresh runs (the cancelled ones stay cancelled). |

A person can also pause a journey from `/app/journeys/<id>` (**Pause journey**). There is no Resume button there -- resuming a journey a person paused is your call, the same `PATCH` above with `"status": "active"`.

## Stop on a reply

A journey stops for a contact once they reply. `exitOnReply` is on by default for every journey (including ones created before it existed); reads return it as `exit_on_reply`.

When a message from a run's contact arrives after the journey has already sent them at least one email, every active run of a journey with `exitOnReply` for that contact is cancelled: the run's status becomes `cancelled`, its `context.cancelReason` is `contact_replied`, a `journey.cancelled` event carries the same reason, and any of the run's sends still waiting for approval are rejected. The address is matched within your workspace, ignoring case, `+tags` and Gmail dots.

| Counts as a reply | Does not count |
| - | - |
| A normal reply | An automatic reply (out of office) |
| An opt-out ("unsubscribe me") | A delivery notice (bounce, delay) |
| A clean reply held for a person's review | Spam, and mail the safety check quarantined or rejected (phishing, malware, prompt injection) |

To keep a journey sending after a reply, turn it off on create or update (an update applies to runs already in flight):

```bash title="curl"
curl -X PATCH https://mailbox.molted.email/v1/journeys/JOURNEY_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "exitOnReply": false }'
```

CLI: `molted journeys update JOURNEY_ID --no-exit-on-reply`. MCP: `create_journey` and `activate_journey` take `exit_on_reply`.

## Add a step

```
POST https://mailbox.molted.email/v1/journeys/:id/steps
```

```bash title="CLI"
molted journeys add-step JOURNEY_ID --step-order 1 --step-type send \
  --config '{"templateId":"welcome","payload":{"firstName":"Ada"}}'
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/journeys/JOURNEY_ID/steps \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stepOrder": 1,
    "stepType": "send",
    "config": {
      "templateId": "welcome",
      "payload": { "firstName": "Ada" }
    }
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `stepOrder` | `number` | Yes | Position of the step in the journey (1, 2, 3...). |
| `stepType` | `string` | Yes | `send`, `delay`, `branch` or `end`. |
| `config` | `object` | No | Step configuration, see below. |

MCP: `add_journey_step` (`journey_id`, `step_order`, `step_type`, `config`).

### Step types

| Type | Description | Config fields |
| - | - | - |
| `send` | Send an email using a template. | `templateId` (defaults to `_default`), `payload` (merged under the trigger payload, so trigger fields win), `mailboxId` (send from that mailbox; if it isn't found or isn't active the run fails with `mailbox_not_found`), `dedupeKeyPrefix` |
| `delay` | Wait before executing the next step. | `delayMinutes` (default 60) |
| `branch` | Route to different paths based on conditions. | `conditions` (field, operator, value, nextStepOrder), `defaultNextStepOrder` |
| `end` | Mark the journey run as complete. | none |

**Branch conditions** support these operators: `eq`, `neq`, `gt`, `lt`, `contains`, `exists` (`value` is required for every operator except `exists`).

A condition's `field` is a dot path resolved against the run's trigger payload -- the
`payload` you send to [trigger a journey](#trigger-a-journey-via-event-ingestion). A bare
field name (`"plan"`) and a `"properties."`-prefixed one (`"properties.plan"`) are
equivalent; an explicit `"triggerPayload.plan"` prefix also works -- the literal path is
tried first, so a payload that genuinely has a nested `properties` (or `triggerPayload`)
object still resolves correctly rather than having the alias shadow it. Contact fields
(e.g. a segment's `lifecycleStage`) are **not** resolved here -- only the trigger payload.

The config is validated when the step is created or updated (`POST`/`PATCH .../steps`) --
a malformed shape (unknown operator, missing `nextStepOrder`, etc.) is rejected with `400`
instead of silently never branching. Every target step\_order must also be **greater than
the branch step's own** -- branching backward, or to itself, isn't supported (the worker
can't re-run a step that's already completed for the run), and is rejected the same way.

### Branch step example

Conditions are tried in order; the first one that matches wins (like a `switch`), and
`defaultNextStepOrder` is the fallback when none match:

```json title="Branch step"
{
  "stepOrder": 2,
  "stepType": "branch",
  "config": {
    "conditions": [
      { "field": "properties.plan", "operator": "eq", "value": "pro", "nextStepOrder": 3 }
    ],
    "defaultNextStepOrder": 5
  }
}
```

> **Note:** A legacy shape is still accepted for backward compatibility, but shouldn't be used for new
> steps: `{ "conditions": [...], "onMatch": { "nextStep": 3 }, "onNoMatch": { "nextStep": 5 } }`,
> where **every** condition must match (AND) to take `onMatch`, otherwise `onNoMatch`.

## Update or delete a step

```
PATCH  https://mailbox.molted.email/v1/journeys/:id/steps/:stepId
DELETE https://mailbox.molted.email/v1/journeys/:id/steps/:stepId
```

```bash title="CLI"
molted journeys update-step JOURNEY_ID STEP_ID --config '{"delayMinutes":1440}'
molted journeys delete-step JOURNEY_ID STEP_ID
```

`PATCH` takes any of `stepOrder`, `stepType` and `config` (a new `config` replaces the old one, it isn't merged) and returns `{ "updated": true }`, or `404 Step not found`. `DELETE` returns `{ "deleted": true }` and shifts every later step's `stepOrder` down by one; branch targets (`nextStepOrder`, `defaultNextStepOrder`) are not rewritten, so re-check your branches after deleting a step before them. Both are refused with `400` while the journey is `active` (pause it first). MCP: `update_journey_step` (`journey_id`, `step_id`, `step_order`, `step_type`, `config`); there is no MCP tool for deleting a step.

## Trigger a journey via event ingestion

```
POST https://mailbox.molted.email/v1/agent/events/ingest
```

```bash title="CLI"
molted journeys trigger --event user_signed_up --contact ada@example.com \
  --properties '{"plan":"pro"}'
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/events/ingest \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "eventName": "user_signed_up",
    "contactEmail": "ada@example.com",
    "payload": { "plan": "pro" }
  }'
```

`eventName`, `contactEmail` and `payload` (an object) are required. Every `active` journey whose `triggerEvent` matches (and whose `triggerConditions`, if any, all match the payload) enrolls the contact, at most one active run per contact per journey. The contact doesn't need an existing contact record. Ingest refuses a mailbox-scoped key and needs a key with write scope. MCP: `trigger_journey` (`event_name`, `contact_email`, `payload`).

### Response

Runs start before the response returns, so the response says what happened in every journey whose trigger event matched. You don't need to poll the journey or its runs to find out:

```json title="Response (201)"
{
  "eventId": "EVENT_ID",
  "journeys": [
    { "journey_id": "JOURNEY_ID", "journey_name": "Onboarding", "outcome": "started", "run_id": "RUN_ID" },
    { "journey_id": "JOURNEY_ID_2", "journey_name": "Win-back", "outcome": "skipped", "reason": "already_active", "active_run_id": "RUN_ID_2" },
    { "journey_id": "JOURNEY_ID_3", "journey_name": "Old onboarding", "outcome": "skipped", "reason": "journey_not_active", "journey_status": "paused" }
  ]
}
```

| `outcome` / `reason` | Meaning |
| - | - |
| `started` | A run was created; `run_id` is its id (the `id` [List journey runs](#list-journey-runs) shows). |
| `skipped`, `already_active` | The contact already has an active run in this journey, named by `active_run_id` (`null` if it ended in the meantime). Two events for the same contact at once still start one run. |
| `skipped`, `trigger_conditions_not_met` | The payload does not equal every `triggerConditions` entry. |
| `skipped`, `journey_not_active` | The journey is not active: `journey_status` is `paused`, `draft`, `archived` or `deleted`. |
| `skipped`, `no_steps` | The journey has no steps. |

An empty `journeys` list means no journey triggers on `eventName`. Suppression and consent are checked by each send step, not at enrolment: a suppressed contact still starts a run, and its sends are blocked. The `journeys` entries use snake\_case like the run objects below; `eventId` is unchanged.

## List journey runs

```
GET https://mailbox.molted.email/v1/journeys/:id/runs
```

```bash title="CLI"
molted journeys runs list JOURNEY_ID
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/journeys/JOURNEY_ID/runs" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Response

At most 100 runs, newest `started_at` first, as raw rows:

```json title="Response"
[
  {
    "id": "RUN_ID",
    "journey_id": "JOURNEY_ID",
    "tenant_id": "tenant_abc123",
    "contact_email": "ada@example.com",
    "contact_id": "CONTACT_ID",
    "current_step_id": "STEP_ID",
    "status": "active",
    "context": {},
    "started_at": "2026-03-01T12:00:00Z",
    "completed_at": null,
    "next_step_at": "2026-03-02T12:00:00Z"
  }
]
```

Run `status` is `active`, `completed`, `failed` or `cancelled`. A run stopped by a reply is `cancelled` with `context.cancelReason: "contact_replied"` (see [Stop on a reply](#stop-on-a-reply)). There is no MCP tool for listing runs.

## Cancel a run

Stop a single in-flight run. Any step job already queued for it (a pending `delay`, or one
deferred for a paused mailbox/journey) becomes a no-op the next time it's picked up. Cancelling also rejects that contact's pending send approvals for the journey; a send already handed to the send queue can still go out.

```
POST https://mailbox.molted.email/v1/journeys/:id/runs/:runId/cancel
```

```bash title="CLI"
molted journeys runs cancel JOURNEY_ID RUN_ID
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/journeys/JOURNEY_ID/runs/RUN_ID/cancel \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Returns `{ "cancelled": true, "id": "RUN_ID" }`, `404 Run not found`, or `400 Run is already <status>`. MCP: `cancel_journey_run` (`journey_id`, `run_id`).

To cancel every active run of a journey at once (returns `{ "cancelled": <count> }`; CLI `molted journeys runs cancel-all JOURNEY_ID`, MCP `cancel_all_journey_runs`):

```
POST https://mailbox.molted.email/v1/journeys/:id/runs/cancel-all
```

A person can do either from `/app/journeys/<id>` too (**Cancel run** on a row, **Cancel all runs**
behind a confirm) -- the same effect, just through the portal instead of the API.

## Delete a journey

```
DELETE https://mailbox.molted.email/v1/journeys/:id
```

```bash title="CLI"
molted journeys delete JOURNEY_ID
```

Delete a journey you no longer need, such as a test one. A journey in `draft`, `paused`,
`archived` or `completed` status can be deleted; an `active` one is refused with `400`: pause or
archive it first. Any run still waiting (a paused journey's parked runs) is cancelled, and its
pending send approvals are rejected. An unknown id is a `404`.

```bash title="curl"
curl -X DELETE "https://mailbox.molted.email/v1/journeys/JOURNEY_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"
# -> { "deleted": true, "id": "JOURNEY_ID", "cancelledRuns": 0 }
```

MCP: `delete_journey` (`journey_id`). A person can also delete it from
`/app/journeys/<id>` (**Delete journey**, behind a confirm, once it isn't active).

The journey disappears from the list, from `GET /v1/journeys/:id` and from its runs list; its runs, steps and the
emails it sent stay in your history. Deleting can't be undone through the API.

## How journeys execute

1. When a trigger event fires, a journey run is created for the matching contact.
2. Each contact is deduplicated per journey, by email address: an address can only have one active run per journey.
3. Before each step executes, the run is checked: the journey must be `active` and the run must still be `active` (not already completed, failed or cancelled). A `paused` journey defers the step and retries every 15 minutes -- indefinitely, with no expiry, since a pause is a deliberate operator action -- picking back up automatically once the journey is active again. Archiving a journey cancels its active runs immediately (this is the same thing `POST .../runs/cancel-all` does); the worker repeats the check as a backstop for a step already in flight at that moment.
4. Steps execute in order. `delay` steps schedule the next step after the configured wait time.
5. `branch` steps evaluate conditions (see [Branch step example](#branch-step-example)) and route to the appropriate path; the branch decision is recorded on the run.
6. Before a `send` step goes out, it's checked against [suppressions](https://molted.email/docs/suppressions.md), consent and your billing plan. These block that step -- it's recorded, not silently dropped -- and the run continues to the next step: a suppressed contact (`suppressed`); `no_consent` (the tenant requires consent for marketing, the template is a marketing one, and the journey isn't a `list.subscribe.pending` journey); `thread_taken_over` (a person has taken over the thread with this contact); and a trial (not yet activated) or expired plan (`trial_not_activated`, `subscription_expired`). A paused mailbox or account defers the step every 15 minutes instead, and fails the run with `paused_timeout` after 72 hours. A mailbox at autonomy level 1 or 2 holds the send for approval, and the run moves on.
7. `send` steps get the same outbound compliance as any other send (see [System variables](https://molted.email/docs/templates.md#system-variables) and [Lint rules](https://molted.email/docs/templates.md#lint-rules)): a marketing template's `{{unsubscribe_url}}` (or `{{molted:unsubscribe_url}}`) is substituted with a working per-recipient link, `List-Unsubscribe` / `List-Unsubscribe-Post` one-click headers are attached, `{{physical_address}}` is filled with the workspace's physical address (any template type), and a marketing email that doesn't already carry the address gets it appended as a footer. A marketing template whose tenant has no physical address on file blocks the step with reason `missing_physical_address` -- recorded the same way a suppressed contact is, and the run continues. Transactional templates (including the `_default` template) are never blocked or given the footer.
8. A `send` step also counts against your [send quota](https://molted.email/docs/rate-limits.md) -- the same monthly/daily/hourly budget an API send draws from. Over the daily or hourly limit, or while Molted has paused Free sending (`free_sends_paused`), the step is deferred and retried automatically (no reason is recorded, since nothing permanent happened yet). Over the monthly limit (or its overage cap, on a plan with overage), the step is blocked with reason `monthly_limit_exceeded` or `overage_cap_exceeded` -- recorded the same way a suppressed contact is, and the run continues to the next step.
9. The run completes when it reaches an `end` step or there is no later step. It ends `failed` on errors such as `mailbox_not_found`, a template render failure or `paused_timeout`, and `cancelled` when you cancel it, the journey is archived or deleted, or (with `exitOnReply`, the default) the contact replies after the journey has emailed them.
