Open in
Outbound Calls
Trigger an outbound voice call from an outbound-enabled phone number.
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": "+14155552671",
"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": "+14155552671",
"config": {
"prompt": "You are calling on behalf of Acme to confirm…",
"voice": "john",
"product": "spark",
"tools": []
}
}'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": "+14155552671",
"agent_id": 12,
"variables": {"name": "Ada", "account_id": "A-17"}
}'result = requests.post(
"https://api.thunderphone.com/v1/call",
headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
json={
"from_number": "+15551234567",
"to_number": "+14155552671",
"agent_id": 12,
},
).json()Request fields
| Field | Type | Required | Description |
|---|---|---|---|
from_number | string | yes | E.164. Must be an outbound-enabled number owned by this organization |
to_number | string | yes | E.164 target. A number without a country code is read in the from_number's country and called in E.164 |
agent_id | integer | see below | An agent id in this org |
variables | object | no | Per-call values for {{name}} and {{name|Friend}} placeholders. Strings, numbers, booleans, or null; 32 KB maximum. Applies to the saved agent or inline prompt. See call variables. |
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 |
timezone | string | no | IANA time zone for this call only, such as America/Chicago. The agent states the date and time and offers times in it, ahead of the agent's own timezone. Use it when one agent calls contacts in different zones. Also accepted inside config. A name that is not an IANA zone is ignored (the call goes ahead in the agent's zone) and reported in the response's warnings |
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). |
warnings | array | Only present when part of the request was ignored, each {"code", "message"}. timezone_invalid: timezone was not an IANA zone. |
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,
max_hold_seconds and timezone.
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 |
400 | { "code": "destination_not_allowed", "reason": "...", "detail": "..." } | to_number is a destination ThunderPhone does not call. See blocked destinations |
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 |
404 | from_number is not registered to this organization | The number is unknown, belongs to another organization, or is not active when the request first looks it up |
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 |
409 | { "code": "...", "detail": "...", "retryable": false } | The number is not eligible to place this call. Correct the number's state before trying again; do not retry the same request unchanged. |
429 | concurrent_call_limit_reached | Your organization already has as many calls in progress as its concurrent-call limit. Nothing was dialed; retry after Retry-After seconds or when a call ends |
429 | same_number_too_soon | Your organization called this to_number less than its same-number spacing ago (10 seconds by default). Retry after Retry-After seconds |
429 | platform_outbound_daily_limit_reached | Today's daily limit for calls from ThunderPhone numbers is used up |
500 | { "detail": "..." } | No outbound trunk is configured. Contact support; this is not a number-state problem you can correct. |
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. |
Eligibility conflicts use one of these stable code values:
- ThunderPhone-number review state:
managed_number_inbound_only,managed_number_outbound_pending,managed_number_outbound_denied, ormanaged_number_outbound_revoked. - Number state:
number_provisioning,number_failed,number_releasing,number_released, ornumber_missing. - Customer VoIP state:
voip_verification_failed,voip_connection_missing,voip_connection_wrong_organization,voip_connection_error,voip_connection_pending,voip_connection_testing,voip_verification_unverified, orvoip_verification_stale.
The request first looks up an active from_number. A number that is already
provisioning, failed, releasing, or released fails that lookup with 404. If an
initially active number becomes ineligible after the request is accepted but
before the call is placed, the final eligibility check returns the 409 payload
above with the corresponding number_* code.
The other 409 response is the existing idempotency conflict. It has a
detail field but does not include code or retryable.
Blocked destinations
Every outbound call, from any number, refuses to_number values that are not
valid phone numbers (reason invalid_number), emergency numbers such as 911,
112 or 999 (emergency_number), short codes (short_code), and numbers in
Cuba, Iran, North Korea or Syria (sanctioned_country). Calls from a number
on your own carrier (VoIP connection) are
refused only for these reasons; any other number, including premium-rate and
shared-cost numbers, is up to you and your carrier.
Calls from ThunderPhone numbers can also only reach ordinary US and Canadian
numbers: landline, mobile, toll-free or VoIP. Premium-rate numbers return
premium_rate, other countries platform_country_not_supported, other number
types platform_number_type_not_supported, and the fictional 555-0100 to
555-0199 range fictional_number. To call these, use a number on your own
carrier.
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).