---
title: "Outbound Calls"
description: "Trigger an outbound voice call from an outbound-enabled phone number."
---

Place an outbound call from one of your own [phone numbers](/api-reference/phone-numbers)
to a target number. You can reference a saved [agent](/api-reference/agents)
by id, or inline a one-off configuration for this call only.

## Endpoint

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/call` | Place an outbound call |

<Note>
  `from_number` must be an outbound-enabled number in your organization.
  Customer VoIP numbers continue to support outbound calling. ThunderPhone
  numbers start inbound-only; an organization admin can request outbound
  calling from the Phone Numbers page. ThunderPhone reviews each request
  manually, and approved numbers can place calls and run campaigns.
</Note>

## Place a call

<CodeGroup>
```bash 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"
  }'
```

```bash 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": []
    }
  }'
```

```bash 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 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()
```
</CodeGroup>

### 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](/api-reference/agents) 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](/guides/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`](/api-reference/agents#deployed-fields). 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:

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

### Response

Returns `201 Created`:

```json
{
  "call_id": 987654321,
  "status":  "initiated"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `call_id` | integer | Assigned id — use it on the [call endpoints](/api-reference/calls) to follow the call. |
| `status` | string | Always `"initiated"` on success. This is the *initiation* status, not the [call state machine](/api-reference/calls#call-object); 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}`](/api-reference/calls#retrieve-a-call) or subscribe
to [`telephony.complete`](/webhooks/call-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}`](/api-reference/calls#retrieve-a-call)
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](#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](#outbound-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](/api-reference/introduction#call-limits). 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](/api-reference/introduction#call-limits) ago (10 seconds by default). Retry after `Retry-After` seconds |
| `429` | `platform_outbound_daily_limit_reached` | Today's [daily limit](/api-reference/phone-numbers#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`, 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](/api-reference/voip-connections)) 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.

<Warning>
  Make sure your account has enough balance. Calls block at `<= $0.00`
  (402). See [Billing → Top up](/api-reference/billing#top-up-balance).
</Warning>

## 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](/api-reference/campaigns) creation/`start` reject with:

```json
{
  "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:

```json
{
  "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).

<Note>
  The confirmation is an organization-binding representation, so it
  cannot be recorded with an `sk_live_` API key — an organization
  admin confirms once, normally via the dashboard prompt that appears
  on the first outbound action (`403` on `POST` with a key). API-key
  calls to `/v1/call` are gated all the same until that happens.
</Note>

---

## Related

<CardGroup cols={2}>
  <Card title="Agents" icon="robot" href="/api-reference/agents">
    Configure the prompt, voice, and tools for outbound calls.
  </Card>
  <Card title="VoIP Connections" icon="phone-volume" href="/api-reference/voip-connections">
    Set up the VoIP provider credentials that back outbound calls.
  </Card>
  <Card title="Calls" icon="phone" href="/api-reference/calls">
    Observe the call as it runs and inspect the transcript afterward.
  </Card>
  <Card title="Billing" icon="credit-card" href="/api-reference/billing">
    Keep your prepaid balance topped up so outbound calls can place.
  </Card>
</CardGroup>
