MOLTED EMAIL

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
CLI
molted journeys create --name "Onboarding sequence" --trigger-event user_signed_up
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"
  }'
FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
namestringYesJourney name.
triggerEventstringYesEvent that starts the journey for a contact.
triggerConditionsobjectNoOnly enroll when every key equals the same top-level key of the event payload, e.g. { "plan": "pro" }. CLI: --trigger-conditions '<json>'.
exitOnReplybooleanNoStop a contact's run once they reply. Defaults to true. CLI: --no-exit-on-reply to turn it off. See 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).

List journeys

GET https://mailbox.molted.email/v1/journeys
CLI
molted journeys list
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
CLI
molted journeys get JOURNEY_ID
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
CLI
molted journeys update JOURNEY_ID --name "Updated onboarding" --status active
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

StatusMeaning
draftThe state every journey is created in. Not yet processing triggers.
activeProcessing triggers and running steps. Steps can't be updated or deleted while active.
pausedStops 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.
archivedStops 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 replyDoes not count
A normal replyAn automatic reply (out of office)
An opt-out ("unsubscribe me")A delivery notice (bounce, delay)
A clean reply held for a person's reviewSpam, 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):

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
CLI
molted journeys add-step JOURNEY_ID --step-order 1 --step-type send \
  --config '{"templateId":"welcome","payload":{"firstName":"Ada"}}'
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" }
    }
  }'
FieldTypeRequiredDescription
stepOrdernumberYesPosition of the step in the journey (1, 2, 3...).
stepTypestringYessend, delay, branch or end.
configobjectNoStep configuration, see below.

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

Step types

TypeDescriptionConfig fields
sendSend 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
delayWait before executing the next step.delayMinutes (default 60)
branchRoute to different paths based on conditions.conditions (field, operator, value, nextStepOrder), defaultNextStepOrder
endMark 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. 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:

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

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
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
CLI
molted journeys trigger --event user_signed_up --contact ada@example.com \
  --properties '{"plan":"pro"}'
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:

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 / reasonMeaning
startedA run was created; run_id is its id (the id List journey runs shows).
skipped, already_activeThe 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_metThe payload does not equal every triggerConditions entry.
skipped, journey_not_activeThe journey is not active: journey_status is paused, draft, archived or deleted.
skipped, no_stepsThe 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
CLI
molted journeys runs list JOURNEY_ID
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:

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). 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
CLI
molted journeys runs cancel JOURNEY_ID RUN_ID
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
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.

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) 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, 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 and 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 -- 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.