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/journeysmolted journeys create --name "Onboarding sequence" --trigger-event user_signed_upcurl -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. |
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/journeysmolted journeys listcurl "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/:idmolted journeys get JOURNEY_IDcurl "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/:idmolted journeys update JOURNEY_ID --name "Updated onboarding" --status activecurl -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):
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/stepsmolted journeys add-step JOURNEY_ID --step-order 1 --step-type send \
--config '{"templateId":"welcome","payload":{"firstName":"Ada"}}'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. 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:
{
"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/:stepIdmolted journeys update-step JOURNEY_ID STEP_ID --config '{"delayMinutes":1440}'
molted journeys delete-step JOURNEY_ID STEP_IDPATCH 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/ingestmolted journeys trigger --event user_signed_up --contact ada@example.com \
--properties '{"plan":"pro"}'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:
{
"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 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/runsmolted journeys runs list JOURNEY_IDcurl "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:
[
{
"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/cancelmolted journeys runs cancel JOURNEY_ID RUN_IDcurl -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-allA 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/:idmolted journeys delete JOURNEY_IDDelete 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 -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
- When a trigger event fires, a journey run is created for the matching contact.
- Each contact is deduplicated per journey, by email address: an address can only have one active run per journey.
- Before each step executes, the run is checked: the journey must be
activeand the run must still beactive(not already completed, failed or cancelled). Apausedjourney 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 thingPOST .../runs/cancel-alldoes); the worker repeats the check as a backstop for a step already in flight at that moment. - Steps execute in order.
delaysteps schedule the next step after the configured wait time. branchsteps evaluate conditions (see Branch step example) and route to the appropriate path; the branch decision is recorded on the run.- Before a
sendstep 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 alist.subscribe.pendingjourney);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 withpaused_timeoutafter 72 hours. A mailbox at autonomy level 1 or 2 holds the send for approval, and the run moves on. sendsteps 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-Postone-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 reasonmissing_physical_address-- recorded the same way a suppressed contact is, and the run continues. Transactional templates (including the_defaulttemplate) are never blocked or given the footer.- A
sendstep 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 reasonmonthly_limit_exceededoroverage_cap_exceeded-- recorded the same way a suppressed contact is, and the run continues to the next step. - The run completes when it reaches an
endstep or there is no later step. It endsfailedon errors such asmailbox_not_found, a template render failure orpaused_timeout, andcancelledwhen you cancel it, the journey is archived or deleted, or (withexitOnReply, the default) the contact replies after the journey has emailed them.