# 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**

```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**

```bash
curl -s https://voice.sphoro.com/v1/agents \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

**Node**

```js
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());
```

**Python**

```python
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))
```

**Go**

```go
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**

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

**A key that is wrong answers401 Unauthorized**

```json
{
  "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.](https://voice.sphoro.com/docs/calls) 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**

```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**

```bash
curl -s "https://voice.sphoro.com/v1/calls?limit=20" \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

**Response200 OK**

```json
{
  "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:

**Shell**

```bash
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**

```bash
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**

```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**

```json
{
  "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](https://voice.sphoro.com/docs/api-keys#which-role-to-pick). |
| `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](https://voice.sphoro.com/docs/api-keys#which-role-to-pick).
