---
title: "Authentication"
description: "How to authenticate with the ThunderPhone API using sk_live_ bearer keys, pick the organization your requests act on, and read the shared error format."
---

The ThunderPhone API is a REST + JSON API. Every public endpoint accepts an
[Organization API key](#api-keys) via the standard OAuth 2.0 Bearer scheme
and returns JSON with UTF-8 encoding.

The resource families listed under
[Available permissions](/guides/oauth#available-permissions) in the OAuth guide
(agents, calls, phone numbers, knowledge, campaigns, integrations, voices,
testing, billing reads) also accept an OAuth 2.0 access token issued by
ThunderPhone's authorization server, sent the same way
(`Authorization: Bearer <access_token>`) and limited to the permissions the
user approved; a request outside them returns `403` with
`error="insufficient_scope"`. Organization, member, and API-key management
endpoints are not available through OAuth. Integration platforms (MCP clients,
Make, the ThunderPhone CLI) connect with OAuth so users never paste an
`sk_live_` key. Endpoints, PKCE, refresh, and revocation are in
[Connect with OAuth](/guides/oauth).

## Base URL

```
https://api.thunderphone.com/v1
```

All examples in this reference are rooted at that base URL. The API is
versioned via the `/v1` path prefix — we add new fields additively and
avoid breaking changes within `v1`.

OpenAPI spec: https://thunderphone.com/docs/openapi.json

## API keys

Create a key from the dashboard:

<Steps>
  <Step title="Sign in">
    Log into the [ThunderPhone Dashboard](https://app.thunderphone.com).
  </Step>
  <Step title="Open Keys">
    Navigate to **Settings → Keys**.
  </Step>
  <Step title="Create key">
    Click **Create API Key**, give it a name (e.g. `production`, `ci`), and copy the `sk_live_...` value.
    The raw key is shown **only once** — if you lose it, revoke and create a new one.
  </Step>
</Steps>

<Warning>
  **Keep API keys secret.** They carry broad access to your organization's
  resources. Never ship them in client-side code or commit them to version
  control. Publishable keys (`pk_live_...`) are **not** API keys — they are
  a separate, origin-restricted credential for the embeddable web widget
  and are not accepted by this API.
</Warning>

<Note>
  **API keys are exempt from the "Require passkeys" org policy.** Passkeys
  are a human 2FA check performed at sign-in and on session use; an
  `sk_live_...` key is a machine credential with no login session to
  attach a passkey check to, so it authenticates independently of that
  policy even when it's enabled for your organization. This is intentional
  — treat key issuance and rotation as your access control for
  server-to-server integrations, and audit or revoke keys from
  **Organization → Keys** the same way you would rotate any other
  standing credential. See [Create a server API key](/guides/api-keys)
  and [Enterprise SSO](/guides/sso#security-policy) for how the policy
  applies to human members.
</Note>

### What API keys can't do

A key acts with the role of the person who created it, so it reaches most
of what that person can, inside the key's own organization. Account
control, money movement, and anything that reaches another organization are
the exception. These endpoints return `403` to an `sk_live_` key and need a
signed-in session instead (see
[Dashboard access](#dashboard-access-x-thunderphone-org)):

| Area | Endpoints closed to API keys |
|------|------------------------------|
| [API keys](/api-reference/developer-api-keys) | List, create, and revoke keys |
| [Members](/api-reference/members) | Remove a member, change a role, transfer ownership, leave |
| [Invites](/api-reference/invites) | List, send, and revoke invites; accept, decline, or request access to one |
| [Organizations](/api-reference/organizations) | Create or delete an organization |
| [Enterprise SSO](/guides/sso) | Change SSO connections or the security policy |
| [Billing](/api-reference/billing) | Change auto-reload or the monthly limit, top up, add or remove a card, set the default card |
| [Client portals](/api-reference/client-portals) | Add, re-enable, or remove a viewer |
| Transfers ([agents](/api-reference/agents), [numbers](/api-reference/phone-numbers), [integrations](/api-reference/integrations), [publishable keys](/api-reference/publishable-keys)) | Copy or move into any organization other than the key's own |

A leaked key therefore can't lock you out, let someone else in, or change
how your organization pays, and revoking it cuts it off. After revoking a
leaked key, also check for publishable keys or SIP endpoint passwords it
may have created. A key can still
spend balance the way your integration does (placing calls, buying
numbers), and reads next to these endpoints, such as listing members,
billing state, and transactions, still work with a key.

## Making a request

Send the key as a bearer token in the `Authorization` header. The API
infers your organization from the key, so **you don't need an org id in
the URL path**.

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/agents \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
import requests

resp = requests.get(
    "https://api.thunderphone.com/v1/agents",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
    timeout=10,
)
resp.raise_for_status()
agents = resp.json()
```

```javascript Node.js
const response = await fetch(
  "https://api.thunderphone.com/v1/agents",
  { headers: { Authorization: `Bearer ${process.env.THUNDERPHONE_API_KEY}` } },
);
if (!response.ok) throw new Error(await response.text());
const agents = await response.json();
```
</CodeGroup>

<Info>
  Successful JSON responses use `200 OK` for reads and `201 Created` for
  writes. Deletes return `204 No Content`. Errors use the standard HTTP
  status codes listed below.
</Info>

## Health check

Verify API connectivity without authentication:

```bash
curl https://api.thunderphone.com/v1/healthz
```

```json Response
{ "ok": true }
```

## Dashboard access (X-ThunderPhone-Org)

The dashboard at [app.thunderphone.com](https://app.thunderphone.com)
authenticates users with a personal session/token (one user can belong to
many organizations). Those requests identify the target organization via
the `X-ThunderPhone-Org` header:

```http
Authorization: Token <personal-token>
X-ThunderPhone-Org: 42
```

You will normally **not** need this header — it exists for the dashboard
code path and for the [account-control endpoints](#what-api-keys-cant-do)
that refuse API keys. API integrations built against an `sk_live_` key
should omit it; the key already carries an org binding and the header, if
present, is ignored for API-key requests.

## ID format

Most core resources use 64-bit integer ids (agents, phone numbers,
calls, members, organizations, publishable keys, VoIP connections).
Newer resources use **UUID** ids — integrations, developer API keys,
webhook endpoints, campaigns, knowledge bases and documents, MCP
servers, provider connections, investigations, and test suites — and
issue reports are addressed by a UUID `trace_id`. Each page states the
id type in its object table. Tokens (invites, API keys) are opaque
strings. Every successful write returns the persisted object including
its assigned id:

```json
{
  "id": 12,
  "name": "Customer Support Agent",
  "created_at": "2026-04-20T18:24:10.113Z",
  "updated_at": "2026-04-20T18:24:10.113Z"
}
```

Timestamps are ISO 8601 with millisecond precision in UTC.

## Pagination

List endpoints return a plain JSON array by default. Endpoints that can
produce very large result sets (currently **calls** and
**issue reports**) accept `limit` and `offset` query parameters and
return a wrapped envelope:

```json
{
  "results": [ /* ... */ ],
  "total":   1234,
  "limit":   50,
  "offset":  0
}
```

To page forward, add `limit` to the current `offset` on each request
and stop when `offset + limit >= total`. See each endpoint's page for
the default and maximum `limit` it accepts.

<Note>
  [Billing transactions](/api-reference/billing#list-transactions) also
  accept `limit`/`offset` but return a plain array without the envelope
  — page forward until you receive fewer rows than `limit`.
</Note>

## Errors

The API uses conventional HTTP status codes:

| Status | Meaning |
|--------|---------|
| `200` | OK |
| `201` | Created |
| `204` | No Content — operation succeeded with no body |
| `400` | Bad Request — invalid parameters. Body contains `detail` or field-specific messages |
| `401` | Unauthorized — missing or invalid API key |
| `402` | Payment Required — insufficient balance |
| `403` | Forbidden — valid credentials but the resource is out of scope or policy denies the action |
| `404` | Not Found — resource doesn't exist (or isn't visible to this key) |
| `409` | Conflict — optimistic-concurrency failures, or an action that requires a live/other state |
| `410` | Gone — deprecated URL shape (see `new_path` in body), or an expired/used invite token |
| `422` | Unprocessable Entity — the request is well-formed but can't be processed (e.g. billing prerequisites not met) |
| `429` | Too Many Requests — a [call limit](#call-limits) or a scoped limit on a specific endpoint (noted on that endpoint's page) |
| `500` | Internal Server Error |
| `502` | Bad Gateway — an upstream provider (LiveKit, VoIP, model backend) failed |
| `503` | Service Unavailable — an upstream provider (e.g. the billing provider) is temporarily unreachable; retry shortly |

Error responses include a JSON body with a human-readable `detail`
message:

```json Error body
{
  "detail": "Call not found."
}
```

Field-specific validation errors (`400`) instead map each offending
field to a list of messages, using DRF's default format:

```json Validation error
{
  "name": ["This field is required."],
  "voice": ["Unsupported voice option."]
}
```

<Note>
  Most endpoints return **only** `detail` (or the field-error map) —
  there is no machine-readable `code` field on generic errors. The
  exception is [Billing](/api-reference/billing#errors), whose payment
  errors add `code`, `retryable`, and `next_action` fields so clients
  can drive recovery flows (3-D Secure, card update, retry).
</Note>

## Rate limiting

There is **no global request rate limit** on the API today. Endpoints
that start calls enforce the [call limits](#call-limits) below. A small
number of other endpoints carry scoped limits — for example, [invite
access requests](/api-reference/invites#request-access-to-an-invite)
are limited to 3 per hour per user. These are documented on the
relevant endpoint pages and return `429 Too Many Requests` when
exceeded. Treat write operations as non-idempotent unless the endpoint
documents otherwise.

### Call limits

Every organization has two call limits. Email
[support@thunderphone.com](mailto:support@thunderphone.com) to raise
either one for your organization.

- **Concurrent calls:** at most **20 calls in progress at once**,
  counting every kind of call together: inbound phone and SIP calls,
  outbound calls (`POST /v1/call`, the MCP `place_call` tool and
  campaigns), web widget sessions, realtime sessions, mic sessions and
  test calls. A call counts from the moment it starts until it ends.
- **Same-number spacing:** outbound calls from your organization to the
  same number must be at least **10 seconds** apart. This applies to
  `POST /v1/call`, `place_call` and campaigns.

Your organization's values are the read-only `max_concurrent_calls` and
`min_seconds_between_calls_to_same_number` fields on the
[organization](/api-reference/organizations#organization-object).

A call that would go over a limit is refused before anything is dialed
or billed. Call-starting endpoints return `429 Too Many Requests` with a
`Retry-After` header giving the seconds to wait:

```json Concurrent calls
{
  "code": "concurrent_call_limit_reached",
  "detail": "This organization has reached its limit of 20 concurrent calls. Try again when a call ends. To raise the limit, email support@thunderphone.com.",
  "max_concurrent_calls": 20,
  "retry_after_seconds": 30
}
```

```json Same-number spacing
{
  "code": "same_number_too_soon",
  "detail": "This organization called this number less than 10 seconds ago. Calls to the same number must be at least 10 seconds apart; try again in 7 seconds. To change this, email support@thunderphone.com.",
  "min_seconds_between_calls_to_same_number": 10,
  "retry_after_seconds": 7
}
```

The web widget's session request returns the same values as `error` and
`message`. An inbound call that arrives while your organization is at
its limit is refused and hung up before an agent answers, and is not
billed; it shows in call history as a failed call with end reason
`concurrent_call_limit_reached`. Campaigns never use up a contact's
attempts on either limit: the contact waits and is dialed on a later
pass.

Some features also have their own caps, which apply within the
concurrent-call limit: a campaign runs at most 10 calls at once and
starts no new call while your organization has 10 outbound calls in
progress, the web widget allows 25 active sessions (or your
concurrent-call limit, if that is higher), and at most 10
simulation runs can be active.

## Migration from `/v1/orgs/<id>/...` paths

The API previously nested every resource under your organization id in
the URL path — e.g. `/v1/orgs/42/agents`. Those URLs now return `410
Gone` with a body pointing to the new flat shape:

```json
{
  "detail": "This endpoint moved. Drop the /orgs/<id>/ prefix...",
  "legacy_path": "/v1/orgs/42/agents",
  "new_path": "/v1/agents"
}
```

Existing integrations only need a one-line change: remove `/orgs/<id>`
from every URL. Your `sk_live_` key is unchanged and continues to
identify your organization automatically.

## Next steps

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Create your first agent, provision a number, and take a test call.
  </Card>
  <Card title="Agents" icon="robot" href="/api-reference/agents">
    Configure voices, prompts, and tool integrations.
  </Card>
  <Card title="Calls" icon="phone" href="/api-reference/calls">
    List, inspect, and export call history.
  </Card>
  <Card title="Webhooks" icon="bolt" href="/webhooks/overview">
    Subscribe to real-time events.
  </Card>
</CardGroup>
