PPactDocs
CRM

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 for now + duration_seconds.
  • branch — evaluate a predicate, follow the then or else edge.
  • exit — terminate the enrollment with an optional reason.
  • 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.
json
{
  "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.