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

Calls

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

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":   "+14155550199",
    "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":   "+14155550199",
    "config": {
      "prompt": "You are calling on behalf of Acme to confirm…",
      "voice":  "john",
      "product": "spark",
      "tools": []
    }
  }'
Python
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

FieldTypeRequiredDescription
from_numberstringyesE.164. Must be a number owned by this org (VoIP-backed)
to_numberstringyesE.164 target
agent_idintegersee belowAn agent id in this org
configobjectsee belowInline config — schema matches the call.incoming webhook response
max_hold_secondsintegernoOverride the agent's hold timeout for this call only
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_progresscompleted/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 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
402Insufficient balanceOrg balance is ≤ $0.00. Top up first
403outbound_tcpa_confirmation_requiredThe org's one-time TCPA confirmation is missing
403Outbound blocked (demo number, unverified VoIP, trial restriction)See message for specifics
404from_number is not registered to this organization
404Agent not foundagent_id invalid or wrong org
409Idempotency key already used with a different requestUse the original request or a new key
502Upstream SIP / LiveKit submission failureThe 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/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).