Open in
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:
- Sign in
Log into the ThunderPhone Dashboard.
- Open Keys
Navigate to Settings → Keys.
- Create key
Click Create API Key, give it a name (e.g.
production,ci), and copy thesk_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):
| Area | Endpoints closed to API keys |
|---|---|
| API keys | List, create, and revoke keys |
| Members | Remove a member, change a role, transfer ownership, leave |
| Invites | List, send, and revoke invites; accept, decline, or request access to one |
| Organizations | Create or delete an organization |
| Enterprise SSO | Change SSO connections or the security policy |
| Billing | Change auto-reload or the monthly limit, top up, add or remove a card, set the default card |
| Client portals | Add, 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 https://api.thunderphone.com/v1/agents \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"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()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{ "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: 42You 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:
| 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 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:
{
"detail": "Call not found."
}Field-specific validation errors (400) instead map each offending
field to a list of messages, using DRF's default format:
{
"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 MCPplace_calltool 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_calland 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:
{
"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
}{
"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.