Outbound Calls
Trigger an outbound voice call from one of your VoIP numbers.
Place an outbound call from one of your own phone numbers to a target number. You can reference a saved agent by id, or inline a one-off configuration for this call only.
Endpoint
| Method | Path | Description |
|---|---|---|
POST | /v1/call | Place an outbound call |
Place a call
curl -X POST https://api.thunderphone.com/v1/call \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from_number": "+15551234567",
"to_number": "+14155550199",
"agent_id": 12,
"idempotency_key": "crm-task-4815"
}'curl -X POST https://api.thunderphone.com/v1/call \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from_number": "+15551234567",
"to_number": "+14155550199",
"config": {
"prompt": "You are calling on behalf of Acme to confirm…",
"voice": "john",
"product": "spark",
"tools": []
}
}'result = requests.post(
"https://api.thunderphone.com/v1/call",
headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
json={
"from_number": "+15551234567",
"to_number": "+14155550199",
"agent_id": 12,
},
).json()Request fields
| Field | Type | Required | Description |
|---|---|---|---|
from_number | string | yes | E.164. Must be a number owned by this org (VoIP-backed) |
to_number | string | yes | E.164 target |
agent_id | integer | see below | An agent id in this org |
config | object | see below | Inline config — schema matches the call.incoming webhook response |
max_hold_seconds | integer | no | Override the agent's hold timeout for this call only |
idempotency_key | string (1–128 characters) | no | Prevent duplicate origination when the same normalized request is retried. Unique within the organization for the lifetime of the retained call record. |
Exactly one of:
agent_id— use the saved configuration.config— inline a one-off configuration (same shape as acall.incomingwebhook response).- Neither — the call uses the number's configured
outbound_agent. Fails with400if no outbound agent is set.
Response
Returns 201 Created:
{
"call_id": 987654321,
"status": "initiated"
}| Field | Type | Description |
|---|---|---|
call_id | integer | Assigned id — use it on the call endpoints to follow the call. |
status | string | Always "initiated" on success. This is the initiation status, not the call state machine; poll GET /v1/calls/{call_id} for the live status (in_progress → completed/failed). |
201 means LiveKit accepted the origination request. The endpoint does not
wait for the destination to answer. Busy, no-answer, rejection, and trunk
failures therefore normally arrive asynchronously: poll
GET /v1/calls/{call_id} or subscribe
to telephony.complete. A failed dial transitions
the call to failed, releases its billing reservation, and reports a sanitized
end_reason.
If the LiveKit submission response times out, ThunderPhone cannot safely know
whether LiveKit accepted the call. The API returns 202 Accepted with the same
call_id and "status": "unknown"; ThunderPhone then reconciles the
deterministic room and SIP participant. It never treats that timeout as a
definite failure or blindly originates a second call.
Idempotent retries
Send idempotency_key when a client might retry POST /v1/call. The key is
scoped to the organization and remains reserved for as long as the CallLog
is retained. The request fingerprint covers the normalized from_number,
to_number, agent_id, complete inline config, language override, and
max_hold_seconds.
A replay of the same request returns 200 OK, the original call_id, and
"idempotent_replay": true without originating a second call. Its status
reflects the durable outcome: pending, unknown, initiated, in_progress,
completed, or failed. Failed replays also include the stored sanitized
failure_reason. Reusing a key with a different normalized request returns
409 Conflict.
Poll GET /v1/calls/{call_id}
to observe state transitions:
Live status | Meaning |
|---|---|
in_progress | SIP dialing or connected |
completed | Call ended normally |
failed | Call could not be established (trial-mode, unverified number, provider error) |
Error responses
| Status | Body code/detail hint | Meaning |
|---|---|---|
400 | from_number and to_number are required | Missing required fields |
400 | Provide either agent_id or config, not both | Both supplied |
402 | Insufficient balance | Org balance is ≤ $0.00. Top up first |
403 | outbound_tcpa_confirmation_required | The org's one-time TCPA confirmation is missing |
403 | Outbound blocked (demo number, unverified VoIP, trial restriction) | See message for specifics |
404 | from_number is not registered to this organization | |
404 | Agent not found | agent_id invalid or wrong org |
409 | Idempotency key already used with a different request | Use the original request or a new key |
502 | Upstream SIP / LiveKit submission failure | The stored attempt is failed. Correct the reported issue or retry as a new call with a new idempotency key. |
Changelog
- 2026-08-06:
POST /v1/callnow returns after origination is accepted instead of waiting for answer; ring failures finalize asynchronously. Added optionalidempotency_key, durable dial-state reconciliation, and202 status=unknownfor ambiguous submission timeouts.
Outbound TCPA confirmation
Before an organization's first outbound-enabling action, an
organization admin must record a one-time confirmation that its outbound
calling complies with applicable telemarketing/robocall law (including
the TCPA). Until it exists, POST /v1/call and
campaign creation/start reject with:
{
"code": "outbound_tcpa_confirmation_required",
"detail": "Outbound calling requires a one-time TCPA compliance confirmation…"
}with status 403. The confirmation is per organization and one-time
— once recorded, it is never requested again. It applies only to
organizations created on or after 2026-08-03; organizations that
existed before then (and enterprise-agreement organizations) are fully
exempt — GET /v1/outbound-tcpa-confirmation reports this as
"gate_applies": false. The gate covers only originating outbound
calls: inbound calls, live-call transfers, and campaigns that are
already running are never affected.
| Method | Path | Description |
|---|---|---|
GET | /v1/outbound-tcpa-confirmation | Confirmation status + current text_version |
POST | /v1/outbound-tcpa-confirmation | Record the confirmation (org admin sessions only) |
GET returns:
{
"text_version": "outbound-tcpa-v4-2026-08-03",
"statement_en": "I confirm on behalf of my organization that its outbound calls through ThunderPhone will comply with applicable calling laws — the TCPA and FCC rules for U.S. calls, and the destination country's laws elsewhere.",
"fcc_link_url": "https://www.ecfr.gov/current/title-47/chapter-I/subchapter-B/part-64/subpart-L/section-64.1200",
"docs_link_url": "https://thunderphone.com/docs/guides/outbound-calling-laws",
"gate_applies": true,
"confirmed": false,
"confirmation": null
}POST takes { "accepted": true, "text_version": "…", "locale": "en", "rendered_text": "…" }. rendered_text must be exactly the
canonical statement for the submitted text_version — the statement is
server-pinned, and a mismatch returns 400. It is stored server-side as
a SHA-256 hash, with the UI locale, confirming user + role, IP, and user
agent, as the evidence record. text_version may be the current version
from GET or any still-accepted earlier version (so a dialog fetched
just before a statement update still submits cleanly, paired with that
version's own statement text). Returns 201 (or 200 if the org is
already confirmed — repeat confirms are idempotent).