---
title: "Place outbound calls (API)"
description: "Trigger an AI-driven outbound call from your own code — survey, follow-up, or confirmation flows."
---

Outbound calling lets you hand a destination number and an agent
configuration to ThunderPhone and have the AI place the call on your
behalf. Typical use cases:

- Appointment confirmations
- Survey callbacks
- "Second-try" follow-ups after a missed call
- Dispatch-style notifications

<Note>
  Calling a whole list? The dashboard's
  [**Campaigns**](/guides/outbound-campaigns) feature
  (`/dashboard/campaigns`) takes a CSV of contacts and handles
  timezone-aware calling windows, concurrency, and retry policy for
  you. This guide covers single programmatic calls.
</Note>

## Prerequisites

<Steps>
  <Step title="Choose an outbound-enabled number">
    Use a number you own through a
    [VoIP connection](/api-reference/voip-connections), or request permission
    to use your ThunderPhone numbers for outbound calls from the Phone Numbers
    page. ThunderPhone numbers start inbound-only. ThunderPhone reviews each
    request manually; once approved, those numbers can place calls up to the
    approved daily limit (resetting at 00:00 UTC) and run campaigns, and you
    can ask for a higher limit. Customer VoIP numbers have no daily limit, so we recommend
    them for outbound calling. See
    [Bring your own numbers](/guides/bring-your-own-numbers).
  </Step>
  <Step title="Create an agent">
    An outbound-flavoured prompt tends to start with the agent
    identifying itself and its purpose — "Hi, this is Acme calling to
    confirm your appointment for tomorrow at 3pm…" Set
    `outbound_speak_order` to `agent_first` (the default).
  </Step>
  <Step title="Keep a positive balance">
    Outbound calls return `402 Payment Required` if balance is ≤
    `$0.00`. Top up or enable auto-reload under
    **Organization → Billing Settings** in the dashboard. Your `sk_live_`
    key can check the balance with
    [`GET /v1/billing`](/api-reference/billing#get-billing-account) but
    can't top up or change auto-reload.
  </Step>
</Steps>

## Place a call with a saved agent

The simplest path — reference an agent by id:

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

Response:

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

<Warning>
  `status: "initiated"` just means the request was accepted — the call
  is **not yet connected**. Poll
  [`GET /v1/calls/{call_id}`](/api-reference/calls#retrieve-a-call)
  for the live status (`in_progress` → `completed` / `failed`).
</Warning>

## Place a call with inline config

If you want a one-off prompt that isn't worth saving as an agent,
pass `config` instead. The shape matches the response schema of the
[`call.incoming` webhook](/webhooks/call-incoming#response-schema):

```bash
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 confirming Jane Doe appointment for 3pm tomorrow…",
      "voice":   "john",
      "product": "spark"
    }
  }'
```

## Follow the call

In parallel, subscribe to the
[`telephony.complete` webhook](/webhooks/events#telephony-complete) —
the fastest way to know a call finished. If you can't accept inbound
webhooks, poll `GET /v1/calls/{call_id}` every couple of seconds; the
record includes `end_reason`, `duration_seconds`, and the recording
URL once the call ends.

If your agent is set up to extract structured data after the call,
that data is delivered separately after finalization completes.

### Pass per-call variables to a saved agent

Saved agents can use placeholders like `{{name}}` or `{{name|there}}` in
prompt text. When you place an outbound call with `agent_id`, pass
`variables` with the values for that call:

```json
{
  "from_number": "+[REDACTED_PHONE]",
  "to_number": "+[REDACTED_PHONE]",
  "agent_id": 12,
  "variables": {
    "name": "Jordan"
  }
}
```

Use string values keyed by placeholder name. These variables apply to
saved agents; this guide's inline `config` example does not use them.

## Failure modes worth handling

| Error | Fix |
|-------|-----|
| `402 Payment Required` | Top up the balance or enable auto-reload |
| `400` with `code: destination_not_allowed` | ThunderPhone does not call that number: invalid numbers, emergency numbers, premium-rate numbers, short codes, or sanctioned countries. From ThunderPhone numbers, only ordinary US and Canadian numbers (no premium-rate). `reason` says which; see [blocked destinations](/api-reference/outbound-calls#blocked-destinations) |
| `409` with `retryable: false` for a ThunderPhone number | Follow the `code`: request outbound calling on the Phone Numbers page, wait for a pending review, or use another eligible number |
| `429` with `code: platform_outbound_daily_limit_reached` | The organization used today's outbound calls from ThunderPhone numbers. Wait for `resets_at` (00:00 UTC), request a higher limit on the Phone Numbers page, or call from a number on your own VoIP connection |
| `429` with `code: concurrent_call_limit_reached` | The organization has as many calls in progress as its [concurrent-call limit](/api-reference/introduction#call-limits) (20 by default, counting inbound, outbound, web and test calls). Retry after the `Retry-After` header's seconds or when a call ends; email support@thunderphone.com to raise the limit |
| `429` with `code: same_number_too_soon` | You called the same `to_number` less than 10 seconds ago. Retry after the `Retry-After` header's seconds |
| `409` with `retryable: false` for a customer VoIP number | Correct the state named by `code`; for verification failures, run [`POST /v1/phone-numbers/{id}/verify-voip`](/api-reference/phone-numbers#verify-a-voip-sourced-number) |
| `409` idempotency conflict | Reuse the key only with the original normalized request, or send a new key |
| `404 from_number is not registered to this organization` | Confirm the `from_number` matches an active phone number in your organization. A number that is already provisioning, failed, releasing, or released also returns this response |
| `500` with only a `detail` field | No outbound trunk is configured. Contact support; this is not a number-state problem you can correct |
| `502 Bad Gateway` | Transient SIP / LiveKit failure; safe to retry |

See the [outbound calls reference](/api-reference/outbound-calls#error-responses)
for the complete stable eligibility-code list and both `409` response shapes.
If an active number becomes ineligible after the request is accepted but before
the call is placed, the final check returns the non-retryable `409` payload.

## Controlling hold time

Outbound calls that run long because the callee is slow to respond
(IVR trees, queues) can be capped with `max_hold_seconds`:

```json
{
  "from_number": "+15551234567",
  "to_number":   "+14155552671",
  "agent_id":    12,
  "max_hold_seconds": 120
}
```

The agent hangs up if no human audio has been received in the last
N seconds. Default is 900 seconds (15 minutes).

---

## Next steps

<CardGroup cols={2}>
  <Card title="Outbound calls reference" icon="phone-arrow-up-right" href="/api-reference/outbound-calls">
    Every request field and error code.
  </Card>
  <Card title="Receive call.complete" icon="bolt" href="/webhooks/call-complete">
    Stream finished outbound calls to your system.
  </Card>
  <Card title="Billing" icon="credit-card" href="/api-reference/billing">
    Auto-reload so outbound never fails on balance.
  </Card>
  <Card title="Test outbound agents" icon="flask" href="/guides/test-agents">
    Dry-run your outbound agent before production.
  </Card>
</CardGroup>
