Make a call
Two objects and one command. Create an agent — the configuration that decides how
the conversation goes — then place a call that runs it. Everything below assumes
$SPHORO_API_KEY is set from step 1.
Step 1 · Create an agent
An agent is long-lived: create it once and place as many calls with it as you like. The only two things it insists on are a name and one language carrying the instructions it follows.
/v1/agentsAGENT_ID=$(curl -s -X POST https://voice.sphoro.com/v1/agents \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Clinic reception",
"languages": [{
"code": "en-IN",
"system_prompt": "You are a receptionist for Acme Clinic. Answer questions about opening hours and appointments. Keep answers to one or two sentences.",
"greeting": "Thanks for calling Acme Clinic. How can I help?"
}]
}' | jq -r .id)
echo $AGENT_ID
# agt_7f3ab2c19e4dconst res = await fetch("https://voice.sphoro.com/v1/agents", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SPHORO_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Acme Clinic reception",
languages: [{
code: "en-IN",
system_prompt:
"You are a warm, efficient receptionist for Acme Clinic. Answer questions about " +
"opening hours and appointments. Keep every answer to one or two sentences.",
greeting: "Thanks for calling Acme Clinic. How can I help?",
}],
}),
});
const agent = await res.json();
console.log(agent.id); // agt_7f3ab2c19e4dimport os, json, urllib.request
body = json.dumps({
"name": "Acme Clinic reception",
"languages": [{
"code": "en-IN",
"system_prompt": (
"You are a warm, efficient receptionist for Acme Clinic. Answer questions "
"about opening hours and appointments. Keep every answer to one or two sentences."
),
"greeting": "Thanks for calling Acme Clinic. How can I help?",
}],
}).encode()
req = urllib.request.Request(
"https://voice.sphoro.com/v1/agents",
data=body,
headers={
"Authorization": "Bearer " + os.environ["SPHORO_API_KEY"],
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req) as res:
agent = json.load(res)
print(agent["id"]) # agt_7f3ab2c19e4d{
"id": "agt_7f3ab2c19e4d",
"object": "agent",
"name": "Acme Clinic reception",
"languages": [{
"code": "en-IN",
"system_prompt": "You are a receptionist for Acme Clinic. Answer questions...",
"greeting": "Thanks for calling Acme Clinic. How can I help?"
}],
"model": "claude-sonnet-5",
"temperature": 0.6,
"max_tokens": 512,
"speed": 1,
"pitch": 1,
"created_at": "2026-08-18T09:12:44Z"
}Keep the id. It is the one thing every call needs.
Every field an agent takes
Two are required. The rest have the defaults shown above, and every one of them can be changed
later with PATCH /v1/agents/{id} without touching the others.
| Field | Type | Description |
|---|---|---|
namerequired | string | A label for you, up to 120 characters. Never spoken aloud. |
languagesrequired | array | Every language it speaks, the one calls open in first — and where the agent's instructions live. Each entry has a code, a system_prompt, an optional voice_id, and a greeting — the first thing said, before the caller speaks. The first entry's prompt is the agent's own, up to 100,000 characters: say what it does, what it must not do, and how long its answers should be, because voice punishes long answers far more than chat does. A later language that leaves its prompt empty is read against the first one with an instruction to speak that language in front. Without a greeting the agent waits for the caller to open, which on an outbound call is a silence they will hang up on. Every accepted code, and what a voice is, is in step 4. |
speedoptional | number, 0.25–4 or 0 | Speaking rate, 1 being the voice's natural pace. Send 0 for whatever the deployment plays at, which is also what an agent that has never been given one carries. |
pitchoptional | number, 0.25–4 or 0 | Pitch, 1 being natural. Send 0 for the deployment's own. |
filler_phraseoptional | string | Said while a slow lookup runs, so the line is not silent — "let me check that for you". |
modeloptional | string | The language model behind the conversation. Defaults to claude-sonnet-5. Leave it alone unless you have measured a reason. |
temperatureoptional | number, 0–2 | How much the wording varies. Defaults to 0.6. Below 0.3 sounds clipped; above 1 starts improvising facts. |
max_tokensoptional | integer, 1–4000 or 0 | Ceiling on one reply. Defaults to 512, which is roughly 30 seconds of speech — and the ceiling is low on purpose, because a long reply is one the caller talks over. Send 0 for the deployment's own default, which is also what an agent with no model carries. |
record_callsoptional | boolean | Record the audio, retrievable afterwards. Defaults to off. Recording people has legal conditions that differ by country — that is your call to make, not ours. |
transfer_numberoptional | string | The one number in E.164 the agent may transfer a caller to. A single number rather than a list, because an agent that can dial anywhere is one prompt injection away from dialling anywhere. |
webhook_urloptional | string | An https URL for this agent's events alone. Most integrations want one account-wide endpoint instead — step 5. |
carrieroptional | string | Which carrier places this agent's outbound calls, overriding the deployment default. Leave empty unless you have been told otherwise. |
descriptionoptional | string | A note to yourself. Never spoken, never sent to the model. |
metadataoptional | object | Your own string key/values, returned on every read. Useful for the id this agent has in your system. |
Step 2 · Place the call
One request. It returns immediately with a queued call — dialling, ringing and
answering happen after the response, and you hear about them through
events or
webhooks.
/v1/callsCALL_ID=$(curl -s -X POST https://voice.sphoro.com/v1/calls \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d "{
\"agent_id\": \"$AGENT_ID\",
\"to\": \"+919876543210\"
}" | jq -r .id)
echo $CALL_ID
# call_1d8e4c7b9a02import { randomUUID } from "node:crypto";
const res = await fetch("https://voice.sphoro.com/v1/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SPHORO_API_KEY}`,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
agent_id: agent.id,
to: "+919876543210",
}),
});
const call = await res.json();
console.log(call.id, call.status); // call_1d8e4c7b9a02 queuedimport os, json, uuid, urllib.request
body = json.dumps({"agent_id": agent["id"], "to": "+919876543210"}).encode()
req = urllib.request.Request(
"https://voice.sphoro.com/v1/calls",
data=body,
headers={
"Authorization": "Bearer " + os.environ["SPHORO_API_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req) as res:
call = json.load(res)
print(call["id"], call["status"]) # call_1d8e4c7b9a02 queued{
"id": "call_1d8e4c7b9a02",
"object": "call",
"agent_id": "agt_7f3ab2c19e4d",
"status": "queued",
"direction": "outbound",
"from": "+911140848000",
"to": "+919876543210",
"language": "en-IN",
"source": "sdk",
"source_client": "sphoro-voice-node/1.0.0",
"created_at": "2026-08-18T09:14:02Z",
"started_at": "2026-08-18T09:14:02Z"
}Every field a call takes
| Field | Type | Description |
|---|---|---|
agent_idrequired | string | The agent to run. It must belong to your account; another account's id is a validation error, not a 404. |
tooptional | string | The number to dial, in E.164 — a leading +, country code, no spaces or dashes. Required for a phone call. Omit it for a browser call. |
fromoptional | string | The caller ID to present. Omit it and we pick one for you — the number assigned to member_id if you sent one, otherwise the agent's own list, rotating so the same recipient keeps seeing the same number. |
member_idoptional | string | Who is placing the call, when a person is. One agent serves your whole team, so the agent alone cannot say who dialled — see calling on somebody's behalf. |
directionoptional | string | outbound, inbound or web. Inferred rather than sent: outbound when to is present, web when it is not. |
languageoptional | string | Opens this one call in one of the agent's languages. Defaults to the first. A language the agent does not speak is refused — see step 4. |
variablesoptional | object | String key/values that fill {{placeholders}} in the prompt and greeting — see personalising a single call. |
metadataoptional | object | Your own string key/values, stored on the call and returned on every read and every webhook. Put your order id here. |
carrieroptional | string | Which carrier places this one call, overriding the agent's and the deployment's choice. Only valid on an outbound call. |
Where a call came from
Every call comes back with a source, and it is not a field you send. It is
derived from the endpoint you reached and the credential you used, so nothing can file itself
under the wrong one:
| Source | Means |
|---|---|
app | Placed on one of our own surfaces by a signed-in person — the operator console, the dialer, a test call from an agent page. |
sdk | Placed through one of the published clients, or minted at
POST /v1/realtime/tokens for a browser. source_client names which,
and at what version. |
api | A direct request whose User-Agent named no client
we recognise. Your own integration, or curl. |
campaign | The campaign dialler, working through a list. Nobody asked for this call on its own. |
inbound | A carrier delivered it. Nothing on this side placed it,
and source_client names the carrier. |
direction tells you which way the audio went; this tells you which of your
integrations produced the call, which stops being the same question the moment you have more
than one. source_client is free text and is absent when nothing named itself —
read it, do not branch on it. Both are absent on calls placed before we recorded this, which
is not the same as api.
Idempotency-Key. If the
connection drops after we accept the request but before you read the response, retrying without
one places a second call to a real person. With one, the retry returns the original call. Generate
it when you decide to dial and reuse it for every retry of that decision —
step 2 has the detail.Step 3 · Follow the call as it happens
Open a stream and read events as they occur — transcript turns, transfers, the end reason. It
is Server-Sent Events, so curl can read it and browsers reconnect to it
automatically.
/v1/calls/{id}/eventscurl -N https://voice.sphoro.com/v1/calls/$CALL_ID/events \
-H "Authorization: Bearer $SPHORO_API_KEY"id: evt_01
event: call.started
data: {"id":"evt_01","type":"call.started","call_id":"call_1d8e4c7b9a02","created_at":"2026-08-18T09:14:02Z","data":{"agent_id":"agt_7f3ab2c19e4d","direction":"outbound","to":"+919876543210","from":"+911140848000"}}
id: evt_02
event: call.transcript.updated
data: {"id":"evt_02","type":"call.transcript.updated","call_id":"call_1d8e4c7b9a02","created_at":"2026-08-18T09:14:19Z","data":{"role":"user","text":"are you open on Sunday?"}}
id: evt_03
event: call.ended
data: {"id":"evt_03","type":"call.ended","call_id":"call_1d8e4c7b9a02","created_at":"2026-08-18T09:15:31Z","data":{"duration_seconds":89.2,"turns":6,"end_reason":"completed"}}
event: done
data: {}Three things about this stream are worth knowing before you build on it:
- It ends with an
event: doneframe aftercall.ended, so a client knows the difference between "finished" and "the connection dropped". - Every frame carries an
id. Reconnect withLast-Event-IDset to the last one you saw and you get everything you missed, rather than a gap. - A comment line arrives every 15 seconds during a quiet call, which is what stops a proxy idling the connection out.
Step 4 · Read the result
Once the call is over, fetch it. The record carries how it ended, how long it lasted, and the whole conversation.
/v1/calls/{id}curl -s https://voice.sphoro.com/v1/calls/$CALL_ID \
-H "Authorization: Bearer $SPHORO_API_KEY"{
"id": "call_1d8e4c7b9a02",
"object": "call",
"agent_id": "agt_7f3ab2c19e4d",
"status": "completed",
"direction": "outbound",
"from": "+911140848000",
"to": "+919876543210",
"language": "en-IN",
"started_at": "2026-08-18T09:14:02Z",
"ended_at": "2026-08-18T09:15:31Z",
"duration_seconds": 89.2,
"end_reason": "completed",
"source": "sdk",
"source_client": "sphoro-voice-node/1.0.0",
"turns": 6,
"transcript": [
{ "role": "agent", "text": "Thanks for calling Acme Clinic. How can I help?", "at": "2026-08-18T09:14:11Z" },
{ "role": "user", "text": "are you open on Sunday?", "at": "2026-08-18T09:14:19Z", "latency_ms": 640 }
],
"recording_url": "https://voice.sphoro.com/v1/calls/call_1d8e4c7b9a02/recording"
}Just the transcript
The same conversation without the rest of the record, which is what you want if you are rendering it in a UI:
/v1/calls/{id}/transcriptcurl -s https://voice.sphoro.com/v1/calls/$CALL_ID/transcript \
-H "Authorization: Bearer $SPHORO_API_KEY"{
"object": "transcript",
"call_id": "call_1d8e4c7b9a02",
"status": "completed",
"turns": 6,
"entries": [ ... ]
}Each entry carries a role of user, agent or
tool; the text; when it was said; and, on the caller's turns,
latency_ms — how long the agent took to start answering. An entry with
"source": "keypad" was typed on the keypad rather than spoken, which matters because
"1" pressed and "one" said arrive as the same string.
The recording
Only if the agent was created with record_calls: true. The endpoint streams the
audio itself, authenticated the same way as everything else:
/v1/calls/{id}/recordingcurl -s https://voice.sphoro.com/v1/calls/$CALL_ID/recording \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-o call.wavA call with no recording answers 404 and not_found rather than an
empty file.
What each call status means
| Status | Means |
|---|---|
queued | Accepted, not yet dialled. Every outbound call starts here. |
in_progress | Connected and talking. A browser call starts here, since there is nothing to dial. |
completed | Finished. Read end_reason to know how — a
call that went to voicemail also completes. |
failed | Never got to a conversation. |
And why it ended
end_reason is the field to branch on, not status. Reaching voicemail
and reaching the customer both end a call the same way.
| End reason | Means |
|---|---|
completed | The conversation ran to its end. |
voicemail | An answering machine picked up, not a person. |
no_answer | Rang out. |
busy | The line was busy. |
canceled | Cancelled before it connected. |
rejected | The far end refused the call outright. |
bad_number | There is no such number to reach. |
unanswered | It rang and nobody picked up. On a console call, the carrier reported the far end ringing and the ring window then ran out. |
not_connected | It never rang. The number was dialled and the far
end's telephone never started, so the attempt never left the carrier — which is a different
thing from unanswered and worth a different response. |
dropped | A connected leg stopped sending without hanging up. |
declined | An operator refused it while it rang. |
missed | It rang on a line a person answers and nobody took it. |
caller_gave_up | The caller hung up while it was still ringing. |
ended_by_api | You ended it — see below. |
carrier_failure | The carrier could not place it. Bad number, blocked route, no credit. |
carrier_disconnected | The line dropped mid-conversation. Also how a transferred call ends here, since the carrier tears the media stream down once the caller is with a person. |
announcement | A normal agent with no transfer number read
its greeting out and hung up. |
transfer_failed | A normal agent could not hand the call
over. It has no model to apologise with, so the call ends rather than holding the caller on a
line nobody is coming to answer. |
at_capacity | Your account was at its concurrent-call ceiling. Retry it. |
no_input | The caller went quiet. A conversation agent asks once
whether they are still there and, hearing nothing more, says goodbye; a keypad menu re-reads
its options and then gives up. The timings are the agent's conversation
settings. |
max_duration | The call reached the agent's
conversation.max_call_seconds. The agent said a short goodbye first. |
over_budget | The project this agent bills to was over its monthly budget, so the call was refused with the busy line. |
Ending a call early
Hangs up the phone leg as well as closing the conversation. Both matter: closing only our side leaves a carrier leg up, and you are billed for every minute of it.
/v1/calls/{id}/endcurl -s -X POST https://voice.sphoro.com/v1/calls/$CALL_ID/end \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason":"customer_requested"}'reason is optional and is stored as the call's end_reason; omit it
and you get ended_by_api.
Listing calls
Newest first, cursor-paginated like every list on the API. Two filters, applied before
pagination — so ?status=failed pages through failures rather than through the first
page of everything.
/v1/callscurl -s "https://voice.sphoro.com/v1/calls?status=failed&limit=20" \
-H "Authorization: Bearer $SPHORO_API_KEY"
curl -s "https://voice.sphoro.com/v1/calls?agent_id=$AGENT_ID" \
-H "Authorization: Bearer $SPHORO_API_KEY"List responses omit transcripts — fetch a single call for those. Paging is covered in step 2.
Calling on somebody’s behalf
One agent serves your whole team. That is the point of it — fifty people who make the same kind of call share one prompt, one voice and one set of documents, and you maintain one thing instead of fifty. But an agent is a configuration, not a person, so a call placed through it says nothing about who made it.
If your own system dials on a colleague’s behalf — a CRM with a click-to-call button,
a collections queue, a callback worker — send member_id:
/v1/callscurl -s -X POST https://voice.sphoro.com/v1/calls \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"agent_id\": \"$AGENT_ID\",
\"to\": \"+919876543210\",
\"member_id\": \"$MEMBER_ID\"
}"Two things follow, and both are what the console already does for somebody signed in:
- The caller ID becomes their own number — whichever of your numbers is assigned to them on the Phone numbers page. So when the person they rang calls back, it reaches them and not a colleague. Without this the call goes out on the agent’s rotation, and the callback lands wherever that rotation pointed.
- The call is attributed to them —
member_idcomes back on the call and on every webhook, and the call appears in that person’s own history rather than the account’s.
member_id is the member’s id on this
platform, not an id from your system. A member with no number assigned to them falls back to the
agent’s rotation, and an id from another account matches nothing at all — the lookup only
ever sees your own numbers.An explicit from still wins over both. If your system has already decided which
number to present, that decision is not overruled by a seat it does not know about — and the call
is still attributed.
Personalising a single call
Write {{placeholders}} into a language's prompt or greeting, then fill them per
call. This is how one agent serves every customer without a prompt per customer.
{
"languages": [
{
"code": "en-IN",
"system_prompt": "You are confirming an appointment for {{first_name}} on {{date}} at {{time}}.",
"greeting": "Hello {{first_name}}, this is Acme Clinic calling about your appointment on {{date}}."
}
]
}curl -s -X POST https://voice.sphoro.com/v1/calls \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d "{
\"agent_id\": \"$AGENT_ID\",
\"to\": \"+919876543210\",
\"variables\": {
\"first_name\": \"Priya\",
\"date\": \"Thursday the 21st\",
\"time\": \"4:30 in the afternoon\"
}
}"{{date}} without supplying one is refused with
validation_error naming it. That is deliberate: the alternative is an agent reading
"your appointment on curly-curly-date" to a customer, and there is no undo on a phone call. The
eight the platform fills itself are the exception — see below.Write values the way they should be spoken. "4:30 in the afternoon" is read back
correctly; "16:30" is a coin toss. The same goes for dates, amounts and reference
numbers.
Variables the platform fills
Eight placeholders are not yours to supply. The platform already knows them, fills them when the call is answered, and never refuses a call for their absence — so a prompt may use them freely and a campaign spreadsheet needs no column for them.
| Field | Type | Description |
|---|---|---|
agent_idoptional | string | The agent taking the call. |
agent_nameoptional | string | The agent's name, as you named it. Callers never hear it unless the prompt says it. |
call_idoptional | string | This call's id — the one call records, webhooks and recordings use. |
call_directionoptional | string | inbound, outbound, or a browser. |
from_numberoptional | string | The number presented. unknown on a call held in a browser. |
to_numberoptional | string | The number dialled. unknown on a call held in a browser. |
current_dateoptional | string | Today, where the agent is — 8 September 2026. Read in the agent's timezone. |
current_timeoptional | string | Now, to the minute — 4:12 pm. Worked out when the call is answered, not when it is created, so a campaign row queued at nine and dialled at four says four. |
Sending one of these in variables yourself is not an error and is not overridden:
your value wins. An agent that has always filled {{current_date}} from its own
spreadsheet column keeps doing exactly that.
Calling from a browser
Leave to out and you get a web call instead of a phone call: no
carrier, no number, no per-minute charge, and the response carries a WebSocket URL to connect a
browser to. It is the fastest way to hear an agent before a phone number is provisioned.
curl -s -X POST https://voice.sphoro.com/v1/calls \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"agent_id\":\"$AGENT_ID\"}"{
"id": "call_1d8e4c7b9a02",
"status": "in_progress",
"direction": "web",
"realtime_url": "wss://voice.sphoro.com/v1/realtime?call_id=call_1d8e4c7b9a02"
}Connect to realtime_url and exchange JSON frames. Audio travels base64-encoded in
both directions.
The audio is G.711 µ-law at 8 kHz, each way. Read it off
session.created rather than assuming it — that message carries
input_audio_format, output_audio_format and sample_rate,
and a client that hardcodes them plays noise the day a deployment negotiates something else.
getUserMedia produces floating-point samples at the device rate, almost always
48 kHz, so a browser client has to low-pass and decimate before companding — and in that
order. Dropping five samples in six without filtering first folds everything above 4 kHz
back into the speech band, which is not muffled audio, it is unintelligible audio. Do it in an
AudioWorklet: a ScriptProcessorNode runs on the main thread, so every
render competes with the microphone.Two more things a client has to get right, and both fail quietly. Pace the
playback — queue the arriving frames and let the audio clock drain them, because
writing a long buffer out as fast as it arrives ends the agent’s turn early and takes
barge-in with it. And when the caller talks over the agent, send
interrupt and empty your own buffer: the message stops the server
generating, and only the flush stops the sound.
| You send | Meaning |
|---|---|
input_audio.append | A chunk of microphone audio. |
input_audio.commit | End of the caller's turn, if you are doing your own turn detection. |
input_text.append | A typed turn, for testing without a microphone. |
interrupt | The caller started talking over the agent. Stop speaking. |
session.update | Change language, voice or audio format mid-call. |
session.end | Hang up. |
| You receive | Meaning |
|---|---|
session.created | Connected. Carries the resolved session configuration. |
transcript.partial · transcript.final | What the caller is saying, as it is recognised and once it settles. |
response.started · response.done | The agent began and finished a reply. |
response.audio.delta | A chunk of the agent's speech. Play it as it arrives. |
response.text.delta | The same reply as text, for captions. |
state.changed | Listening, thinking, speaking — enough to drive an indicator. |
error | Something went wrong, in the same shape as an HTTP error. |
session.ended | The call is over, with the reason and turn count. |
?access_token= query parameter. Do not put a long-lived API key
there: have your server create the call and hand the browser the realtime_url it
returns.When a call will not go out
| What you see | Why |
|---|---|
422 on agent_id | The id is not one of your agents, or it belongs to another account. Check the value you captured. |
422 on to | Missing on an outbound call, or not E.164.
+919876543210, not 09876543210. |
422 on variables | The prompt or greeting has a placeholder you did not fill. |
402 plan_limit_reached | Your plan's ceiling. Retrying will not help. |
Status failed, carrier_failure | The request was fine and the carrier could not place it — unreachable number, blocked route, no credit. |
Ends immediately, at_capacity | You were at your concurrent-call ceiling. Retry. |
Every code above is explained in full in step 2.
Check it before you move on
- The call connected, and
end_reasonwascompleted. - The transcript shows both sides of the conversation.
- You are sending an
Idempotency-Keyon every POST that dials.
Next: step 4 makes it speak the right language in the right voice.