Journey detail
The single-journey view — its versioned DAG definition, status lifecycle, enrollments, per-step run logs, and variant analytics.
Journey detail
A journey is a versioned graph of steps that drives a subject through sends, waits,
and branches until they exit. Unlike a linear sequence, a journey is a DAG: it can fan out
on a predicate, pause for a duration, call out to an HTTP endpoint, or write back to an
internal field. The graph is stored as JSON on journey_definitions, so you can revise a
journey without breaking in-flight enrollments — every enrollment stays pinned to the
definition_id it started on.
The detail page is the single-record view backing GET /v1/journeys/{id}. It returns the
journey plus its currently-promoted definition, and links to the enrollments, run logs,
definition versions, and variant analytics described below.
Marketing module + RBAC
All journey routes require verify_api_key, require_module("marketing"), and a
SEQUENCES_READ / SEQUENCES_WRITE permission. Reads and writes are tenant-scoped via
get_tenant_id; a journey belonging to another tenant returns 404.
The definition graph
Each node in steps carries a kind discriminator. The validator
(core.journeys._VALID_STEP_KINDS) accepts:
send— write a delivery row through the consent gate, then advance.wait— schedule the enrollment fornow + duration_seconds.branch— evaluate a predicate, follow thethenorelseedge.exit— terminate the enrollment with an optionalreason.social_touch— a non-email touch on the timeline.fetch— call an HTTP endpoint and store the response under a variable.decision— multi-way branching that reads previously-stored variables.update— write a templated value to an allowlisted internal field.send_time_optimize— a send whose dispatch time is optimized per subject.
{
"start_step_id": "s1",
"steps": {
"s1": { "kind": "send", "channel": "email", "subject": "…", "body": "…", "next": "s2" },
"s2": { "kind": "wait", "duration_seconds": 86400, "next": "s3" },
"s3": { "kind": "branch", "predicate": { … }, "then": "s4", "else": "s5" },
"s4": { "kind": "exit" },
"s5": { "kind": "exit", "reason": "no_consent" }
}
}
Consent is gated at the graph level
Any send step calls core.consent.consent_service.gate before the delivery row is
written. A blocked send writes a blocked_consent delivery row, logs a journey_runs row
with outcome=blocked_consent, and — in the current phase — exits the enrollment with
exit_reason='consent_withdrawn'. Frequency caps (core.frequency_caps) are enforced on
the same path. The legal substrate is honored without complicating the graph.
Status lifecycle
POST /v1/journeys/{id}/status (or the status field on PATCH /v1/journeys/{id}) moves
a journey through draft → active → paused → archived. journey_service.set_status
validates the target against those four allowed values and returns 422 on an unknown status.
Only an active journey
enrolls and advances subjects. The PATCH route also edits name and description inline —
and it explicitly commits, because a name/description-only change would otherwise be rolled
back by the request-scoped connection.
Versioned definitions
A journey can hold many definitions; exactly one is current.
POST /v1/journeys/{id}/definitions— append a new draft definition.GET /v1/journeys/{id}/definitions— list versions (label,is_draft,is_current).GET /v1/journeys/{id}/definitions/{def_id}— read one version with its full graph.POST /v1/journeys/{id}/promote/{def_id}— promote a draft to current.
Promoting a draft does not disturb running enrollments — they finish on the definition they entered on, so you can iterate safely on a live journey.
Enrollments and run logs
POST /v1/journeys/{id}/enroll manually enrolls a subject; GET /v1/journeys/{id}/enrollments
lists them. Each enrollment has a per-step execution trail at
GET /v1/journeys/enrollments/{eid}/runs — one journey_runs row per step transition
(kind, action, outcome), which is how you answer "why did this contact get this email,
and when's the next step?" The scheduler advances due enrollments via
POST /v1/journeys/tick.
Variant analytics
GET /v1/journeys/{id}/variants reports per-step, per-variant performance. Which variant a
contact got comes from journey_runs.outcome_json.variant_key, so the report reflects what the
runtime chose, not what eventually landed in the delivery table. It counts the same way as the
sequence funnel:
enqueuedis every pick the runtime made.sentcounts only deliveries a provider accepted. A queued, failed, or manual delivery is never counted as sent.opened,clicked,repliedcount distinct sent deliveries with at least one such event. Opening the same message twice counts once. Events from your email provider's webhook are matched by the provider's message ID, so they count even though the webhook never names the delivery.ratesgives the open and reply rate. When a rate was not measured, for example because nothing has been sent or the provider has reported no events, it isnullwith a note explaining why, instead of0%.
conversion_rate is replies per send
conversion_rate is replied / sent (conversion_basis: "replied_per_sent"). It is a proxy
for comparing variants, not a revenue figure. It is null until at least one reply has been
recorded, because zero replies cannot be told apart from a reply source that is not connected.
For revenue, the Journeys list shows each journey's attributed pipeline (hover it for the
won value): opportunities on accounts whose contacts enrolled in the journey in the last 60 days. There is no
per-variant revenue attribution.
Pre-launch simulation
Before activating, run a read-only dry run: POST /v1/journeys/{id}/simulate projects
journey behavior over a cohort (capped at 5,000 subjects) without writing any
sequence_deliveries or consent_events rows. You can simulate the same cohort repeatedly
without polluting analytics, then diff completed runs with
POST /v1/journeys/simulations/compare.