Journeys
A flow 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
| 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. |
/v1/journeys/{id}/draftcurl -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"} }
]
}'/v1/journeys/{id}/publishcurl -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.
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
/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 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 |
|---|---|---|
idrequired | 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. |
typerequired | string | One of the types above. |
nameoptional | 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. |
descriptionoptional | string | What the node is for, in a sentence. |
casesoptional | array | The ways out. See cases below. |
positionoptional | 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_idrequired | string | The agent that makes the call. |
from_numberoptional | string | Overrides the caller ID. Empty uses the agent's own. |
variablesoptional | 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_analysisoptional | 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_timeoutoptional | 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. |
exportoptional | 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 |
|---|---|---|
foroptional | duration | "30m", "24h",
"72h". Empty waits no time at all, which is only useful with a
window. |
windowoptional | 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 retry node
| Field | Type | Description |
|---|---|---|
attemptsrequired | 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. |
torequired | string | The node to go back to — nearly always the call that did not connect. May not be the retry node itself. |
delayoptional | duration | How long to wait before each retry:
"30m", "24h". Empty retries at once. |
windowoptional | object | A calling window applied after delay,
exactly as on a wait node. |
{ "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 |
|---|---|---|
urlrequired | 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. |
methodoptional | string | Defaults to POST. |
headersoptional | object | Rendered against the execution's data. |
bodyoptional | object | Sent as JSON, rendered against the execution's data. |
save_asoptional | string | Where the decoded response lands, so later cases can branch on it. Empty saves it under the node's own path only. |
timeoutoptional | duration | Per attempt, at most 60s. |
max_attemptsoptional | 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
{ "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.
{
"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 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_visitsoptional | 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_exhaustedoptional | 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_stepsoptional | integer | On the journey. Caps how many nodes one execution may visit at all. Without it a mis-drawn edge dials somebody forever. |
max_lifetimeoptional | 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_matchoptional | 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
/v1/journey_executionscurl -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}
}'/v1/journey_executions/{id}/v1/journey_executions/{id}/cancel# 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 |
| To call back tomorrow if nobody answers | auto_reschedule_seconds on
the agent — see conversation behaviour |
| 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 |
| To update your CRM after each call | A workflow — 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.