sphoroVOICEdocs
Markdown

Use your API key

One header on every request, one base URL, one body format. This page is everything that is true of every endpoint — read it once here instead of rediscovering it one route at a time.

Step 1 · The base URL

Every endpoint lives under /v1 on this origin:

Base URL
https://voice.sphoro.com/v1

These docs are served by the API they describe, so that is the deployment you are reading right now and every example below is already pointed at it.

Step 2 · The header

Send the key as a bearer token. That is the entire authentication scheme — there is no signing step, no session, no token exchange.

HTTP
Authorization: Bearer $SPHORO_API_KEY
The key goes in the header, never in the URL. A key in a query string is written into access logs, proxy logs, Referer headers and browser history — four places you do not control and cannot clean up.

Step 3 · Prove the key works

Before building anything on it, make one request that can only succeed with a valid key. This one lists your agents; a brand-new account has none, and an empty list is a success.

GET/v1/agents
curl -s https://voice.sphoro.com/v1/agents \
  -H "Authorization: Bearer $SPHORO_API_KEY"
const res = await fetch("https://voice.sphoro.com/v1/agents", {
  headers: { Authorization: `Bearer ${process.env.SPHORO_API_KEY}` },
});

if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());
import os, urllib.request, json

req = urllib.request.Request(
    "https://voice.sphoro.com/v1/agents",
    headers={"Authorization": "Bearer " + os.environ["SPHORO_API_KEY"]},
)
with urllib.request.urlopen(req) as res:
    print(json.load(res))
req, _ := http.NewRequest("GET", "https://voice.sphoro.com/v1/agents", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SPHORO_API_KEY"))

res, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
	body, _ := io.ReadAll(res.Body)
	return fmt.Errorf("%d: %s", res.StatusCode, body)
}
A working key answers200 OK
{
  "object": "list",
  "data": [],
  "has_more": false
}
A key that is wrong answers401 Unauthorized
{
  "type": "https://voice.sphoro.com/docs/authentication#unauthenticated",
  "title": "Unauthenticated",
  "status": 401,
  "detail": "the credential is missing, malformed or no longer valid",
  "code": "unauthenticated",
  "request_id": "req_3f9a2c1b7e5d"
}

If you get the second one, work through it in this order: the header is spelled Authorization, the value begins Bearer  with one space, the key begins vsk_ and was pasted whole, and the key has not been revoked or allowed to expire in the portal.

Key working? Step 3 places a call. The rest of this page is the ground rules — worth reading now, but nothing here blocks you.

Sending a body

Every request that has a body sends JSON, and says so:

HTTP
Content-Type: application/json

Unknown fields are rejected, not ignored. A body containing "temperture" fails with validation_error naming that field, rather than succeeding quietly with the default and leaving you to work out months later why a setting never took effect.

Reading lists

Every endpoint that returns more than one object uses the same two parameters and the same envelope. Results are newest first.

Shell
curl -s "https://voice.sphoro.com/v1/calls?limit=20" \
  -H "Authorization: Bearer $SPHORO_API_KEY"
Response200 OK
{
  "object": "list",
  "data": [ ... ],
  "has_more": true,
  "next_cursor": "Y2FsbF85YjIxY2U3N2EwNGY"
}
ParameterTypeMeaning
limitintegerHow many to return, 1–100. Defaults to 20.
starting_afterstringThe next_cursor from the previous page.

Keep following next_cursor while has_more is true:

Shell
curl -s "https://voice.sphoro.com/v1/calls?limit=20&starting_after=Y2FsbF85YjIxY2U3N2EwNGY" \
  -H "Authorization: Bearer $SPHORO_API_KEY"

A cursor is opaque. It is not an object id and its encoding is not part of the contract — send back exactly the string you were handed. Anything else is refused with validation_error rather than quietly returning the wrong page. next_cursor is absent on the last page, so branch on has_more.

Retrying safely

Send an Idempotency-Key on any POST. If the connection drops and you retry with the same key, you get the original response back instead of a second call being placed to a customer.

Shell
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":"agt_...","to":"+919876543210"}'

Use one key per logical operation, not per attempt: generate it when you decide to place the call, and reuse that same value for every retry of it. Keys are remembered for 24 hours.

Two situations answer idempotency_conflict. Reusing a key with a different body is refused outright rather than quietly doing one or the other. Retrying while the first request is still in flight also reports it — and that one is worth handling, because it means your original is still running and you should wait rather than treat it as a failure.

Rate limits

Every response tells you the ceiling, what is left of it, and when the window resets:

HTTP
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1755432000

Over the limit you get 429 with a Retry-After header in seconds. Wait that long — it is an exact answer, and guessing is what turns one throttled request into a queue of them.

Request IDs

Every response carries X-Request-Id, and every error repeats it in the body as request_id. Log it. It is the fastest path from "something failed" to the exact request in our logs, and the first thing support will ask you for.

When something fails

Every failure has the same shape — application/problem+json, the format described by RFC 9457, with three additions that matter in practice: a stable code, the request_id to quote, and per-field errors when a body was invalid.

Response422 Unprocessable Content
{
  "type": "https://voice.sphoro.com/docs/authentication#validation_error",
  "title": "Validation failed",
  "status": 422,
  "detail": "one or more fields are invalid",
  "code": "validation_error",
  "request_id": "req_3f9a2c1b7e5d",
  "errors": [
    { "field": "to",       "message": "is required for an outbound call" },
    { "field": "agent_id", "message": "does not name an agent belonging to this account" }
  ]
}

Branch on code, never on the message. Wording is improved over time; codes do not change. type links straight to the row below that explains the one you got.

Every error code

CodeStatusMeansWhat to do
unauthenticated401The key is missing, malformed, revoked or expired.Check the Authorization: Bearer header actually carries the key. A revoked or expired key reports the same way.
permission_denied403The key is valid, but its role does not cover this endpoint.The message names the missing permission. Use a key with a stronger role — see step 1.
validation_error422One or more fields in the body are invalid.Read errors[]: every problem is reported at once, so fix them together rather than resubmitting to find the next one.
not_found404No such object on this account.Check the id. Another account's id is a 404 here, never a 403 — we do not confirm that other people's objects exist.
invalid_request400The request itself was malformed — unparseable JSON, an unknown field, a bad query parameter.Fix the request. Retrying it unchanged will fail identically.
rate_limited429Too many requests in the current window.Wait retry_after_seconds, which the response also carries as the Retry-After header, then retry.
plan_limit_reached402Your plan's ceiling for this kind of object is reached.Delete one, or move to a plan with a higher ceiling. Retrying will not help.
conflict409The request conflicts with the object's current state.Re-read the object and decide — for example, a call you tried to end had already ended.
idempotency_conflict409An Idempotency-Key was reused with a different body, or the first request carrying it is still running.Use a fresh key for a genuinely different request. If your original is still in flight, wait and retry the same key — that is the case this code exists to tell you about.
payload_too_large413The body is over the size limit.Split the request.
unsupported_media_type415The Content-Type is not one this endpoint accepts.Send application/json on any request with a body.
internal_error500Something broke on our side.Retry with backoff. If it persists, quote the request_id — it is how we find the exact request.
service_unavailable503A dependency we need is temporarily unavailable.Retry with backoff.

What to retry

Retry 429, 500, 502, 503 and outright network failures, with exponential backoff and jitter. Never retry any other 4xx: the request is wrong and will fail identically. Always send an Idempotency-Key on POST so a retry after a timeout cannot place a second call.

What each permission covers

Only relevant if you got a permission_denied. The message names the permission it wanted; this says what that permission is for.

PermissionCovers
calls:writePlacing calls and ending them
calls:readCall records, transcripts and recordings
agents:readReading agents and webhook endpoints
agents:writeChanging the agents an account already has
agents:createBringing an agent into existence, and deleting one. Held by admin and owner keys only — a member key edits and runs agents but does not decide which exist
knowledge:read · knowledge:writeKnowledge bases and the documents in them
analytics:readUsage and analytics
keys:read · keys:writeListing and issuing API keys
billing:read · billing:writePlan and balance
tts:writeHeld by every role. No endpoint in this guide requires it — it exists for custom functions, which may declare permissions of their own

Which role carries which is in step 1.