# Journeys


A journey is a stored, versioned flow that walks individual users through a sequence of steps — send a push, wait, branch on what the user did, write a tag — one step at a time, on a server-side clock. Where a message is a single send to an audience computed once, a journey is a long-running enrolment per user, entered when someone joins a segment or fires an event, and advanced by the server every few seconds until the user reaches the end or exits.

All examples use `https://app.openpush.ai`, the OpenPush API base URL.

## When to use

- **Onboarding sequences** — a welcome push on day 0, a follow-up on day 3 only for people who did not tap the first one.
- **Re-engagement** — enter users when they fall into a "dormant 14 days" segment, send, wait, tag the ones who came back.
- **Event-driven follow-ups** — a user fires `cart_abandoned`, waits an hour, gets one push.
- **Population tagging over time** — combine waits and tag steps to stamp users with lifecycle tags that your segments then read.

Use a plain [message](sending-messages.md) instead when the send is one-shot and time-boxed: a single broadcast, a scheduled announcement, an [A/B test](ab-testing.md). Journeys add per-user state, which you do not need for those.

## Prerequisites

- An app with working push credentials ([APNs](platform-setup-apns.md) / [FCM](platform-setup-fcm.md)).
- A REST API key. Journey routes accept `X-OP-API-Key` only — the compatibility `Authorization: Key` spelling does not work here.
- At least one [template](templates.md). A send step has no inline copy field; its content comes from a template row.
- For segment-triggered journeys, at least one [segment](segments.md).

## The journey object

A journey row carries a name, a description, a state, a monotonic `version`, and a **definition** — a JSON object with a closed set of six top-level fields:

| Field | Type | Description |
|---|---|---|
| `audience` | object | How users get in. Required before the journey can go live. |
| `nodes` | array | The steps, in order. Branch children nest inside their branch. |
| `early_exit` | object or null | Rules that finish a user's run before the end of the flow. |
| `reentry_rules` | object or null | How long after finishing a user may enter again. |
| `schedule` | object or null | Optional `start_at` / `stop_at` for the journey as a whole. |
| `goal` | object or null | A named target. Stored and validated only — see [Limits](#limits). |

Anything else at the top level is rejected with an `unknown-field` issue naming the key. Node-level keys are *not* checked the same way: unknown fields inside a node are accepted and stored verbatim, so a typo in a node config fails silently rather than loudly.

## Lifecycle states

```
draft     → scheduled | active
scheduled → draft | active | archived
active    → paused | archived
paused    → active | archived
archived  → (terminal)
```

| State | What it means |
|---|---|
| `draft` | Being designed. Nothing runs. Every structural edit is allowed. |
| `scheduled` | Validated and waiting for `schedule.start_at`. The start must be at least 300 seconds in the future. |
| `active` | Running. Users enter, timers tick, steps execute. |
| `paused` | Frozen. **Timers do not fire during a pause** — on resume, every pending wake time is pushed forward by exactly the pause duration, so a user two days into a three-day wait still has one day left. |
| `archived` | Terminal. Every live run is halted, the entry listeners are removed, and the journey cannot be reactivated. |

State changes happen synchronously in the request that asks for them. Moving to `active` or `scheduled` runs the full definition validation first; any blocking issue comes back as a `400` with an `issues` array in `meta`, and the state does not change.

```bash
curl -X PATCH https://app.openpush.ai/v1/apps/app_3f9c2b/journeys/journey_01hq8n4t2v \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"state": "active", "concurrency_key": "8f2c1d0ab4e79305c6118d3a7f4e2b90dd51c67a"}'
```

`concurrency_key` is mandatory on every journey PATCH, including this one. Read the journey first, send back the `concurrency_key` you got, and if someone else saved in between you get a `409 stale-concurrency-key` instead of a silent overwrite.

## Node kinds

There are twelve node kinds in the vocabulary, but **only six execute**: `send_push`, `send_iam`, `wait`, `tag`, `yes_no`, and `split_range`. The other six exist so a flow can be sketched with them in place; they are refused when you try to set the journey live, and if a definition somehow reaches one at runtime the run is halted rather than skipped.

Config lives directly on the node object, alongside `kind`. Node ids are server-assigned (`jnode_…`) — supplying one on create is rejected.

### Nodes that run

| Kind | Config | Behaviour |
|---|---|---|
| `send_push` | `template_id` (required to go live) | Sends the template through the ordinary campaign path. |
| `send_iam` | `iam_id` and `user_ttl_seconds` (integer, 1 s – 1 year; both required to go live) | Queues the [in-app message](in-app-messages.md) for that user's next session. |
| `wait` | `duration_seconds` (integer, 60 s – 1 year) | Parks the run until the timer expires. |
| `tag` | `assignments` (non-empty object; keys ≤255, values ≤1024) | Merges tags onto the user, then advances immediately. |
| `yes_no` | `branches` — exactly two, exactly one carrying a `condition` | Takes the conditional branch if the condition is true, otherwise the other one. |
| `split_range` | `branches` — 2 to 20, each with an integer `weight`, weights summing to exactly 100 | Deterministically assigns the run to one arm by weight. |

### Nodes that are design-only

`send_live_activity`, `send_email`, `send_sms`, `send_webhook`, `wait_until`, `time_window`.

Any of these in a definition produces a blocking `staged-node` issue — *"{kind} is available for draft design but cannot go live yet."* — so the journey stays in `draft`. Their config fields are accepted without validation. Do not build a flow that depends on one.

### Branches

A branch is an object on a `yes_no`, `split_range` (or design-only `wait_until`) node:

```json
{"id": "jbranch_01hq8n…", "condition": { }, "weight": 50, "nodes": [ ]}
```

`id` is server-assigned. Child steps live in the branch's own `nodes` array, so the flow is a tree, not a graph with explicit edges. When a branch's children run out, the run continues at the first step after the branching node — convergence is structural, walking back up the ancestry. There is no join node and no way to jump sideways.

The whole definition is capped at **200 nodes** counted at every depth.

## Triggers

There are exactly two ways in, set by `audience.kind`. There is no tag-change trigger and no route that enrols a named user on demand.

### `segment` — polled membership diff

```json
{"audience": {
  "kind": "segment",
  "included_segment_ids": ["seg_01hq8n4t2v"],
  "excluded_segment_ids": [],
  "future_additions_only": false
}}
```

Every five seconds the server recomputes the audience, diffs it against what it saw last time, and enters users who are newly in — up to 500 per pass. Segment ids must exist on the app or you get a `segment-not-found` issue.

`future_additions_only: true` seeds the diff table with every current member marked as permanently ineligible, so only people who join the segment *after* the journey goes live are enrolled. This is a one-time seeding at first pass and cannot be undone by flipping the flag back.

### `event_trigger` — pushed by a custom event

```json
{"audience": {
  "kind": "event_trigger",
  "name": "cart_abandoned",
  "attributes": [[{"key": "value_usd", "op": "greater", "value": "50"}]]
}}
```

`name` follows the [custom event](events.md) charset (`a-z A-Z 0-9 _ - . space`, ≤128 characters). `attributes` is exactly one AND-group of conditions — a list containing one list. Operators: `is`, `is_not`, `equal`, `not_equal`, `greater`, `greater_or_equal`, `less`, `less_or_equal`, `exists`, `not_exists`. The six comparison operators are numeric-only and reject a non-numeric value at validation time; `exists` / `not_exists` must not carry a value. There is no `before` / `after` operator.

Entry fires as soon as the event is recorded, but **only if the event's own timestamp is within the last 24 hours** — backfilling historical events into OpenPush will not retro-trigger journeys.

## Wait semantics

`wait` is the only timing node that runs.

- On first arrival the run is parked with a wake time of *now + `duration_seconds`* and marked `waiting`. The next tick past that time advances it.
- Bounds: 60 seconds to 31,556,952 seconds (one year).
- **Waits are timezone-agnostic.** They are plain epoch arithmetic; a user's timezone is never consulted. If you need local-hour delivery, that is a property of the send — see [Best-hour delivery](best-hour-delivery.md) and quiet hours — not of the wait.
- Pausing the journey shifts every pending wake time forward by the pause duration.
- Arrival is recorded once, so a re-visit cannot restart a wait that already started.

There is no wait-until-a-time-of-day, no day-of-week window, and no jitter.

## Branch and condition semantics

Three condition kinds are accepted; anything else is `invalid-condition-kind`.

### `segment_membership`

```json
{"kind": "segment_membership",
 "included_segment_ids": ["seg_01hq8n4t2v"],
 "excluded_segment_ids": []}
```

Evaluated live, against the segment's current rules. A segment whose filter no longer compiles evaluates to false rather than erroring the run.

### `on_notification_action`

```json
{"kind": "on_notification_action",
 "sending_node_id": "jnode_01hq8n4t2v",
 "action": "clicked"}
```

Branches on what the user did with a push this same journey sent them. The referenced node must appear **earlier** in walk order, or you get `invalid-message-reference`. For a `send_push` node the allowed actions are `received`, `confirmed`, and `clicked` — the same [receipt ladder](users-and-subscriptions.md) the rest of the product uses.

On create, node ids do not exist yet. Give the sending node a `client_node_id` of your own and reference that instead; it is resolved to the real id when the server assigns them.

### `event_trigger`

Legal only inside the design-only `wait_until` node, so in practice it never evaluates. Do not build with it.

### `yes_no`

Branches are evaluated in order and the first one whose condition is true wins. If none matches, the branch with no condition is taken; failing that, the last branch.

### `split_range`

Assignment is a deterministic hash of the run id and the node id against the cumulative weights, memoised on the run. A user who somehow revisits the node lands in the same arm. A user who *re-enters* the journey starts a new run and therefore gets a fresh draw — stickiness is per run, not per person.

Split weights can be edited while the journey is live and while people are sitting on that node; the branch *count* cannot change (see [Editing a live journey](#editing-a-live-journey)).

## The send steps

Two steps reach a user: `send_push`, which sends a notification, and `send_iam`,
which queues an in-app message. Everything else only moves the run along.

### `send_push`

- **Content comes exclusively from the template.** Title, body, image, deep link, and data payload are read from the template row at execution time. There is no inline copy field and no per-node override.
- If the template has been deleted by the time the step runs, the step records a skip and the run advances anyway. It does not halt and it does not retry.
- The send goes through the same path a campaign does, which means **quiet hours, frequency caps, retries, and receipts all apply normally**. Quiet hours and caps are app settings, not journey settings — see [Sending messages](sending-messages.md).
- [Liquid personalization](personalization.md) works, with the ordinary `user`, `subscription`, `message`, and `app` namespaces. There is no `journey` namespace.
- **One message record is created per (journey, node) and reused for every user who passes through.** It is named `"{journey name} · {template name}"` and is marked as journey-originated. It is not one message per arrival, so per-arrival message analytics do not exist.

### `send_iam`

`send_iam` does not display anything at the moment the run reaches it. It writes a
per-user target row against the referenced [in-app message](in-app-messages.md),
and the device collects it on its next eligible fetch.

- `iam_id` must resolve to an in-app message in this app at validation time, and the message must be `Active`, `Scheduled` or `Paused` when the step runs — otherwise the step records `message_unavailable` and the run advances. The device only collects the target if the message is `Active` at *fetch* time, so a message paused between the step and the next session yields no display.
- `user_ttl_seconds` is the window the target stays collectable — 1 second to a year, defaulting to 24 hours if the field is absent at runtime. An uncollected target simply expires.
- **One target per user per in-app message per journey.** The check is against the journey's executed-node history, so a re-entry does not produce a second display; the step records `already_targeted` and moves on.
- The target is consumed by the device fetch, so it is delivered once even if the same session fetches twice.
- An `on_notification_action` condition may branch on a `send_iam` node, but `clicked` is the only action it accepts.

## Entry and exit behaviour

### Entry

A user is enrolled only if all of the following hold:

1. The journey is `active`.
2. The user exists on the app.
3. Eligibility passes. For **segment** entry: no active, waiting, or in-progress run may already exist for that user, and either they have never run this journey before, or their last run finished at least `reentry_rules.duration_seconds` ago. **Without `reentry_rules`, a user can never enter a segment journey twice.** For **event** entry, eligibility always passes — the same user can have many concurrent runs of the same journey.
4. `reentry_rules.duration_seconds` must be an integer of at least 600 seconds.

Immediately after enrolment and before the first step, the entry-time exit check runs. It evaluates only `when_not_in_audience` and `on_segment` — the session and event exit rules are not consulted at entry.

### Exit

`early_exit` accepts these rules:

| Rule | Fires when |
|---|---|
| `on_session` | The user opens the app (a device registration is received). |
| `on_event: {"name": "purchase_completed"}` | A matching custom event is recorded. |
| `when_not_in_audience` | Checked at entry only. |
| `on_segment: {"included_segment_ids": [...]}` | Checked at entry only. |
| `tag_on_early_exit` | Not a trigger — an object of tags written onto the user when an early exit fires. |

An empty `early_exit` object with no rule inside is rejected as `empty-early-exit`.

> **Note.** `on_segment` and `when_not_in_audience` are evaluated **only at entry**, not continuously. A user who drifts into an exit segment halfway through a flow keeps going. If you need mid-flight exits, express them as `on_event` or `on_session`.

When an event fires, exit is processed **before** entry. That means a journey whose entry event and exit event are the same name behaves as a restart: the old run finishes, a new one begins.

### Halts

Halting is distinct from exiting — it means the run was stopped by the system rather than by a rule. Runs are halted when the journey is archived, when `schedule.stop_at` passes, when a node referenced by a live run disappears, and when a run reaches a design-only node kind.

## Editing a live journey

Every save bumps the journey's `version` and appends a revision row. Beyond that:

- Archived journeys refuse all edits.
- Once the journey is past `draft` / `scheduled`, the **structural shape is frozen**: node kinds, node parentage, the set of branch ids, `audience.kind`, and `future_additions_only` cannot change. The error tells you to duplicate the journey instead.
- A node that a live run is currently sitting on cannot be removed.
- Non-structural edits are allowed: template ids, wait durations, split weights, tag assignments, conditions, names.
- **In-flight runs execute the current definition**, not the one they entered on. A wait shortened today shortens the wait of someone who entered last week.

Definition fields on PATCH are applied as a recursive merge — `null` deletes a key. To change one node without resending the whole definition, patch the node directly:

```bash
curl -X PATCH https://app.openpush.ai/v1/apps/app_3f9c2b/journeys/journey_01hq8n4t2v/nodes/jnode_01hq8n4t2v \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"concurrency_key": "c04a9f7213be6d85a0f21c9b7734de60915a8b41", "duration_seconds": 172800}'
```

A node's `id` and `kind` are immutable on that route.

## Creating a journey

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2b/journeys \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d @journey.json
```

```json
{
  "name": "Welcome series",
  "description": "Day 0 push, day 3 nudge for non-clickers.",
  "audience": {"kind": "segment", "included_segment_ids": ["seg_01hq8n4t2v"]},
  "reentry_rules": {"duration_seconds": 2592000},
  "early_exit": {"on_event": {"name": "subscription_started"},
                 "tag_on_early_exit": {"welcome_outcome": "converted"}},
  "nodes": [
    {"kind": "send_push", "client_node_id": "day0", "template_id": "tpl_01hq8n4t2v"},
    {"kind": "wait", "duration_seconds": 259200},
    {"kind": "yes_no",
     "branches": [
       {"condition": {"kind": "on_notification_action",
                      "client_node_id": "day0", "action": "clicked"},
        "nodes": [{"kind": "tag", "assignments": {"welcome_outcome": "engaged"}}]},
       {"nodes": [{"kind": "send_push", "template_id": "tpl_01hq8n5x9k"}]}
     ]}
  ]
}
```

The response is `201` with the full journey, including server-assigned `jnode_…` and `jbranch_…` ids and the `concurrency_key` you will need for the next write. The journey is created in `draft`; PATCH it to `active` when you are ready.

Deletion is only permitted from `draft` or `archived`. A live or paused journey must be archived first.

The console edits the same definition and calls the same routes, so anything described here is reachable either way.

## Reading stats

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c2b/journeys/journey_01hq8n4t2v/stats \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

```json
{
  "entered": 4820,
  "completed": 3105,
  "exited_early": 612,
  "halted": 4,
  "active": 1099,
  "nodes": [
    {"node_id": "jnode_01hq8n4t2v", "kind": "executed", "count": 4820},
    {"node_id": "jnode_01hq8n5x9k", "kind": "wait_started", "count": 4802},
    {"node_id": "jnode_01hq8n6b3p", "kind": "branch", "count": 3877}
  ]
}
```

Reading it correctly matters, because the shape is smaller than it looks:

- `entered`, `completed`, `exited_early`, and `halted` are **all-time totals**, summed from a daily rollup. They are not windowed and there is no date filter.
- `active` is a **live count** of runs currently active, waiting, or being processed — it is a snapshot, not a total.
- `nodes` is a count of ledger events grouped by node and by **event kind**, where the kind is one of `entered`, `wait_started`, `executed`, `branch`, `completed`, `exited`, `halted`. It is *not* the node's type. A `branch` row tells you how many times a branching node was resolved, not which way.
- Counts are of **runs**, not distinct people. A user with two concurrent event-triggered runs counts twice.

There is no per-branch breakdown, no exit-reason split, no daily series, no funnel per step, and no skipped counter. If you need per-message performance for a journey step, read the message record that step created through the ordinary [message](sending-messages.md) report.

## Limits

| Limit | Value |
|---|---|
| Nodes per journey | 200, counted at all depths |
| `wait` duration | 60 seconds – 1 year |
| `yes_no` branches | Exactly 2, exactly 1 with a condition |
| `split_range` branches | 2–20, integer weights summing to exactly 100 |
| `wait_until` branches (design-only) | 1–10 |
| Minimum re-entry interval | 600 seconds |
| Minimum lead time for `scheduled` | 300 seconds |
| Segment entrances per pass | 500 |
| Runs advanced per tick | 200 |
| Steps a single run may take in one claim | 200 |
| Journey list page size | Default 50, ceiling 1000 |
| Rate limit | None on any journey route |

Things to know before you design around them:

- **Goals are stored, never computed.** A `goal` object is validated and persisted, but nothing evaluates it and nothing renders progress against it. Treat it as documentation of intent.
- **Journey history is not exported.** Runs, node events, and daily rollups appear in neither the NDJSON archive nor any CSV export, even though the archive's terminal record claims completeness. See [Import and export](import-export.md).
- **Custom events are the only behavioural trigger.** Events do not reach [segments](segments.md), so "did X in the last 7 days" is not expressible as a segment filter and cannot be used as a journey audience except through an event trigger.
- **Version history is not reachable.** Revisions are recorded on every save but there is no route to list or restore them.
- Deleting a segment is not blocked by a journey that references it. The reference simply stops matching.
- Every counter and every rate is process-local, not global. Per-tick batch sizes are per replica, so the observed throughput scales with however many replicas OpenPush is running.

## FAQ

**Can I enrol a specific user by API call?**
No. The only ways in are segment membership and a custom event. If you need on-demand enrolment, fire a custom event for that user and trigger on it.

**Why did my journey go live but nobody entered?**
Three usual causes: `future_additions_only` was true, so everyone already in the segment was seeded as ineligible; the users had run the journey before and there are no `reentry_rules`; or the trigger events were backdated more than 24 hours and were treated as catalogue data.

**Can two branches of a split send different copy at the same step?**
Yes — put a `send_push` node inside each branch pointing at a different template. For measuring a copy difference on a single send, an [A/B test](ab-testing.md) is the better tool.

**What happens to people mid-flow when I archive?**
Every run stops immediately with a halted status, the halted counter increments, and the entry listeners are removed. Archiving is terminal; you cannot bring the journey back.

**Does a wait respect the user's timezone or quiet hours?**
The wait itself does not — it is pure elapsed time. Quiet hours are applied by the send step when it fires, so a push that comes due inside a quiet window is held and released when the window opens.

## Related

- [Sending messages](sending-messages.md)
- [In-app messages](in-app-messages.md)
- [Templates](templates.md)
- [Segments](segments.md)
- [Events](events.md)
- [Personalization](personalization.md)
- [Import and export](import-export.md)
