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 instead when the send is one-shot and time-boxed: a single broadcast, a scheduled announcement, an A/B test. Journeys add per-user state, which you do not need for those.
Prerequisites
- An app with working push credentials (APNs / FCM).
- A REST API key. Journey routes accept
X-OP-API-Keyonly — the compatibilityAuthorization: Keyspelling does not work here. - At least one template. A send step has no inline copy field; its content comes from a template row.
- For segment-triggered journeys, at least one segment.
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. |
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
Code
| 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.
Code
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 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:
Code
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
Code
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
Code
name follows the custom event 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_secondsand markedwaiting. 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 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
Code
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
Code
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 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).
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.
- Liquid personalization works, with the ordinary
user,subscription,message, andappnamespaces. There is nojourneynamespace. - 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,
and the device collects it on its next eligible fetch.
iam_idmust resolve to an in-app message in this app at validation time, and the message must beActive,ScheduledorPausedwhen the step runs — otherwise the step recordsmessage_unavailableand the run advances. The device only collects the target if the message isActiveat fetch time, so a message paused between the step and the next session yields no display.user_ttl_secondsis 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_targetedand 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_actioncondition may branch on asend_iamnode, butclickedis the only action it accepts.
Entry and exit behaviour
Entry
A user is enrolled only if all of the following hold:
- The journey is
active. - The user exists on the app.
- 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_secondsago. Withoutreentry_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. reentry_rules.duration_secondsmust 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_segmentandwhen_not_in_audienceare 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 ason_eventoron_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, andfuture_additions_onlycannot 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:
Code
A node's id and kind are immutable on that route.
Creating a journey
Code
Code
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
Code
Code
Reading it correctly matters, because the shape is smaller than it looks:
entered,completed,exited_early, andhaltedare all-time totals, summed from a daily rollup. They are not windowed and there is no date filter.activeis a live count of runs currently active, waiting, or being processed — it is a snapshot, not a total.nodesis a count of ledger events grouped by node and by event kind, where the kind is one ofentered,wait_started,executed,branch,completed,exited,halted. It is not the node's type. Abranchrow 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 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
goalobject 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.
- Custom events are the only behavioural trigger. Events do not reach segments, 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 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.