ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Overview

Authentication

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 via the standard OAuth 2.0 Bearer scheme and returns JSON with UTF-8 encoding.

The resource families listed under 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.

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:

  1. Sign in
  2. Open Keys

    Navigate to Settings → Keys.

  3. 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.

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):

AreaEndpoints closed to API keys
API keysList, create, and revoke keys
MembersRemove a member, change a role, transfer ownership, leave
InvitesList, send, and revoke invites; accept, decline, or request access to one
OrganizationsCreate or delete an organization
Enterprise SSOChange SSO connections or the security policy
BillingChange auto-reload or the monthly limit, top up, add or remove a card, set the default card
Client portalsAdd, re-enable, or remove a viewer
Transfers (agents, numbers, integrations, 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.

cURL
curl https://api.thunderphone.com/v1/agents \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
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()
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();

Health check

Verify API connectivity without authentication:

curl https://api.thunderphone.com/v1/healthz
Response
{ "ok": true }

Dashboard access (X-ThunderPhone-Org)

The dashboard at 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:

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 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:

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

{
  "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.

Errors

The API uses conventional HTTP status codes:

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

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:

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

Rate limiting

There is no global request rate limit on the API today. Endpoints that start calls enforce the call limits below. A small number of other endpoints carry scoped limits — for example, invite access requests 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 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.

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:

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
}
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:

{
  "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