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 aggregates per-step, per-variant performance
(sent, opened, clicked, replied, conversion_rate) using
journey_runs.outcome_json.variant_key as the source of truth — so the report reflects
what the runtime chose, not what eventually landed in the delivery table.
conversion_rate is a proxy today
The conversion_rate in the variant breakdown is replied / sent — a deliberately coarse
proxy. A later conversions table is planned to refine this into deal-won / revenue-attributed
figures. Treat it as a relative signal between variants, not an absolute conversion metric.
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.