Journeys
A journey is a multi-step automated flow. Users enter it when they join a segment or fire an event, then walk a tree of nodes — wait, branch, tag, send a push — one step at a time, until they finish, exit early, or the journey is archived under them.
A journey is stored as a definition: a closed set of six fields (audience, nodes, early_exit, reentry_rules, schedule, goal) plus a name and description. You create the definition, validate it by moving the journey to active, and read progress from the stats endpoint.
All examples use https://app.openpush.ai, the OpenPush API base URL.
Authentication
Code
Journey routes accept only the X-OP-API-Key header. The Authorization: Key <key> spelling works on the OneSignal-compatible Live Activity routes and nowhere else, including here.
Error envelope
Journeys are the one part of the API that does not use {"detail": "…"}. Every journey error returns:
Code
| Code | Status | Meaning |
|---|---|---|
journey-not-found | 404 | No journey with that id in this app. |
app-not-found | 404 | No such app. |
node-not-found | 404 | No node with that id in this journey. |
stale-concurrency-key | 409 | The concurrency_key you sent is missing or no longer current. |
invalid-payload | 400 | Everything else: unknown fields, illegal transitions, validation failures. |
invalid-cursor | 400 | The list cursor could not be decoded. |
journey-archived | 400 | The journey is archived and cannot be edited. |
meta carries at most one of two keys:
issues— a list of{code, path, node_id, message, blocking}objects, one per validation problem.attributes— a sorted list of the body keys that were rejected as unknown or server-controlled.
Nothing else appears in meta.
Rate limits
None. No journey route is rate limited.
The journey object
Summary shape (returned by the list route):
| Field | Type | Description |
|---|---|---|
id | string | journey_… |
app_id | string | Owning app. |
name | string | |
state | string | One of the six states below. |
audience | object | {"kind": "segment"} or {"kind": "event_trigger"} — kind only. |
schedule | object or null | Echoed verbatim from the definition. |
reentry_rules | object or null | Echoed verbatim. |
live_version | int or null | The journey's current definition version once it has started; null before first launch. |
created_source | string | dashboard if created in the console, public_api if created over the API. |
created_at, updated_at, started_at, paused_at, archived_at | string or null | ISO-8601 UTC, …Z. |
Detail shape (create, get, patch, patch node) is the summary plus:
| Field | Type | Description |
|---|---|---|
description | string or null | |
audience | object | The full audience definition, not just its kind. |
early_exit | object or null | |
schedule, reentry_rules, goal | object or null | |
nodes | array | The node tree, with server-assigned ids. |
concurrency_key | string | Opaque token you must echo on every write. |
Timestamp quirk.
created_atand friends are ISO strings, butschedule.start_atandschedule.stop_atare echoed back exactly as you stored them. If you wrote epoch seconds, you read epoch seconds.
live_versionis the journey's current version oncestarted_atis set — it is not a separately published revision. Editing a live journey moves it.
Definition fields
| Field | Type | Rules |
|---|---|---|
name | string, required | 1–300 characters. |
description | string or null | ≤1024 characters. |
audience | object | Required before the journey can go live. kind must be segment or event_trigger. |
nodes | array | Up to 200 nodes counted at every depth. Defaults to []. |
early_exit | object or null | Must carry at least one rule if present. |
reentry_rules | object | {"duration_seconds": <int ≥ 600>}. |
schedule | object or null | start_at / stop_at, each ISO-8601 (a trailing Z is fine) or epoch seconds or null. stop_at must be strictly greater than start_at. |
goal | object or null | Validated and stored; see the note under Goals. |
Anything else at the top level of the definition is rejected with an unknown-field issue.
audience — segment kind
| Field | Rules |
|---|---|
included_segment_ids | Array, at least one id. Each must exist in this app (segment-not-found). |
excluded_segment_ids | Optional array. |
future_additions_only | Boolean, default false. |
audience — event_trigger kind
| Field | Rules |
|---|---|
name | Event name, ≤128 characters, matching ^[a-zA-Z0-9_\-. ]+$. |
attributes | Exactly one AND-group: [[{key, op, value}, …]]. |
Attribute 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. exists / not_exists must not carry a value. Keys are ≤255 characters, values ≤1024. There is no before or after operator.
early_exit rules — any combination of:
| Rule | Shape |
|---|---|
on_session | Truthy — exit when the user opens the app. |
on_event | {"name": "<event name>"} |
when_not_in_audience | Truthy. |
on_segment | {"included_segment_ids": [...]} — at least one, all must exist. |
tag_on_early_exit | Object of tag key (≤255) to value (≤1024), applied on exit. |
when_not_in_audienceandon_segmentare evaluated only at the moment a user enters. A user who later falls into an exit segment mid-flight is not removed.on_sessionandon_eventare evaluated continuously as sessions and events arrive.
Node kinds
Twelve kinds exist. Six execute. The other six are accepted in a draft so you can lay out the shape of a flow, but they block activation and halt any run that somehow reaches them.
| Kind | Executes | Config | Rules |
|---|---|---|---|
send_push | yes | template_id, goal | template_id is required to go live and must resolve to a template in this app. |
send_iam | yes | iam_id, user_ttl_seconds | Both are required to go live. iam_id must resolve to an in-app message in this app (iam-not-found otherwise); user_ttl_seconds is an integer from 1 second to one year (invalid-iam-window otherwise). |
wait | yes | duration_seconds | Integer, 60 to 31,556,952 (one year). |
tag | yes | assignments | Non-empty object; key ≤255, value ≤1024. |
yes_no | yes | branches | Exactly 2 branches, exactly one of which carries a condition. |
split_range | yes | branches[].weight | 2–20 branches; integer weights summing to exactly 100. |
wait_until | no | branches, expiration, wait_indefinitely_acknowledged | 1–10 branches, all with conditions. expiration: null requires wait_indefinitely_acknowledged: true; otherwise expiration.duration_seconds is 60 s–1 year. |
time_window | no | windows[] | At least one window; day_of_week null or 1–7; start/end as {hour, minute}; span at least 15 minutes. |
send_live_activity | no | — | Fields accepted verbatim, never validated. |
send_email | no | — | As above. |
send_sms | no | — | As above. |
send_webhook | no | — | As above. |
Attempting to activate a journey containing any non-executing kind returns a blocking issue:
Code
Universal node fields
| Field | Description |
|---|---|
id | Server-assigned, jnode_…. Supplying one on create is rejected. |
client_node_id | Optional, unique within the journey. Use it to reference a node from an on_notification_action condition before server ids exist. |
kind | Required, immutable after creation. |
annotation | Free text, ≤255 characters. |
branches | Array; each branch gets a server-assigned jbranch_… id. |
Unknown node-level fields are accepted and stored without validation. Only the six top-level definition keys are checked. A typo inside a node config is silently kept.
Conditions
Three condition kinds are recognised; anything else is invalid-condition-kind.
| Kind | Shape | Runtime behaviour |
|---|---|---|
segment_membership | included_segment_ids (≥1), excluded_segment_ids | Recomputes the segment and tests this user. An unusable filter evaluates as false. |
on_notification_action | sending_node_id (or client_node_id) plus action | The referenced node must appear earlier in walk order, or the save fails with invalid-message-reference. Tests whether that delivery reached the state named. When the referenced node is a send_iam, the only accepted action is clicked, and it tests for a click on the in-app message this run targeted. |
event_trigger | event name + attributes | Legal only inside a wait_until node, which does not execute — so this condition is never evaluated. |
Actions accepted by validation, per referenced node kind: send_push → received, confirmed, clicked; send_live_activity → clicked; send_email → received, clicked; send_sms → received; send_iam → clicked; send_webhook → none.
Triggers and entry
Exactly two entry mechanisms exist. There is no tag-change trigger and no API route that enrolls a user directly.
Segment audience — polled. A diff pass runs on the scheduler tick, every 5 seconds. It recomputes the audience, diffs against what it saw last time, and enters newly-matching users, up to 500 per pass per journey. future_additions_only: true on a journey that has never run seeds every current member as permanently barred, so only people who join the segment afterwards enter.
Event audience — push-driven. Recording a custom event enters every active journey whose audience.name matches and whose attribute conditions hold. Only events whose timestamp is within the last 24 hours trigger enrolment; a backfilled event older than that is stored but never enters anyone.
Re-entry. For a segment audience, a user with an active, waiting or processing run is never re-entered. A user with a finished run re-enters only once reentry_rules.duration_seconds has elapsed since that run exited — without reentry_rules, a user never enters twice. For an event audience there is no eligibility check at all: every matching event starts another run, so a user can hold many concurrent runs.
Wait semantics
Only wait executes. On first arrival the run records a wake time of now + duration_seconds and goes to waiting; the scheduler advances it on the first tick past that time. The step is idempotent — a re-visit does not restart the clock.
- Bounds: 60 seconds to one year.
- Timezone-agnostic. Waits are pure epoch arithmetic. No user timezone, no local-hour targeting, no jitter.
- Pausing a journey shifts every waiting run's wake time forward by the pause duration, so timers do not fire while paused and nobody arrives late in a burst on resume.
- A run advances at most 200 node steps per scheduler claim.
Branch semantics
yes_no evaluates branches in order and takes the first whose condition is true. If none matches, it takes the first branch with no condition; failing that, the last branch. Convergence after a split is structural — the run walks back up the tree to find the next step.
split_range assigns a branch by a deterministic hash of the run id and node id against the cumulative weights, and memoises the result on the run, so re-visiting the node in the same run takes the same arm. Because the memo is keyed on the run, a re-entry gets a fresh assignment — arm stickiness does not survive across runs.
Send nodes
Two node kinds reach a user: send_push sends a notification, send_iam queues an in-app message. No other executing kind produces anything the user sees.
send_push
- Content comes entirely from the referenced template — title, body, image, deep link and custom data. There is no inline copy field and no per-node content override.
- If the template has been deleted by the time a run arrives, the step is recorded as skipped and the run advances. Nothing is sent and nothing fails.
- Delivery goes through the ordinary send path, so quiet hours, frequency caps, retries and receipts all apply exactly as they do to a campaign. Quiet hours and frequency caps are app settings, not journey fields.
- Liquid renders normally. There is no
journey.*namespace — the available roots areuser,subscription,message,app,dynamic_content, and your flattened tags. - One message record exists per (journey, node) and is reused for every user who passes through, so the per-node send counters are cumulative across the journey's whole life.
send_iam
- The node writes a per-user target row rather than displaying anything. The device collects it on its next in-app-message fetch and the target is consumed at that fetch, so one target yields at most one display. See the in-app messages API.
- The message must be
Active,ScheduledorPausedwhen the run arrives. Otherwise the step recordsmessage_unavailablein the node event'sdetailand the run advances. - One target per (user,
iam_id, journey). The guard is the journey's own executed-node event history, not the target row, so it survives re-entry and target expiry; a repeat recordsalready_targeted. user_ttl_secondsis how long the target stays collectable. It defaults to 86,400 seconds at execution time if the field is missing; an uncollected target expires silently.- The executed node event carries
{iam_id, target_id, targeted}— thesend_iamcounterpart of the send-push funnel detail.
Goals
A goal object is validated (name ≤255; metric from entered, completed, exited_early at journey level or sent, delivered, confirmed, clicked, ctr, failed, capped on a node; measure count or rate; comparison one of greater, greater_or_equal, less, less_or_equal; a finite numeric target) and stored with the definition.
Nothing evaluates or reports on it. No endpoint returns goal attainment. Treat the field as metadata until that changes.
States
Code
State changes are synchronous — the response reflects the new state. There is no intermediate processing state on a journey; processing exists only on individual runs and is refused if you try to set it.
| Transition | Side effects |
|---|---|
→ active or scheduled | Full validation. Any blocking issue is a 400 with meta.issues. |
→ scheduled | schedule.start_at must be at least 300 seconds in the future. |
paused → active | Every active and waiting run's wake time shifts forward by the pause duration. |
→ archived | Every active, waiting and processing run is halted; listeners are removed. Terminal — an archived journey can only be deleted. |
Editing a live journey. Once a journey leaves draft/scheduled:
- Changing the structure — node kinds, node parents, branch id lists,
audience.kind, orfuture_additions_only— is refused: "Structural nodes and audience shape cannot be restructured after launch; duplicate the journey instead." - Removing a node that a live run is currently sitting on is refused.
- Everything else (copy references, wait durations, tag assignments, split weights) is editable, and in-flight runs immediately execute the new definition — a run does not keep the version it entered on.
- Split weights are editable on a live journey even while people are on that node. Only the branch count is protected.
- An archived journey refuses every edit.
GET /v1/apps/{app_id}/journeys
Lists journeys, ordered by id, with a keyset cursor.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
state | string | no | — | Filter to one state. Must be a known state. |
limit | int | no | 50 | Page size. Ceiling 1000. |
cursor | string | no | — | next_cursor from a previous page. |
Example request
Code
Example response
Code
next_cursor is present only when has_more is true.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | invalid-payload | state is not a known journey state. |
| 400 | invalid-cursor | Cursor could not be decoded. |
| 404 | app-not-found | No such app. |
POST /v1/apps/{app_id}/journeys
Creates a journey in draft. Returns 201.
Body — exactly these keys are allowed: name, description, audience, nodes, early_exit, reentry_rules, schedule, goal. Any other key is rejected, with the offending names in meta.attributes. nodes defaults to [].
Node ids are assigned by the server. Supplying an id on a node is rejected.
What is validated on create. Everything except four "you're not finished yet" gaps, which are allowed in a draft and only block when you go live:
audience-required— no audience yettemplate-required— asend_pushnode with no templatestaged-node/staged-channel— a node kind that cannot go live
Any other issue — a malformed node, a segment that does not exist, weights that do not sum to 100 — is a 400 on create.
Example request
Code
Example response
201 Created:
Code
Keep concurrency_key. Every write below requires it.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | invalid-payload | Body is not a JSON object. |
| 400 | invalid-payload + meta.attributes | Unknown or server-controlled body key. |
| 400 | invalid-payload + meta.issues | Validation failed. |
| 404 | app-not-found | No such app. |
PATCH /v1/apps/{app_id}/journeys/{journey_id}
Updates the journey. One call can change the name, patch the definition, and move the state.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
concurrency_key | string | yes | Must equal the journey's current key. |
name | string | no | |
description | string or null | no | |
state | string | no | Target state. Must be a legal transition. |
audience, nodes, early_exit, reentry_rules, schedule, goal | any | no | Merged into the definition. |
Any other key is rejected with meta.attributes.
concurrency_key is mandatory. Omitting it is not "skip the check" — it is an immediate 409 stale-concurrency-key. Read the journey, take its key, write, take the new key from the response.
Definition fields are a recursive merge patch. Objects merge key by key at every depth, and null deletes a key rather than setting it to null. To clear early_exit entirely, send "early_exit": null. To replace the node tree, send the whole nodes array — arrays are replaced wholesale, not merged element-wise.
Order of operations within one request: definition patch, then name/description, then the state change. A definition that fails validation aborts the whole call.
Example request — go live
Code
Example request — change a wait and drop the schedule
Code
Example response
The full detail object, with the new state, an incremented live_version if the definition changed, and a new concurrency_key.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | invalid-payload | Body not an object; unknown key; illegal transition (Cannot move a journey from active to draft.); validation issues in meta.issues. |
| 400 | invalid-payload | scheduled requested with start_at less than 300 s away. |
| 400 | journey-archived | The journey is archived. |
| 409 | stale-concurrency-key | Key missing or superseded. Re-read and retry. |
| 404 | journey-not-found | No such journey in this app. |
PATCH /v1/apps/{app_id}/journeys/{journey_id}/nodes/{node_id}
Patches one node in place, without you resending the whole tree. Useful for changing a template reference or a wait duration on a large journey.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
concurrency_key | string | yes | Must match the journey's current key. |
| (node fields) | any | no | Merged into the node with the same recursive merge-patch rules as above; null deletes a key. |
id and kind are immutable. Sending id at all, or a kind different from the node's current kind, is 400 invalid-payload "Node id and kind are immutable."
After the merge, the whole journey is re-validated and saved, so a node edit that breaks a sibling's on_notification_action reference fails here.
Example request
Code
Example response
The full journey detail object, with a new concurrency_key.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | invalid-payload | Body not an object; id/kind change attempted; validation issues. |
| 404 | node-not-found | No node with that id in this journey. |
| 404 | journey-not-found | No such journey. |
| 409 | stale-concurrency-key | Key missing or superseded. |
DELETE /v1/apps/{app_id}/journeys/{journey_id}
Deletes a journey.
Only draft and archived journeys can be deleted. A journey that is scheduled, active or paused must be archived first — archiving halts everyone currently in it.
Example request
Code
Example response
Code
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | invalid-payload | "Only draft or archived journeys can be deleted." |
| 404 | journey-not-found | No such journey in this app. |
GET /v1/apps/{app_id}/journeys/{journey_id}/stats
Returns lifetime counters for the journey.
Example request
Code
Example response
Code
Field meanings
| Field | Meaning |
|---|---|
entered | All-time count of enrolments. |
completed | All-time count of runs that reached the end of the tree. |
exited_early | All-time count of runs removed by an early-exit rule. |
halted | All-time count of runs stopped by archiving, schedule end, or an unsupported node. |
active | Live count of runs currently active, waiting or processing. |
nodes[].kind | The node-event kind, not the node type. One of entered, wait_started, executed, branch, completed, exited, halted. |
nodes[].count | Number of events of that kind at that node. |
A node therefore appears once per event kind it has produced — a wait node shows wait_started, a send_push node shows executed, a split shows branch.
That JSON is the entire response. There is no totals wrapper, no per-branch counts, no daily series, no exit-reason breakdown, no unique-user figures, no per-node "skipped" count, no per-node waiting count, no message statistics and no restart counts. For send performance, read the message report for the message the send_push node owns.
Errors
| Status | Code | Cause |
|---|---|---|
| 404 | journey-not-found | No such journey in this app. |
Limits and notes
| Thing | Limit |
|---|---|
| Nodes per journey | 200, counted at every depth |
wait duration | 60 s – 1 year |
split_range branches | 2–20, integer weights summing to 100 |
yes_no branches | Exactly 2, exactly one with a condition |
wait_until branches | 1–10 (node does not execute) |
| Re-entry gap | Minimum 600 s |
| Segment entrances per pass | 500 per journey, every 5 s |
| Node steps per run per scheduler claim | 200 |
| Journey name / description | 300 / 1024 characters |
| List page size | Default 50, ceiling 1000 |
| Rate limits | None |
Also worth knowing:
- Version history is not reachable over the API. Every save appends a revision internally, but no endpoint reads or restores one.
- Deleting a segment a journey references is not blocked. A condition on a missing segment evaluates as false; an audience on a missing segment fails validation the next time you try to save or activate.
created_sourceis derived, not stored. It reportsdashboardwhen the journey has a console owner andpublic_apiotherwise.- The console journey editor is a raw JSON view of the same definition this API accepts. There is no visual canvas.
- Cancelling one user's participation is not an API operation. Archive the journey, or rely on an early-exit rule.
FAQ
Why did my PATCH return 409 when nobody else is editing?
concurrency_key changes on every write, including your own. Take the key from the response of your previous call, not from an earlier read.
Can I put message copy directly on a send_push node?
No. The node references a template id and that is the only content source. Create or edit the template instead.
Can a user be in the same journey twice?
With a segment audience, only after reentry_rules.duration_seconds has elapsed since their last run ended — and never while a run is live. With an event audience, yes, without limit: each qualifying event starts a new run.
Why is a wait firing at an awkward local hour?
Waits are pure elapsed time with no timezone awareness. Use the app's quiet hours to hold the resulting push until the allowed window opens.
How do I stop a journey without losing it?
PATCH it to paused. Timers freeze and resume shifted forward. archived is irreversible and halts everyone.