sphoroVOICEdocs
Markdown

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

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.
EndpointDoes
POST /v1/journeysCreate one.
GET /v1/journeys/{id}/draftRead the working copy.
PUT /v1/journeys/{id}/draftSave the working copy. Runs nothing.
POST /v1/journeys/{id}/validateCheck the draft without publishing it.
POST /v1/journeys/{id}/publishMake the draft the version new executions run.
GET /v1/journeys/{id}/versionsEvery published version.
GET /v1/journeys/{id}/versions/{version}One of them, exactly as it was.
PUT/v1/journeys/{id}/draft
Shell
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
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}.

TriggerAdds
call_endedThe 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.
webhookWhoever 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
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
TypeDoes
callPlaces 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".
waitHolds the contact for a duration, or until a calling window opens. The node that makes a journey span days.
retryGoes 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.
apiOne signed HTTP request to your own systems, with the response available to later branches.
messageOne SMS, WhatsApp or email — the fallback channel for somebody who will not answer the phone.
actionsRuns one of your workflows by id: book the appointment, update the CRM.
branchNever acts. Routes, and leaves again in the same tick. Must have a catch-all.
decideAsks 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.
awaitHolds the contact until a named event is delivered for them, or its timeout passes; nodes.<id>.arrived says which.
splitAn 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", ….
setWrites values for later steps, messages and the goal: {"stage": "qualified"}.
endFinishes 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

FieldTypeDescription
id
required
stringLetters, 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
stringOne of the types above.
name
optional
stringWhat 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
stringWhat the node is for, in a sentence.
cases
optional
arrayThe ways out. See cases below.
position
optional
objectWhere 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

FieldTypeDescription
agent_id
required
stringThe agent that makes the call.
from_number
optional
stringOverrides the caller ID. Empty uses the agent's own.
variables
optional
objectPrompt 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
booleanUnset 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
durationHow 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
booleanUnset 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

FieldTypeDescription
for
optional
duration"30m", "24h", "72h". Empty waits no time at all, which is only useful with a window.
window
optional
objectDelays 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 check would refuse anyway, but as a failed call rather than as a well-timed one.

A retry node

FieldTypeDescription
attempts
required
integerHow 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
stringThe node to go back to — nearly always the call that did not connect. May not be the retry node itself.
delay
optional
durationHow long to wait before each retry: "30m", "24h". Empty retries at once.
window
optional
objectA calling window applied after delay, exactly as on a wait node.
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

FieldTypeDescription
url
required
stringRendered 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
stringDefaults to POST.
headers
optional
objectRendered against the execution's data.
body
optional
objectSent as JSON, rendered against the execution's data.
save_as
optional
stringWhere the decoded response lands, so later cases can branch on it. Empty saves it under the node's own path only.
timeout
optional
durationPer attempt, at most 60s.
max_attempts
optional
integerIncluding 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
{ "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
{
  "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.

FieldTypeDescription
max_visits
optional
integerOn 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
stringOn 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
integerOn the journey. Caps how many nodes one execution may visit at all. Without it a mis-drawn edge dials somebody forever.
max_lifetime
optional
durationOn 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
stringOn 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
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
# 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 wantReach for
The conversation to follow steps in orderA flow
To call back tomorrow if nobody answersauto_reschedule_seconds on the agent — see conversation behaviour
To call back, then try SMS, then give up, and count the outcomesA journey
To do that for four thousand peopleA journey, run as a campaign
To update your CRM after each callA 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.