# Journeys

A [flow](https://voice.sphoro.com/docs/flows) is structure inside one call. A journey is structure *across* calls and days: dial, branch on how it went, wait, try again, and fall back to a message when the phone is never going to be answered.

## What a journey is made of

```
┌──────────┐   answered, owes > 5000   ┌──────────┐
│  remind  │──────────────────────────▶│ arrange  │
│  (call)  │                           │  (call)  │
└────┬─────┘                           └────┬─────┘
     │ no answer                             │ agreed
     ▼                                       ▼
┌──────────┐        ┌──────────┐        ┌──────────┐
│   wait   │───────▶│  remind  │        │   end    │
│   24h    │        │ (revisit)│        │ converted│
└──────────┘        └──────────┘        └──────────┘
                          │ 3 visits reached
                          ▼
                    ┌──────────┐        ┌──────────┐
                    │ message  │───────▶│   end    │
                    │   sms    │        │unreachable│
                    └──────────┘        └──────────┘
```

That graph is the single most common journey anybody builds, and it can be drawn two ways. As above, a call node that may be visited three times, with a no-answer case looping back through a wait, **is** the retry — drawn rather than configured. Or a `retry` node holds the same idea on one block: how many times to go back, how long to wait between attempts, and where to go when they run out. The two are equivalent — `max_visits: 3` on the call is `attempts: 2` on a retry node, because attempts counts the tries *after* the first.

## Draft, then publish

> **Editing a draft changes nothing that is running.** Executions run the *published* version. A draft edited and never published is the single most common "my change did nothing" report against journeys — and it is working as intended, because a graph being edited must not start dialling half-finished.

| Endpoint | Does |
| --- | --- |
| `POST /v1/journeys` | Create one. |
| `GET /v1/journeys/{id}/draft` | Read the working copy. |
| `PUT /v1/journeys/{id}/draft` | Save the working copy. Runs nothing. |
| `POST /v1/journeys/{id}/validate` | Check the draft without publishing it. |
| `POST /v1/journeys/{id}/publish` | Make the draft the version new executions run. |
| `GET /v1/journeys/{id}/versions` | Every published version. |
| `GET /v1/journeys/{id}/versions/{version}` | One of them, exactly as it was. |

`PUT /v1/journeys/{id}/draft`

**Shell**

```bash
curl -s -X PUT https://voice.sphoro.com/v1/journeys/$JOURNEY_ID/draft \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "start": "remind",
    "max_lifetime": "168h",
    "variables": { "amount_due": "number" },
    "nodes": [
      {
        "id": "remind", "name": "Payment reminder", "type": "call", "max_visits": 3, "on_exhausted": "sms",
        "call": { "agent_id": "'"$AGENT_ID"'", "variables": {"first_name": "{{first_name}}"} },
        "cases": [
          { "to": "arrange", "label": "answered and owes more than 5,000",
            "when": {"conditions": [{"variable": "amount_due", "operator": "gt", "value": 5000}]} },
          { "to": "converted", "label": "answered and paid",
            "when": {"conditions": [{"variable": "agreed_to_pay", "operator": "eq", "value": true}]} },
          { "to": "hold", "label": "no answer" }
        ]
      },
      { "id": "hold", "type": "wait",
        "wait": { "for": "24h", "window": {"after": "10:00", "before": "19:00", "timezone": "Asia/Kolkata"} },
        "cases": [{ "to": "remind" }] },
      { "id": "sms", "type": "message",
        "message": { "channel": "sms", "text": "Hello {{first_name}}, we tried to reach you about your balance. Please call us on 020 4000 1234." },
        "cases": [{ "to": "unreachable" }] },
      { "id": "arrange", "type": "call", "call": {"agent_id": "'"$PLAN_AGENT"'"},
        "cases": [{ "to": "converted" }] },
      { "id": "converted",   "type": "end", "end": {"outcome": "converted"} },
      { "id": "unreachable", "type": "end", "end": {"outcome": "unreachable"} }
    ]
  }'
```

`POST /v1/journeys/{id}/publish`

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/journeys/$JOURNEY_ID/publish \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

Executions already running continue on the version they started on. That is deliberate: a contact halfway through a three-call sequence should finish the sequence they began, not jump into a graph where their next node no longer exists.

## Starting a journey on its own

Besides being enrolled — by hand, from a list, or with `POST /v1/journey_executions` — a journey can add people itself. Its `triggers` are part of the graph, so they go live when you publish, and they are paused and resumed without a publish with `PATCH /v1/journeys/{id}` and `{"auto_entry_off": true}`.

| Trigger | Adds |
| --- | --- |
| `call_ended` | The other party of a call that has just ended — the caller of an inbound call, the person rung by an outbound one. Filter with `direction`, `agent_ids` and `outcomes` (`answered`, `no_answer`, `voicemail`, `not_connected`, `failed`). With `"await_analysis": true` the person waits at the start until the call's summary and extracted fields are written, so the first step can branch on them. The call is available as `trigger.outcome`, `trigger.summary`, `trigger.extracted` and so on. |
| `webhook` | Whoever a POST to the journey's address names. Mint the address with `POST /v1/journeys/{id}/webhook/rotate`; it is returned as `webhook_url` and needs no API key, so a website form or Zapier can post to it. The number is read from `phone`, `phone_number` or `mobile`; every other field can be used as `{{field}}`, and the whole body is `trigger.data`. Send `event_id` and a retried delivery is never enrolled twice. |

Somebody already walking the journey is not added again, and calls a journey places never trigger one.

## Events and goals

An event is something your system tells a journey happened to one contact — `paid`, `booked`. Deliver one with `POST /v1/journey_executions/{id}/events` and `{"event": "paid", "data": {...}}`, or without an API key to `{webhook_url}/events` with the contact's `phone`. It is kept as `events.<name>`, releases an `await` step waiting for it, and is checked against the journey's `goal`: a condition that ends the journey early, from wherever the contact is standing — `{"goal": {"when": {"conditions": [{"variable": "events.paid", "operator": "exists"}]}, "outcome": "paid"}}` stops the reminders the moment they pay.

## Journey-wide settings

`calling_hours` is a window every call step keeps to, on top of your account's compliance hours. `reentry` says whether somebody may go through the journey again: empty once their last run has finished, `"never"`, or a length of time such as `"720h"`.

## Results

`GET /v1/journeys/{id}/stats?version=N` counts contacts — not visits — per step, per way out (`exits`, keyed as the editor names a step's ports), per outcome, and each A/B split's paths by outcome. The editor draws the same numbers on the canvas.

**Shell**

```bash
curl -s -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d '{"name": "Asha", "phone": "+919000000000", "event_id": "lead-4812", "utm_source": "google"}'
```

## The node types

`GET /v1/journeys/node_types`

| Type | Does |
| --- | --- |
| `call` | Places an outbound call and **suspends until it has ended and been analysed**. The node every journey exists for, and the only one that takes minutes rather than milliseconds. Keys the caller pressed are in `nodes.<id>.keys`, in order — `"12"` for 1 then 2 — so a keypad menu's choice can route the journey: `keys contains "2"`. |
| `wait` | Holds the contact for a duration, or until a calling window opens. The node that makes a journey span days. |
| `retry` | Goes back to an earlier step, up to a number of times, optionally waiting between attempts. When the attempts run out it leaves through its own cases — the "gave up" way out. |
| `api` | One signed HTTP request to your own systems, with the response available to later branches. |
| `message` | One SMS, WhatsApp or email — the fallback channel for somebody who will not answer the phone. |
| `actions` | Runs one of your [workflows](https://voice.sphoro.com/docs/workflows) by id: book the appointment, update the CRM. |
| `branch` | Never acts. Routes, and leaves again in the same tick. Must have a catch-all. |
| `decide` | Asks a language model which of your `options` fits the most recent call — or any text you point `about` at — and records the key as `nodes.<id>.choice`, with its `reason`. Route on the choice; "none of these" is the empty string. |
| `await` | Holds the contact until a named `event` is delivered for them, or its `timeout` passes; `nodes.<id>.arrived` says which. |
| `split` | An A/B test: each contact takes one path by `weights`, and keeps it if they come back through. The path is `nodes.<id>.path` — "a", "b", …. |
| `set` | Writes `values` for later steps, messages and the goal: `{"stage": "qualified"}`. |
| `end` | Finishes with an outcome somebody can count. Reaching one is how a journey ends on purpose rather than by running out of graph. |

### On every node

| Field | Type | Description |
| --- | --- | --- |
| `id` required | string | Letters, digits and underscores. Cases point at it, and reports count by it — so once a version is published, changing it is not a rename but a different node. |
| `type` required | string | One of the types above. |
| `name` optional | string | What a person calls the node — `"Reminder call"`. Shown on the canvas and on the contact's timeline, and never read by the engine, so it can be retyped freely where the `id` cannot. |
| `description` optional | string | What the node is for, in a sentence. |
| `cases` optional | array | The ways out. See [cases](https://voice.sphoro.com/docs/journeys#cases-the-ways-out) below. |
| `position` optional | object | Where the editor drew the node: `{"x": 120, "y": 40}`. Presentation only. |

The journey itself carries `start_position`, the same `{"x", "y"}` shape, for where the editor draws its Start block. Presentation only, like `position` — and kept inside the definition so a layout survives being published, exported and imported.

### A call node

| Field | Type | Description |
| --- | --- | --- |
| `agent_id` required | string | The agent that makes the call. |
| `from_number` optional | string | Overrides the caller ID. Empty uses the agent's own. |
| `variables` optional | object | Prompt variables for the call, with `{{…}}` rendered against the execution's own data first — so a journey can tell the agent what the *previous* call established. |
| `await_analysis` optional | boolean | Unset means **true**, and that is almost always right. Waits not just for the call to end but for the post-call pass to have written its summary and extractions — a journey that branches on what the caller agreed to cannot branch one second after hangup, because nothing has read the transcript yet. |
| `analysis_timeout` optional | duration | How long to wait for that analysis before moving on with whatever the call record has. Analysis can fail; an execution must not wait on it forever. |
| `export` optional | boolean | Unset means **true**. Merges the call's extracted fields into the execution's top-level data, so a case can read `amount_due` rather than the full prefixed path. Later nodes overwrite earlier ones on a name collision; the prefixed path is always available and is the one to use when two calls extract the same name. |

### A wait node

| Field | Type | Description |
| --- | --- | --- |
| `for` optional | duration | `"30m"`, `"24h"`, `"72h"`. Empty waits no time at all, which is only useful with a window. |
| `window` optional | object | Delays the wake-up further until it falls inside a calling window — `{"after": "10:00", "before": "19:00", "weekdays": ["monday", …], "timezone": "Asia/Kolkata"}`. Applied *after* `for`, never instead of it. A window that closes before it opens — `"after": "20:00", "before": "08:00"` — runs overnight and belongs to the day it opened on. |

> **A window without a duration means "the next time this window is open".** That is how a journey says "first thing Monday". With a duration, it is what stops "wait a day" from a Friday evening dialling somebody at 8pm on Saturday — which the [compliance](https://voice.sphoro.com/docs/compliance) check would refuse anyway, but as a failed call rather than as a well-timed one.

### A retry node

| Field | Type | Description |
| --- | --- | --- |
| `attempts` required | integer | How many times to go back, from 1 to 20. Counts retries, not tries: a call followed by a retry node with `attempts: 2` places the call three times in all. |
| `to` required | string | The node to go back to — nearly always the call that did not connect. May not be the retry node itself. |
| `delay` optional | duration | How long to wait before each retry: `"30m"`, `"24h"`. Empty retries at once. |
| `window` optional | object | A calling window applied after `delay`, exactly as on a wait node. |

**JSON**

```json
{ "id": "again", "name": "Try again", "type": "retry",
  "retry": { "attempts": 2, "to": "remind", "delay": "24h",
             "window": {"after": "10:00", "before": "19:00"} },
  "cases": [{ "to": "sms", "label": "gave up" }] }
```

A retry node's `cases` are only ever the way out once its attempts are used up, and it needs at least one. On that last arrival it does not wait: a delay before "we stopped trying" only delays the fallback. It counts its own attempts, so `max_visits` and `on_exhausted` are refused on it.

### An api node

| Field | Type | Description |
| --- | --- | --- |
| `url` required | string | Rendered against the execution's data. Subject to the same egress guard as every outbound request this platform makes — a journey is customer-authored configuration reaching the network, so it may not name a private address. |
| `method` optional | string | Defaults to `POST`. |
| `headers` optional | object | Rendered against the execution's data. |
| `body` optional | object | Sent as JSON, rendered against the execution's data. |
| `save_as` optional | string | Where the decoded response lands, so later cases can branch on it. Empty saves it under the node's own path only. |
| `timeout` optional | duration | Per attempt, at most `60s`. |
| `max_attempts` optional | integer | Including the first try, at most 5. The node as a whole — every attempt and the pauses between them — is given 90 seconds. Retries are made **only** for a transport failure or a `5xx` — a `4xx` is your endpoint saying the request was wrong, and repeating it will not make it right. |

### Message, actions and end

**JSON**

```json
{ "type": "message", "message": { "channel": "sms", "text": "Hello {{first_name}}…" } }
{ "type": "actions", "actions": { "workflow_id": "wfl_…", "input": {"outcome": "{{outcome}}"} } }
{ "type": "end",     "end": { "outcome": "converted" } }
```

A message node's `channel` is `sms`, `whatsapp` or `email`; `to` defaults to the contact's own number or address. An end node's `outcome` is the label the funnel report counts under — `converted`, `unreachable`, `opted_out`. Empty is `completed`.

## Cases: the ways out

Tried in order of `priority`, then declaration. The first whose `when` holds is taken, and a case with no `when` is the catch-all — so it must be last.

**JSON**

```json
{
  "to": "arrange",
  "label": "answered and owes more than 5,000",
  "when": { "logic": "and", "conditions": [
    { "variable": "amount_due",    "operator": "gt", "value": 5000 },
    { "variable": "agreed_to_pay", "operator": "eq", "value": false }
  ]}
}
```

Deliberately the same condition shape a [flow](https://voice.sphoro.com/docs/flows) edge uses: an account that has learned to write `amount gte 5000` in an in-call flow should not have to learn a second language to write it here. Declare anything numeric in the journey's `variables` map, for the same reason and with the same silent failure if you do not.

## Bounds, because a journey can loop

"No answer, wait a day, try again" *is* a cycle, and it is the most common journey there is. So cycles cannot be banned — they are bounded at run time instead.

| Field | Type | Description |
| --- | --- | --- |
| `max_visits` optional | integer | On a node. How many times one execution may stand here. This is the retry count, and it is the bound an operator actually reasons about — "try the call three times" is a property of the call node, not of the graph. Not allowed on a `retry` node, which counts its own attempts. |
| `on_exhausted` optional | string | On a node. Where to go when `max_visits` is reached. This is the "gave up after three tries" edge, and it is the one people forget to draw. |
| `max_steps` optional | integer | On the journey. Caps how many nodes one execution may visit at all. Without it a mis-drawn edge dials somebody forever. |
| `max_lifetime` optional | duration | On the journey. How long one execution may live — `"168h"`. A contact still walking the graph after this is ended `expired`: a reminder that arrives three months late is worse than none. |
| `on_no_match` optional | string | On the journey. Where an execution goes when a node's cases all fail and none is a catch-all. Empty ends it `stuck`, which is the honest outcome and is reported as one — a contact silently dropped is the failure this names. |

## Running one contact through it

`POST /v1/journey_executions`

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/journey_executions \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "journey_id": "'"$JOURNEY_ID"'",
    "contact": {"phone": "+919876543210", "email": "priya@example.com"},
    "variables": {"first_name": "Priya", "amount_due": 8400}
  }'
```

`GET /v1/journey_executions/{id}`

`POST /v1/journey_executions/{id}/cancel`

**Shell**

```bash
# Where is this contact, and how did they get there?
curl -s https://voice.sphoro.com/v1/journey_executions/$EXECUTION_ID \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

An execution carries its timeline: which nodes it visited, which case fired at each, and the calls it placed. That is the debugging story — a contact who ended up somewhere unexpected has a trail saying exactly which condition took them there.

## Journey, flow, or neither?

| You want | Reach for |
| --- | --- |
| The conversation to follow steps in order | A [flow](https://voice.sphoro.com/docs/flows) |
| To call back tomorrow if nobody answers | `auto_reschedule_seconds` on the agent — see [conversation behaviour](https://voice.sphoro.com/docs/behaviours) |
| To call back, then try SMS, then give up, and count the outcomes | A journey |
| To do that for four thousand people | A journey, run as a [campaign](https://voice.sphoro.com/docs/campaigns) |
| To update your CRM after each call | A [workflow](https://voice.sphoro.com/docs/workflows) — no graph needed |

Sphoro Voice also exposes the whole journey schema to a coding assistant, which is a far better way to draft a graph than typing this JSON — see [build with AI](https://voice.sphoro.com/docs/build-with-ai).
