sphoroVOICEdocs
Markdown

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.

POST/v1/agents
AGENT_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_7f3ab2c19e4d
const 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_7f3ab2c19e4d
import 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
Response201 Created
{
  "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.

FieldTypeDescription
name
required
stringA label for you, up to 120 characters. Never spoken aloud.
languages
required
arrayEvery 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.
speed
optional
number, 0.25–4 or 0Speaking 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.
pitch
optional
number, 0.25–4 or 0Pitch, 1 being natural. Send 0 for the deployment's own.
filler_phrase
optional
stringSaid while a slow lookup runs, so the line is not silent — "let me check that for you".
model
optional
stringThe language model behind the conversation. Defaults to claude-sonnet-5. Leave it alone unless you have measured a reason.
temperature
optional
number, 0–2How much the wording varies. Defaults to 0.6. Below 0.3 sounds clipped; above 1 starts improvising facts.
max_tokens
optional
integer, 1–4000 or 0Ceiling 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_calls
optional
booleanRecord 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_number
optional
stringThe 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_url
optional
stringAn https URL for this agent's events alone. Most integrations want one account-wide endpoint instead — step 5.
carrier
optional
stringWhich carrier places this agent's outbound calls, overriding the deployment default. Leave empty unless you have been told otherwise.
description
optional
stringA note to yourself. Never spoken, never sent to the model.
metadata
optional
objectYour own string key/values, returned on every read. Useful for the id this agent has in your system.
The agent accepts further fields for knowledge bases, callable functions and keypad menus, which are outside this guide — everything needed to get a call connected is above. The complete schema is in openapi.json.

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.

POST/v1/calls
CALL_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_1d8e4c7b9a02
import { 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 queued
import 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
Response201 Created
{
  "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

FieldTypeDescription
agent_id
required
stringThe agent to run. It must belong to your account; another account's id is a validation error, not a 404.
to
optional
stringThe 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.
from
optional
stringThe 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_id
optional
stringWho 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.
direction
optional
stringoutbound, inbound or web. Inferred rather than sent: outbound when to is present, web when it is not.
language
optional
stringOpens 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.
variables
optional
objectString key/values that fill {{placeholders}} in the prompt and greeting — see personalising a single call.
metadata
optional
objectYour own string key/values, stored on the call and returned on every read and every webhook. Put your order id here.
carrier
optional
stringWhich 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:

SourceMeans
appPlaced on one of our own surfaces by a signed-in person — the operator console, the dialer, a test call from an agent page.
sdkPlaced through one of the published clients, or minted at POST /v1/realtime/tokens for a browser. source_client names which, and at what version.
apiA direct request whose User-Agent named no client we recognise. Your own integration, or curl.
campaignThe campaign dialler, working through a list. Nobody asked for this call on its own.
inboundA 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.

Always send an 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.

GET/v1/calls/{id}/events
Shell
curl -N https://voice.sphoro.com/v1/calls/$CALL_ID/events \
  -H "Authorization: Bearer $SPHORO_API_KEY"
Event stream
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: done frame after call.ended, so a client knows the difference between "finished" and "the connection dropped".
  • Every frame carries an id. Reconnect with Last-Event-ID set 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.

This stream is for watching one call you are already holding open — a live UI, or a terminal while you test. For a server that needs to know about every call without holding a connection per call, use webhooks instead.

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.

GET/v1/calls/{id}
Shell
curl -s https://voice.sphoro.com/v1/calls/$CALL_ID \
  -H "Authorization: Bearer $SPHORO_API_KEY"
Response200 OK
{
  "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:

GET/v1/calls/{id}/transcript
Shell
curl -s https://voice.sphoro.com/v1/calls/$CALL_ID/transcript \
  -H "Authorization: Bearer $SPHORO_API_KEY"
Response200 OK
{
  "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:

GET/v1/calls/{id}/recording
Shell
curl -s https://voice.sphoro.com/v1/calls/$CALL_ID/recording \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -o call.wav

A call with no recording answers 404 and not_found rather than an empty file.

What each call status means

StatusMeans
queuedAccepted, not yet dialled. Every outbound call starts here.
in_progressConnected and talking. A browser call starts here, since there is nothing to dial.
completedFinished. Read end_reason to know how — a call that went to voicemail also completes.
failedNever 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 reasonMeans
completedThe conversation ran to its end.
voicemailAn answering machine picked up, not a person.
no_answerRang out.
busyThe line was busy.
canceledCancelled before it connected.
rejectedThe far end refused the call outright.
bad_numberThere is no such number to reach.
unansweredIt rang and nobody picked up. On a console call, the carrier reported the far end ringing and the ring window then ran out.
not_connectedIt 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.
droppedA connected leg stopped sending without hanging up.
declinedAn operator refused it while it rang.
missedIt rang on a line a person answers and nobody took it.
caller_gave_upThe caller hung up while it was still ringing.
ended_by_apiYou ended it — see below.
carrier_failureThe carrier could not place it. Bad number, blocked route, no credit.
carrier_disconnectedThe 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.
announcementA normal agent with no transfer number read its greeting out and hung up.
transfer_failedA 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_capacityYour account was at its concurrent-call ceiling. Retry it.
no_inputThe 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_durationThe call reached the agent's conversation.max_call_seconds. The agent said a short goodbye first.
over_budgetThe 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.

POST/v1/calls/{id}/end
Shell
curl -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.

GET/v1/calls
Shell
curl -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:

POST/v1/calls
Shell
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\",
    \"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 themmember_id comes 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.

On the agent, once
{
  "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}}."
    }
  ]
}
On each call
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\"
    }
  }"
A missing variable fails the request, not the call. Placing a call whose prompt needs {{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.

FieldTypeDescription
agent_id
optional
stringThe agent taking the call.
agent_name
optional
stringThe agent's name, as you named it. Callers never hear it unless the prompt says it.
call_id
optional
stringThis call's id — the one call records, webhooks and recordings use.
call_direction
optional
stringinbound, outbound, or a browser.
from_number
optional
stringThe number presented. unknown on a call held in a browser.
to_number
optional
stringThe number dialled. unknown on a call held in a browser.
current_date
optional
stringToday, where the agent is — 8 September 2026. Read in the agent's timezone.
current_time
optional
stringNow, 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.

Shell
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\"}"
Response201 Created
{
  "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.

A browser will not hand you 8 kHz. 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 sendMeaning
input_audio.appendA chunk of microphone audio.
input_audio.commitEnd of the caller's turn, if you are doing your own turn detection.
input_text.appendA typed turn, for testing without a microphone.
interruptThe caller started talking over the agent. Stop speaking.
session.updateChange language, voice or audio format mid-call.
session.endHang up.
You receiveMeaning
session.createdConnected. Carries the resolved session configuration.
transcript.partial · transcript.finalWhat the caller is saying, as it is recognised and once it settles.
response.started · response.doneThe agent began and finished a reply.
response.audio.deltaA chunk of the agent's speech. Play it as it arrives.
response.text.deltaThe same reply as text, for captions.
state.changedListening, thinking, speaking — enough to drive an indicator.
errorSomething went wrong, in the same shape as an HTTP error.
session.endedThe call is over, with the reason and turn count.
A browser cannot send headers on a WebSocket. The WebSocket API has no way to set one, so this endpoint — and only this endpoint — also accepts the credential as an ?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 seeWhy
422 on agent_idThe id is not one of your agents, or it belongs to another account. Check the value you captured.
422 on toMissing on an outbound call, or not E.164. +919876543210, not 09876543210.
422 on variablesThe prompt or greeting has a placeholder you did not fill.
402 plan_limit_reachedYour plan's ceiling. Retrying will not help.
Status failed, carrier_failureThe request was fine and the carrier could not place it — unreachable number, blocked route, no credit.
Ends immediately, at_capacityYou 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_reason was completed.
  • The transcript shows both sides of the conversation.
  • You are sending an Idempotency-Key on every POST that dials.

Next: step 4 makes it speak the right language in the right voice.