# 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

```
X-OP-API-Key: <REST API key>
```

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:

```json
{"errors": [{"code": "invalid-payload", "title": "Journey has validation errors.", "meta": {}}]}
```

| 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_at` and friends are ISO strings, but `schedule.start_at` and `schedule.stop_at` are echoed back **exactly as you stored them**. If you wrote epoch seconds, you read epoch seconds.

> `live_version` is the journey's *current* version once `started_at` is 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](#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_audience` and `on_segment` are evaluated **only at the moment a user enters**. A user who later falls into an exit segment mid-flight is not removed. `on_session` and `on_event` are 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:

```json
{"code": "staged-node", "message": "send_email is available for draft design but cannot go live yet.", "blocking": true}
```

**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 are `user`, `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](10-in-app-messages.md).
- The message must be `Active`, `Scheduled` or `Paused` when the run arrives. Otherwise the step records `message_unavailable` in the node event's `detail` and 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 records `already_targeted`.
- `user_ttl_seconds` is 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}` — the `send_iam` counterpart 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

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

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`, or `future_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

```bash
curl "https://app.openpush.ai/v1/apps/app_3f9c/journeys?state=active&limit=2" \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

### Example response

```json
{
  "journeys": [
    {
      "id": "journey_01hq7r",
      "app_id": "app_3f9c",
      "name": "Day-3 onboarding",
      "state": "active",
      "audience": {"kind": "segment"},
      "schedule": null,
      "reentry_rules": {"duration_seconds": 2592000},
      "live_version": 4,
      "created_source": "public_api",
      "created_at": "2026-08-12T09:14:00Z",
      "updated_at": "2026-08-27T16:02:11Z",
      "started_at": "2026-08-12T09:20:00Z",
      "paused_at": null,
      "archived_at": null
    }
  ],
  "has_more": true,
  "next_cursor": "djE6am55XzAxaHE3cg"
}
```

`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 yet
- `template-required` — a `send_push` node with no template
- `staged-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

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/journeys \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Day-3 onboarding",
    "description": "Nudge users who have not finished setup.",
    "audience": {
      "kind": "segment",
      "included_segment_ids": ["seg_01hq5k"],
      "future_additions_only": true
    },
    "reentry_rules": {"duration_seconds": 2592000},
    "early_exit": {
      "on_event": {"name": "setup_completed"},
      "tag_on_early_exit": {"onboarding": "done"}
    },
    "nodes": [
      {"kind": "wait", "duration_seconds": 259200},
      {"kind": "send_push", "client_node_id": "nudge", "template_id": "tpl_01hq7m"},
      {
        "kind": "yes_no",
        "branches": [
          {"condition": {"kind": "on_notification_action",
                         "client_node_id": "nudge", "action": "clicked"},
           "nodes": [{"kind": "tag", "assignments": {"onboarding": "engaged"}}]},
          {"nodes": [{"kind": "wait", "duration_seconds": 86400}]}
        ]
      }
    ]
  }'
```

### Example response

`201 Created`:

```json
{
  "id": "journey_01hq7r",
  "app_id": "app_3f9c",
  "name": "Day-3 onboarding",
  "state": "draft",
  "audience": {
    "kind": "segment",
    "included_segment_ids": ["seg_01hq5k"],
    "future_additions_only": true
  },
  "schedule": null,
  "reentry_rules": {"duration_seconds": 2592000},
  "live_version": null,
  "created_source": "public_api",
  "created_at": "2026-08-30T11:00:00Z",
  "updated_at": "2026-08-30T11:00:00Z",
  "started_at": null,
  "paused_at": null,
  "archived_at": null,
  "description": "Nudge users who have not finished setup.",
  "early_exit": {
    "on_event": {"name": "setup_completed"},
    "tag_on_early_exit": {"onboarding": "done"}
  },
  "goal": null,
  "nodes": [
    {"id": "jnode_01hq7s", "kind": "wait", "duration_seconds": 259200},
    {"id": "jnode_01hq7t", "kind": "send_push",
     "client_node_id": "nudge", "template_id": "tpl_01hq7m"},
    {"id": "jnode_01hq7u", "kind": "yes_no", "branches": [
      {"id": "jbranch_01hq7v", "condition": {"kind": "on_notification_action",
        "sending_node_id": "jnode_01hq7t", "action": "clicked"},
       "nodes": [{"id": "jnode_01hq7w", "kind": "tag",
                  "assignments": {"onboarding": "engaged"}}]},
      {"id": "jbranch_01hq7x",
       "nodes": [{"id": "jnode_01hq7y", "kind": "wait", "duration_seconds": 86400}]}
    ]}
  ],
  "concurrency_key": "8f2c1d0ab4e79305c6118d3a7f4e2b90dd51c67a"
}
```

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

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

### Example request — change a wait and drop the schedule

```bash
curl -X PATCH https://app.openpush.ai/v1/apps/app_3f9c/journeys/journey_01hq7r \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "concurrency_key": "b71e440c9a2d38f5017c6ea9b2d4c8130f5a6e22",
    "schedule": null,
    "nodes": [
      {"id": "jnode_01hq7s", "kind": "wait", "duration_seconds": 172800}
    ]
  }'
```

### 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

```bash
curl -X PATCH https://app.openpush.ai/v1/apps/app_3f9c/journeys/journey_01hq7r/nodes/jnode_01hq7t \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"concurrency_key": "c04a9f7213be6d85a0f21c9b7734de60915a8b41", "template_id": "tpl_01hq8a"}'
```

### 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

```bash
curl -X DELETE https://app.openpush.ai/v1/apps/app_3f9c/journeys/journey_01hq7r \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

### Example response

```json
{"success": true}
```

### 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

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

### Example response

```json
{
  "entered": 1840,
  "completed": 1102,
  "exited_early": 315,
  "halted": 0,
  "active": 423,
  "nodes": [
    {"node_id": "jnode_01hq7s", "kind": "wait_started", "count": 1840},
    {"node_id": "jnode_01hq7t", "kind": "executed", "count": 1725},
    {"node_id": "jnode_01hq7u", "kind": "branch", "count": 1725}
  ]
}
```

**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_source` is derived, not stored.** It reports `dashboard` when the journey has a console owner and `public_api` otherwise.
- **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.

---

## Related

- [Journeys guide](../guides/journeys.md)
- [Segments API](04-segments.md)
- [Templates and dynamic content API](05-templates-dynamic-content.md)
- [Events and ingest API](06-events-ingest.md)
- [API overview](00-overview.md)
