---
title: "VoIP Connections"
description: "Bring your own phone numbers into ThunderPhone from Twilio or Telnyx, or from SignalWire, Vonage, Plivo and any other SIP trunk as manual connections: create VoIP connections, verify credentials, and manage imported numbers."
---

VoIP connections let you plug your existing telephony provider into
ThunderPhone. Once connected, you can list, import, and verify phone
numbers from your provider account; imported numbers become
[Phone number](/api-reference/phone-numbers) resources you can assign
agents to.

## Supported providers

| Provider | `provider` id | Setup methods |
|----------|---------------|---------------|
| Twilio | `twilio` | `api_key` |
| Telnyx | `telnyx` | `api_key`, `guided_telnyx` |
| SignalWire | `signalwire` | Not available yet — connect it as `manual` (the API returns `400` for `provider: "signalwire"`) |
| Vonage | `vonage` | Not available yet — connect it as `manual` (the API returns `400` for `provider: "vonage"`) |
| Manual (any SIP trunk) | `manual` | `manual` |

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/voip-connections` | List connections |
| `POST` | `/v1/voip-connections` | Create a connection |
| `PATCH` | `/v1/voip-connections/{connection_id}` | Update credentials / name |
| `DELETE` | `/v1/voip-connections/{connection_id}` | Remove a connection |
| `POST` | `/v1/voip-connections/{connection_id}/enable-transfers` | Turn call transfers on at the carrier (Twilio) |
| `POST` | `/v1/voip-connections/test` | Test raw credentials (no persisted connection) |
| `GET` | `/v1/voip-connections/sip-host` | The SIP host your trunk sends inbound calls to (manual connections) |
| `POST` | `/v1/voip-connections/suggest-name` | Infer a display name from credentials |
| `POST` | `/v1/voip-connections/twilio/subaccounts` | Discover the subaccounts under a Twilio parent account |
| `POST` | `/v1/voip-connections/twilio/subaccounts/connect` | Create one Twilio connection per chosen subaccount |
| `GET` | `/v1/voip-connections/{connection_id}/available-numbers` | List numbers already owned in the provider account |
| `POST` | `/v1/voip-connections/{connection_id}/import-numbers` | Import selected numbers |
| `GET` | `/v1/voip-connections/{connection_id}/search-numbers` | Search purchasable numbers (guided provisioning) |
| `POST` | `/v1/voip-connections/{connection_id}/provision-number` | Buy a number through the connection (guided provisioning) |

<Note>
  There is no `GET /v1/voip-connections/{connection_id}` retrieve
  endpoint — fetch the list and select by `id`.
</Note>

## Connection object

```json
{
  "id": 5,
  "name": "Acme Telnyx Main",
  "provider": "telnyx",
  "status": "connected",
  "setup_method": "api_key",
  "inferred_name": false,
  "credentials": { "apiKey": "********", "connectionId": "123456" },
  "sip_config": { "domain": "acme.sip.telnyx.com" },
  "test_result": { "checks": { "credentials_valid": true } },
  "last_tested_at": "2026-04-20T18:24:10.113Z",
  "numbers_imported": 3,
  "provisioning_supported": true,
  "provisioning_message": null,
  "transfer_refused_at": null,
  "transfer_enabled_at": null,
  "relay": null,
  "created_at": "2026-04-20T18:24:10.113Z",
  "updated_at": "2026-04-20T18:24:10.113Z"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Connection id |
| `name` | string | Display name |
| `provider` | string | `twilio`, `telnyx`, `signalwire`, `vonage`, `manual` |
| `status` | string | `pending`, `testing`, `connected`, `error` (read-only) |
| `setup_method` | string | `api_key`, `manual`, `guided_telnyx` |
| `inferred_name` | boolean | `true` when we auto-generated the name (read-only) |
| `credentials` | object | Returned with sensitive values **masked** — any key containing `key`, `token`, `secret`, or `password` reads `"********"`. Writable on create/update |
| `sip_config` | object | Provider-specific SIP config. Writable on create/update; sensitive values masked on read the same way. A `manual` connection set up from a named provider in the dashboard also carries `preset` (`signalwire`, `vonage` or `plivo`); it is a label only and does not change routing |
| `test_result` | object | Result payload of the most recent credential test (read-only) |
| `last_tested_at` | timestamp \| null | Last credential test (read-only) |
| `numbers_imported` | integer | Count of phone numbers imported through this connection (read-only) |
| `provisioning_supported` | boolean | Whether [guided number provisioning](#search-purchasable-numbers) works for this provider (currently Telnyx and Twilio) |
| `provisioning_message` | string \| null | When unsupported: an explanation, e.g. `"Provisioning not yet supported for twilio; use Import."` |
| `transfer_refused_at` | timestamp \| null | Latest time the carrier refused a cold transfer on this connection, so ThunderPhone connected the call itself. Cleared when [transfers are turned on](#turn-on-call-transfers); a refusal on a call that ended before then does not set it again (read-only) |
| `transfer_enabled_at` | timestamp \| null | When call transfers were turned on at the carrier through [Turn on call transfers](#turn-on-call-transfers) (read-only) |
| `relay` | object \| null | Set when ThunderPhone's SIP registration relay signs in to the carrier for this connection, for carriers that only deliver calls to a registered device. Enabled by ThunderPhone support. `state` is `registering`, `registered`, `refused` (the carrier refused the username or password), `unreachable`, `evicted` (another device signed in with the same account), `relay_down` or `off`; `reason_code` is the carrier's SIP status or the relay's reason for `refused`, `unreachable` and `evicted`; `since` is when it entered that state; `last_registered_at`; `relay_ip` is the relay's address, which ThunderPhone adds to the connection's inbound allowlist (not part of `sip_config.inboundSourceIps`, and not removable). Never carries credentials (read-only) |

<Note>
  The masked `credentials` object shows which keys are configured
  without revealing values. To rotate credentials, `PATCH` with a full
  new `credentials` object.
</Note>

---

## List connections

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/voip-connections \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns an array of [Connection objects](#connection-object).

---

## Create a connection

Creation is a two-phase flow:

1. Call [`POST /v1/voip-connections/test`](#test-credentials) with the
   credentials and get back a **verification evidence id** if the test
   passes.
2. Call this endpoint with the same credentials plus
   `verification_evidence_id` — the evidence proves the credentials
   were already validated, avoiding double-billing for the test.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Telnyx Main",
    "provider": "telnyx",
    "setup_method": "api_key",
    "credentials": {
      "apiKey": "KEY...",
      "connectionId": "123456"
    },
    "sip_config": { "domain": "acme.sip.telnyx.com" },
    "verification_evidence_id": "b9a2..."
  }'
```
</CodeGroup>

### Request fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `provider` | string | yes | Provider id (see table above) |
| `name` | string | no | Defaults to a provider-inferred value |
| `setup_method` | string | no | Defaults to `api_key`; Telnyx supports `guided_telnyx` |
| `credentials` | object | yes | Provider-specific key/secret/connection fields |
| `sip_config` | object | no | Extra SIP config — e.g. `{"domain": "..."}` |
| `verification_evidence_id` | UUID | yes | Id returned by `POST /voip-connections/test` |
| `enable_transfers` | boolean | no | Twilio only. Also turn call transfers on for the connection's SIP trunk, exactly as [Turn on call transfers](#turn-on-call-transfers) does. Defaults to `false`; `400` for other providers |

Returns `201 Created` with the [Connection object](#connection-object). With
`enable_transfers`, a Twilio error never fails the create: the connection is
saved without transfers, `transfer_enabled_at` stays `null`, and
`test_result.transfer_enable_error` says why. Call
[Turn on call transfers](#turn-on-call-transfers) to try again; success
removes `transfer_enable_error`.

Errors:

| Status | Condition |
|--------|-----------|
| `400` | Provider mismatch (evidence generated for a different provider) |
| `400` | Evidence reused / stale / missing |
| `422` | Credentials failed re-verification |

---

## Update a connection

<CodeGroup>
```bash cURL
curl -X PATCH https://api.thunderphone.com/v1/voip-connections/5 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Renamed"}'
```
</CodeGroup>

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | |
| `credentials` | object | Rotate credentials. Requires a fresh `verification_evidence_id` when the merged value differs from what is stored |
| `sip_config` | object | Manual connections: the full SIP settings. Changing them requires a fresh `verification_evidence_id`; the connection returns `status: "pending"` and every number on it drops to `provisioning` while ThunderPhone rebuilds the trunk pair and re-verifies each number with a probe call in the background (`numbers_pending_verification` in the response lists them). Numbers return to `active` only after their probe call succeeds. A change to `inboundSourceIps` alone (order, blanks and duplicates do not count) on a `connected` connection is applied to the inbound trunk immediately instead: the connection stays `connected`, its numbers stay `active` and verified, and each number's incoming-call test resets to `untested`. If the trunk cannot be updated the request returns `502` and nothing is saved. A `pending` or `error` connection re-verifies as above |
| `verification_evidence_id` | UUID | Required whenever `credentials` or `sip_config` actually change |

Secrets are **merged, never echoed**: a key containing `key`, `token`,
`secret` or `password` that you send as `"********"`, as an empty
string, or omit entirely keeps its stored value; send `null` to clear
it. A request that only echoes the masked read-back is not a credential
change and needs no evidence.

---

## Delete a connection

<CodeGroup>
```bash cURL
curl -X DELETE https://api.thunderphone.com/v1/voip-connections/5 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns `204 No Content`. Phone numbers imported through the deleted
connection are **not** removed — they stay in your org with
`voip_connection_id` cleared.

---

## Turn on call transfers

Twilio trunks refuse cold transfers until the trunk allows them. This sets
the connection's Twilio SIP trunk to `TransferMode=enable-all` and leaves the
trunk's caller-ID setting as it is. With Twilio's default
(`TransferCallerId=from-transferee`), the transferred call shows the number
of the person being transferred (the caller on inbound calls, the person
your agent called on outbound calls). Transferred calls are then carried and
billed on your Twilio account. Admin or owner only.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections/5/enable-transfers \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns `200` with the [connection object](#connection-object):
`transfer_enabled_at` set and `transfer_refused_at` cleared. It is safe to
call again: Twilio is left unchanged if transfers are already on, and the call
returns `200`.

| Status | When |
|--------|------|
| `409` | Not a Twilio connection (turn on call transfers, SIP REFER, in your carrier's settings; `detail` says where), the connection has no SIP trunk or credentials, or Twilio has no such trunk |
| `502` | Twilio rejected the stored credentials or the update failed |

---

## Test credentials

Probes the provider API with the supplied credentials (for `manual`,
checks that the SIP host resolves to a public address and, for TCP/TLS,
accepts a connection on the port — a real call is only placed when a
number is verified). On success, the response includes a
`verification_evidence_id` you can pass to
[Create](#create-a-connection) or [Update](#update-a-connection) to
create/rotate the connection without a second round trip.

Pass `connection_id` to test an **edit**: the submitted `credentials` /
`sip_config` are merged over the stored connection (masked, blank or
omitted secrets keep their stored values, exactly as
[Update](#update-a-connection) will merge them), so the evidence matches
the update you are about to send.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections/test \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider":    "telnyx",
    "credentials": { "apiKey": "KEY...", "connectionId": "123456" },
    "sip_config":  { "domain": "acme.sip.telnyx.com" }
  }'
```
</CodeGroup>

```json Response (success)
{
  "status": "pass",
  "verification_evidence_id": "b9a2...",
  "suggested_connection_name": "Telnyx: Acme Main (+15550001234)",
  "checks": {
    "credentials_valid": true,
    "inbound_reachable": true,
    "outbound_authorized": true
  }
}
```

---

## Get the inbound SIP host

`GET /v1/voip-connections/sip-host`

Returns the SIP host a `manual` connection's trunk must send inbound
calls to: `sip.thunderphone.com`, ThunderPhone's SIP edge. Point the
trunk at `<sip_host>:5060` for UDP or TCP, or `<sip_host>:5061` for TLS,
and address each call to the dialed number in E.164 form, with the
same number, including the `+`, in both the request line and the `To`
header (`sip:+14155550142@<sip_host>`). Read it from here rather than
keeping a copy. Trunks set up earlier with a
`<your-project>.sip.livekit.cloud` address keep working while the
connection has no allowed source IPs. The edge's IP addresses
are listed under
[IP allowlisting](/guides/voip-providers#ip-allowlisting-and-trunk-authentication).

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/voip-connections/sip-host \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response
{
  "sip_host": "sip.thunderphone.com"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `sip_host` | string \| null | Hostname only, without scheme or port. `null` when inbound SIP is not configured in this environment; contact support |

---

## Suggest a connection name

Asks the provider for an inferred display name. Used by the dashboard
onboarding flow.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections/suggest-name \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider":    "telnyx",
    "credentials": { "apiKey": "KEY..." }
  }'
```
</CodeGroup>

```json Response
{
  "suggested_connection_name": "Telnyx: Acme Main",
  "suggested_connection_name_inferred": true
}
```

---

## Discover Twilio subaccounts

Lists the active subaccounts a Twilio parent account can see, marking
the ones this org already has a connection for. Pass either raw parent
`credentials` or the `connection_id` of an existing Twilio connection.
Subaccount auth tokens are read from Twilio and kept server-side under
the returned `discovery_id` (valid for 30 minutes); they are never
returned. Admin role required.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections/twilio/subaccounts \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "credentials": { "accountSid": "ACparent...", "authToken": "..." }
  }'
```
</CodeGroup>

```json Response
{
  "discovery_id": "7c1e...",
  "parent": { "sid": "ACparent...", "friendly_name": "Acme" },
  "subaccounts": [
    { "sid": "ACsupport...", "friendly_name": "Acme Support", "already_connected": false, "connection_id": null },
    { "sid": "ACsales...",   "friendly_name": "Acme Sales",   "already_connected": true,  "connection_id": 5 }
  ]
}
```

An empty `subaccounts` array means the credentials belong to an
account with no active subaccounts (or to a subaccount itself).

---

## Connect Twilio subaccounts

Creates one ordinary Twilio connection per selected subaccount, each
with its own verification evidence and SIP trunk inside that
subaccount. Rows are independent: one subaccount failing never blocks
the others, and a failed `sid` can be sent again with the same
`discovery_id` until it expires. Admin role required.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections/twilio/subaccounts/connect \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "discovery_id":   "7c1e...",
    "subaccount_sids": ["ACsupport...", "ACsales..."]
  }'
```
</CodeGroup>

```json Response
{
  "results": [
    { "sid": "ACsupport...", "friendly_name": "Acme Support", "status": "created", "connection": { "id": 9, "provider": "twilio", "setup_method": "twilio_subaccount", "...": "..." }, "error": null },
    { "sid": "ACsales...",   "friendly_name": "Acme Sales",   "status": "skipped", "connection": { "id": 5, "...": "..." }, "error": null }
  ]
}
```

| `status` | Meaning |
|----------|---------|
| `created` | A new connection (and trunk) exists; `connection` is the new [Connection object](#connection-object) |
| `skipped` | The org already had a connection for this subaccount; `connection` is that one |
| `failed` | Nothing was created; `error` says why (Twilio rejected the token, trunk creation failed, unknown `sid`) |

Returns `400` when the `discovery_id` has expired ("Subaccount
discovery expired. Run discovery again.").

---

## List available numbers

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/voip-connections/5/available-numbers \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns the list of phone numbers visible to this connection's
credentials that are **not** yet imported into any ThunderPhone org:

```json
[
  {
    "id": "provider-handle-abc",
    "number": "+15550001234",
    "friendlyName": "Main office",
    "type": "local",
    "capabilities": ["voice"],
    "region": "US-CA",
    "monthlyFee": 100
  }
]
```

---

## Import numbers

Moves the selected numbers into ThunderPhone as
[Phone number](/api-reference/phone-numbers) resources. Imports start
in `status="provisioning"` — call
[`POST /v1/phone-numbers/{id}/verify-voip`](/api-reference/phone-numbers#verify-a-voip-sourced-number)
to complete verification. For a manual SIP connection that places a
short probe call from the number, through your trunk, to a ThunderPhone
number — see [Verify a VoIP-sourced number](/api-reference/phone-numbers#verify-a-voip-sourced-number).

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections/5/import-numbers \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"numbers": ["+15550001234", "+15550009999"]}'
```
</CodeGroup>

```json Response
{
  "imported_count": 2,
  "provider_charge_estimate_cents": 0,
  "provider_charge_note": "Import does not create ThunderPhone charges. Provider charges are billed by your VoIP provider only.",
  "results": [
    { "id": 201, "number": "+15550001234", "status": "provisioning" },
    { "id": 202, "number": "+15550009999", "status": "provisioning" }
  ]
}
```

`numbers` accepts 1–50 entries per request.

---

## Search purchasable numbers

For connections where `provisioning_supported` is `true` (currently
**Telnyx** only), search the provider's inventory for numbers you can
buy without leaving ThunderPhone.

<CodeGroup>
```bash cURL
curl 'https://api.thunderphone.com/v1/voip-connections/5/search-numbers?country=US&area_code=415&type=local' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

| Query param | Required | Description |
|-------------|----------|-------------|
| `country` | yes | Two-letter ISO country code |
| `type` | yes | `local` or `toll-free` |
| `area_code` | no | Digits only |

```json Response
{
  "results": [
    {
      "id": "provider-handle-abc",
      "number": "+14155550123",
      "friendlyName": "+1 415 555 0123",
      "type": "local",
      "capabilities": ["voice"],
      "region": "US-CA",
      "monthlyFee": 100,
      "monthlyFeeCurrency": "USD"
    }
  ],
  "provider_charge_note": "Purchasing this number and its recurring monthly service are billed directly by Telnyx. ThunderPhone does not add a provisioning fee."
}
```

| Status | Condition |
|--------|-----------|
| `200` | Search results |
| `501` | Provider does not support guided provisioning — use [Import](#import-numbers) |
| `502` | Provider API error / temporarily unavailable |

## Provision (buy) a number

Places a number order with the provider using the connection's
credentials, then registers the number in ThunderPhone. The provider —
not ThunderPhone — bills the purchase and monthly fee.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voip-connections/5/provision-number \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"number": "+14155550123"}'
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `number` | string | yes | Explicit E.164 number from a [search](#search-purchasable-numbers) result |

```json Response (201 Created)
{
  "order_id": "ord_abc123",
  "order_status": "pending",
  "provider_charge_note": "Purchasing this number and its recurring monthly service are billed directly by Telnyx. ThunderPhone does not add a provisioning fee.",
  "result": { "id": 203, "number": "+14155550123", "status": "provisioning" }
}
```

Returns `201 Created` when a new order was placed, or `200 OK` when
the number was already owned and only registration was needed.

| Status | Condition |
|--------|-----------|
| `400` | Invalid number / order rejected |
| `501` | Provider does not support guided provisioning |
| `502` | Provider API error / temporarily unavailable |

---

## Related

<CardGroup cols={2}>
  <Card title="Phone Numbers" icon="phone" href="/api-reference/phone-numbers">
    Assign agents to imported numbers and run verify-voip.
  </Card>
  <Card title="Outbound Calls" icon="arrow-up-right" href="/api-reference/outbound-calls">
    Place calls from a customer VoIP number or an approved ThunderPhone number.
  </Card>
</CardGroup>
