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:
https://voice.sphoro.com/v1These 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.
Authorization: Bearer $SPHORO_API_KEYReferer 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.
/v1/agentscurl -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)
}{
"object": "list",
"data": [],
"has_more": false
}{
"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.
Sending a body
Every request that has a body sends JSON, and says so:
Content-Type: application/jsonUnknown 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.
curl -s "https://voice.sphoro.com/v1/calls?limit=20" \
-H "Authorization: Bearer $SPHORO_API_KEY"{
"object": "list",
"data": [ ... ],
"has_more": true,
"next_cursor": "Y2FsbF85YjIxY2U3N2EwNGY"
}| Parameter | Type | Meaning |
|---|---|---|
limit | integer | How many to return, 1–100. Defaults to 20. |
starting_after | string | The next_cursor from the
previous page. |
Keep following next_cursor while has_more is true:
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.
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:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1755432000Over 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.
{
"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
| Code | Status | Means | What to do |
|---|---|---|---|
unauthenticated | 401 | The 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_denied | 403 | The 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_error | 422 | One 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_found | 404 | No 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_request | 400 | The request itself was malformed — unparseable JSON, an unknown field, a bad query parameter. | Fix the request. Retrying it unchanged will fail identically. |
rate_limited | 429 | Too many requests in the current window. | Wait retry_after_seconds, which the response also carries as the Retry-After header, then retry. |
plan_limit_reached | 402 | Your 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. |
conflict | 409 | The 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_conflict | 409 | An 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_large | 413 | The body is over the size limit. | Split the request. |
unsupported_media_type | 415 | The Content-Type is not one this endpoint accepts. | Send application/json on any request with a body. |
internal_error | 500 | Something broke on our side. | Retry with backoff. If it persists, quote the request_id — it is how we find the exact request. |
service_unavailable | 503 | A 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.
| Permission | Covers |
|---|---|
calls:write | Placing calls and ending them |
calls:read | Call records, transcripts and recordings |
agents:read | Reading agents and webhook endpoints |
agents:write | Changing the agents an account already has |
agents:create | Bringing 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:write | Knowledge bases and the documents in them |
analytics:read | Usage and analytics |
keys:read · keys:write | Listing and issuing API keys |
billing:read · billing:write | Plan and balance |
tts:write | Held 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.