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

Calls

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

MethodPathDescription
POST/v1/callPlace an outbound call

Place a call

Using an agent id
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"
  }'
Inline config (one-off)
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": []
    }
  }'
Saved agent with variables
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"}
  }'
Python
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

FieldTypeRequiredDescription
from_numberstringyesE.164. Must be an outbound-enabled number owned by this organization
to_numberstringyesE.164 target. A number without a country code is read in the from_number's country and called in E.164
agent_idintegersee belowAn agent id in this org
variablesobjectnoPer-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.
configobjectsee belowInline config — schema matches the call.incoming webhook response
max_hold_secondsintegernoOverride the agent's hold timeout for this call only
timezonestringnoIANA 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_keystring (1–128 characters)noPrevent duplicate origination when the same normalized request is retried. Unique within the organization for the lifetime of the retained call record.

Exactly one of:

  1. agent_id — use the saved configuration.
  2. config — inline a one-off configuration (same shape as a call.incoming webhook response).
  3. Neither — the call uses the number's configured outbound_agent. Fails with 400 if no outbound agent is set.

Response

Returns 201 Created:

{
  "call_id": 987654321,
  "status":  "initiated"
}
FieldTypeDescription
call_idintegerAssigned id — use it on the call endpoints to follow the call.
statusstringAlways "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).
warningsarrayOnly 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 statusMeaning
in_progressSIP dialing or connected
completedCall ended normally
failedCall could not be established (trial-mode, unverified number, provider error)

Error responses

StatusBody code/detail hintMeaning
400from_number and to_number are requiredMissing required fields
400Provide either agent_id or config, not bothBoth supplied
400{ "code": "destination_not_allowed", "reason": "...", "detail": "..." }to_number is a destination ThunderPhone does not call. See blocked destinations
402Insufficient balanceOrg balance is ≤ $0.00. Top up first
403outbound_tcpa_confirmation_requiredThe org's one-time TCPA confirmation is missing
404from_number is not registered to this organizationThe number is unknown, belongs to another organization, or is not active when the request first looks it up
404Agent not foundagent_id invalid or wrong org
409Idempotency key already used with a different requestUse 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.
429concurrent_call_limit_reachedYour 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
429same_number_too_soonYour organization called this to_number less than its same-number spacing ago (10 seconds by default). Retry after Retry-After seconds
429platform_outbound_daily_limit_reachedToday'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.
502Upstream SIP / LiveKit submission failureThe 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, or managed_number_outbound_revoked.
  • Number state: number_provisioning, number_failed, number_releasing, number_released, or number_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, or voip_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/call now returns after origination is accepted instead of waiting for answer; ring failures finalize asynchronously. Added optional idempotency_key, durable dial-state reconciliation, and 202 status=unknown for 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.

MethodPathDescription
GET/v1/outbound-tcpa-confirmationConfirmation status + current text_version
POST/v1/outbound-tcpa-confirmationRecord 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).