Open in
Phone Numbers
Provision, assign agents to, transfer, and verify phone numbers.
A phone number belongs to an organization and carries two agent
assignments — one for the inbound direction (who answers) and one for
the outbound direction (who is placed on the caller side). Numbers can
be provisioned by ThunderPhone (a ThunderPhone number,
number_type="thunderphone" in the API) or brought in through your own VoIP
provider and verified via a VoIP connection
(number_type="voip").
Endpoints
| Method | Path | Description |
|---|---|---|
GET | /v1/phone-numbers | List phone numbers |
GET | /v1/phone-numbers/limits | Get the organization cap and usage |
POST | /v1/phone-numbers | Provision a new number |
GET | /v1/phone-numbers/{phone_number_id} | Retrieve a single number |
PUT / PATCH | /v1/phone-numbers/{phone_number_id} | Assign agents / update status |
DELETE | /v1/phone-numbers/{phone_number_id} | Release the number |
POST | /v1/phone-numbers/{phone_number_id}/transfer | Move a number to another org |
GET | /v1/phone-numbers/{phone_number_id}/versions | List prior assignments |
POST | /v1/phone-numbers/{phone_number_id}/verify-voip | Verify a VoIP-sourced number |
POST | /v1/phone-numbers/{phone_number_id}/test-inbound | Test that calls to a VoIP-sourced number reach ThunderPhone |
GET | /v1/outbound-platform-number-request | Read ThunderPhone-number outbound access and the latest request |
POST | /v1/outbound-platform-number-request | Request outbound access for ThunderPhone numbers |
Phone number object
{
"id": 201,
"number": "+15551234567",
"label": "Support line",
"source": "voip",
"number_type": "voip",
"status": "active",
"inbound_agent_id": 12,
"outbound_agent_id": null,
"voip_connection_id": 5,
"voip_verification_status": "verified",
"voip_last_verified_at": "2026-04-20T18:24:10.113Z",
"voip_inbound_status": "passed",
"voip_inbound_tested_at": "2026-04-20T18:30:02.518Z",
"voip_inbound_result": {
"reason": "arrived",
"detail": "A test call to +15551234567 reached ThunderPhone after 2.3 seconds.",
"seconds": 2.3,
"from_number": "+15550100000"
},
"outbound_eligible": true,
"outbound_ineligible_reason": null,
"sms_capable": true,
"sms_capability_checked_at": "2026-09-19T15:42:10.113Z",
"timezone": null
}| Field | Type | Description |
|---|---|---|
id | integer | Server-assigned number id |
number | string | E.164-formatted phone number (e.g. +15551234567) |
label | string | Your display label for the number (also settable via PATCH) |
number_type | string | thunderphone (provisioned by ThunderPhone) or voip (brought via a VoIP connection) |
source | string | Deprecated, use number_type. The same information under legacy values: demo means a ThunderPhone number (the value name predates production ThunderPhone numbers and is kept so existing integrations keep working) and voip means a VoIP number |
status | string | provisioning, active, failed, releasing, or released |
inbound_agent_id | integer | null | Agent that answers inbound calls to this number |
outbound_agent_id | integer | null | Agent used when placing outbound calls from this number. ThunderPhone numbers can set this after staff approves outbound access for the organization |
voip_connection_id | integer | null | Linked VoIP connection (only when number_type="voip") |
voip_verification_status | string | unverified, verified (an answered probe call), carrier_accepted (the carrier accepted the trunk, credentials and caller ID but would not route ThunderPhone's test destination — routable, shown with a caveat), or failed |
voip_last_verified_at | timestamp | null | Last successful verify |
voip_inbound_status | string | Result of the optional inbound test: untested, passed (a call to the number arrived at ThunderPhone) or failed. Reads untested again once the VoIP connection's routing changes or the number is re-imported. Informational only: it never changes status or voip_verification_status. Always untested for ThunderPhone numbers |
voip_inbound_tested_at | timestamp | null | When the inbound test behind voip_inbound_status ran; null while untested |
voip_inbound_result | object | null | That test's reason, detail, seconds (to arrival, on a pass), the from_number it called from and its method (loopback or platform, see the inbound test); null while untested |
voip_inbound_test_method | string | null | How the next inbound test starts: loopback (the number first calls itself through its own outbound SIP trunk; it may still fall back to platform) or platform (a ThunderPhone test number calls it). null for ThunderPhone numbers |
carrier_routing | string | Read-only. How your carrier sends this number's calls to ThunderPhone: direct for a normal import, or migration_split while a migration from another voice platform splits the number's calls between ThunderPhone and that platform. A migration_split number cannot be released, re-imported or given another inbound agent until the split is rolled back or completed |
outbound_eligible | boolean | Whether the same local routing and trunk-configuration gate used at call placement currently allows this number to originate a call. This does not probe the carrier or LiveKit over the network. |
outbound_ineligible_reason | string | null | Stable reason code from the placement gate. ThunderPhone-number access uses managed_number_inbound_only before a request, managed_number_outbound_pending during review, managed_number_outbound_denied after denial, and managed_number_outbound_revoked when a prior approval is no longer enabled. Routing failures include codes such as number_released, voip_verification_failed, voip_connection_error, voip_verification_stale, and outbound_trunk_unavailable |
sms_capable | boolean | null | Cached carrier capability for customer-connected numbers. null means not checked yet. ThunderPhone numbers cannot send SMS |
sms_capability_checked_at | timestamp | null | When the carrier capability was last checked |
timezone | string | null | IANA timezone of the number's area code (US and Canada) or country, for example America/Los_Angeles for a +1 415 number. null when the number spans several zones, such as toll-free numbers. New campaigns from this number default to it |
List phone numbers
curl https://api.thunderphone.com/v1/phone-numbers \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"numbers = requests.get(
"https://api.thunderphone.com/v1/phone-numbers",
headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()Returns an array of Phone number objects, sorted by created_at descending.
Provision a number
Requests a new ThunderPhone number from ThunderPhone's pool, up to the organization's cap (see Phone number limits). To bring your own number through a VoIP provider, see Import VoIP numbers.
curl -X POST https://api.thunderphone.com/v1/phone-numbers \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"area_code": "415"}'number = requests.post(
"https://api.thunderphone.com/v1/phone-numbers",
headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
json={"area_code": "415"},
).json()| Field | Type | Required | Description |
|---|---|---|---|
area_code | string | no | 3-digit area code to prefer |
city | string | no | US city name |
state | string | no | 2-letter US state code |
Returns 201 Created with the new Phone number object in
status="provisioning". The upstream provider usually transitions to
active within seconds; poll the detail endpoint to observe the change.
Retrieve a phone number
curl https://api.thunderphone.com/v1/phone-numbers/201 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"Returns 200 OK with a Phone number object, or 404 if not found.
Assign agents / update status
PATCH clears or changes inbound/outbound agent assignments. The new
agents must belong to the same org.
curl -X PATCH https://api.thunderphone.com/v1/phone-numbers/201 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inbound_agent_id": 12,
"outbound_agent_id": 13
}'number = requests.patch(
f"https://api.thunderphone.com/v1/phone-numbers/{phone_number_id}",
headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
json={"inbound_agent_id": 12, "outbound_agent_id": 13},
).json()| Field | Type | Description |
|---|---|---|
inbound_agent_id | integer | null | Pass null to clear |
outbound_agent_id | integer | null | Pass null to clear. ThunderPhone numbers reject assignment with 400 until staff approves outbound access for the organization |
label | string | Display label |
status | string | Usually set by the server; write access is limited to toggling between active and released. Setting released also clears both agent assignments |
Returns 200 OK with the updated Phone number object.
Outbound platform-number access
ThunderPhone numbers start inbound-only. An organization admin can request permission to use its ThunderPhone numbers as outbound caller IDs. Organization API keys cannot submit this request; use a current human session token or the Phone Numbers page in the dashboard.
Daily limit
The first request asks for a daily limit: how many outbound calls per day the organization needs from its ThunderPhone numbers (100 unless you ask for a different number). ThunderPhone staff approve that number or a different one. Every call attempt counts, whether or not it is answered, and the count resets at 00:00 UTC. Once outbound access is enabled, an admin can request a higher daily limit; staff review each increase. Calls from numbers on your own VoIP connections never count.
A call that would go over the limit is refused with 429 Too Many Requests
before anything is dialed:
{
"code": "platform_outbound_daily_limit_reached",
"detail": "This organization has used all 100 outbound calls for today from ThunderPhone numbers. The limit resets at 00:00 UTC. You can request a higher limit on the Phone numbers page. Calls from your own VoIP numbers have no daily limit.",
"daily_call_limit": 100,
"resets_at": "2026-09-29T00:00:00Z"
}Campaigns that reach the limit keep their remaining contacts pending and continue after the reset.
Read access and usage
GET /v1/outbound-platform-number-request returns the current outbound-access
state and today's usage separately from the latest request. A historical
approval does not mean access is still enabled after ThunderPhone revokes it.
{
"platform_outbound_enabled": true,
"daily_call_limit": 100,
"calls_today": 37,
"resets_at": "2026-09-29T00:00:00Z",
"default_daily_call_limit": 100,
"request": {
"id": 91,
"kind": "outbound_platform_numbers",
"status": "approved",
"requested_count": 100,
"use_case": "Appointment reminders",
"expected_daily_calls": null,
"consent_source": "Patients opt in on the intake form",
"website": "https://clinic.example.com",
"denial_reason": "",
"created_at": "2026-09-19T18:00:00Z",
"resolved_at": "2026-09-19T19:00:00Z"
}
}| Field | Type | Description |
|---|---|---|
platform_outbound_enabled | boolean | Whether ThunderPhone numbers can currently place outbound calls |
daily_call_limit | integer | null | Outbound calls allowed per UTC day; null until enabled |
calls_today | integer | Outbound call attempts from ThunderPhone numbers since 00:00 UTC |
resets_at | timestamp | The next 00:00 UTC |
default_daily_call_limit | integer | The daily limit a first request asks for when it sends no requested_count |
request | object | null | The latest request. requested_count is the daily limit it asks for. expected_daily_calls is only set on older requests or when you send it |
request is null before the first request. A denied request, or an approved
request whose access was later revoked, can be followed by a new request.
The same state appears on each ThunderPhone number as
managed_number_inbound_only, managed_number_outbound_pending,
managed_number_outbound_denied, or managed_number_outbound_revoked in
outbound_ineligible_reason. Once outbound access is enabled, the
number can place calls while it is active, its outbound calling
configuration is available, and the daily limit is not used up.
Request access or a higher limit
curl -X POST https://api.thunderphone.com/v1/outbound-platform-number-request \
-H "Authorization: Token tp_sess_YOUR_HUMAN_SESSION" \
-H "X-ThunderPhone-Org: 42" \
-H "Content-Type: application/json" \
-d '{
"requested_count": 100,
"use_case": "Appointment reminders",
"consent_source": "Patients opt in on the intake form",
"website": "https://clinic.example.com"
}'curl -X POST https://api.thunderphone.com/v1/outbound-platform-number-request \
-H "Authorization: Token tp_sess_YOUR_HUMAN_SESSION" \
-H "X-ThunderPhone-Org: 42" \
-H "Content-Type: application/json" \
-d '{
"requested_count": 500,
"use_case": "Appointment reminders",
"consent_source": "Patients opt in on the intake form",
"website": "https://clinic.example.com"
}'requested_count is the daily limit you ask for, from 1 to 1,000,000. On the
first request it is optional and defaults to 100. Once outbound access is
enabled, it is required and must be more than the current daily_call_limit.
website accepts a full URL or a bare domain such as clinic.example.com,
which is stored as https://clinic.example.com. expected_daily_calls is
optional.
The response is 201 Created with the pending request. A request while another
one is pending returns 409 Conflict.
Release a phone number
curl -X DELETE https://api.thunderphone.com/v1/phone-numbers/201 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"Returns 204 No Content. ThunderPhone numbers are returned to the pool
(and stop billing); VoIP numbers are detached from the phone record but
remain in your VoIP provider.
Transfer a number to another org
Atomically move a number between orgs. Requires admin+ in both orgs.
On transfer, the number's inbound/outbound agent assignments are
cleared (agents don't cross org boundaries).
A number always moves to a different organization, so this endpoint needs a
signed-in session (Authorization: Token <session-token> plus
X-ThunderPhone-Org for the source org). An sk_live_ API key is limited to
its own organization and gets 403.
curl -X POST https://api.thunderphone.com/v1/phone-numbers/201/transfer \
-H "Authorization: Token YOUR_SESSION_TOKEN" \
-H "X-ThunderPhone-Org: 42" \
-H "Content-Type: application/json" \
-d '{"target_org_id": 77}'result = requests.post(
f"https://api.thunderphone.com/v1/phone-numbers/{phone_number_id}/transfer",
headers={"Authorization": "Token YOUR_SESSION_TOKEN", "X-ThunderPhone-Org": "42"},
json={"target_org_id": 77},
).json(){
"source_org_id": 42,
"target_org_id": 77,
"cleared_agent_assignments": true,
"phone_number": { /* Phone number object in the target org */ }
}Version history
Every assignment change records a snapshot so you can audit who changed what.
curl 'https://api.thunderphone.com/v1/phone-numbers/201/versions?limit=20' \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"count": 2,
"results": [
{
"id": 88,
"revision": 2,
"created_by_id": 7,
"created_at": "2026-04-20T18:24:10.113Z",
"snapshot": { /* Phone number object at this revision */ },
"changed_fields": ["inbound_agent_id"]
}
]
}created_by_id is the integer user id that made the change (null
for system-initiated changes).
Verify a VoIP-sourced number
For numbers imported through a VoIP connection, run (or re-run) the
verification flow. Provider connections (Twilio, Telnyx) re-check the
provider credentials and SIP reachability. Manual SIP connections
place a short probe call from this number, through the connection's
trunk, to a ThunderPhone number (LiveKit hangs it up after a few
seconds; your carrier bills it like any call). The carrier's SIP
answer is classified in the evidence — 401/407 credentials,
403 forbidden (credentials or caller ID), 404/484 unroutable,
480/408 no answer, 5xx provider error — and the same text is
returned as the failure reason. One exception is known to be a
post-authentication answer: DIDWW's 403 Destination number blacklisted by routing plan means the trunk and credentials were
accepted but the customer's plan does not route ThunderPhone's test
destination. That number becomes voip_verification_status = "carrier_accepted" (evidence outcome: "carrier_accepted"): routable,
but calls to other destinations were not tested. Only an answered call
yields verified.
Verification is admin-only, rate-limited, and serialised per number
(409 while a verification of the same number is in flight, and
409 if the number or its connection changed while the probe was
running — verify again). Numbers that are released cannot be
verified.
curl -X POST https://api.thunderphone.com/v1/phone-numbers/201/verify-voip \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"phone_number_id": 201,
"status": "active",
"voip_verification_status": "verified",
"provider_charge_estimate_cents": 0,
"provider_charge_currency": "usd",
"provider_charge_note": ""
}Verification may take several seconds; the response reflects the final
state. If verification fails, voip_verification_status becomes
failed and the phone status returns to provisioning — fix the VoIP
connection (credentials, trunk, SIP allowlist) and call this endpoint
again.
Verification proves that the number can place calls. It never calls the number, so it does not prove that calls to it reach ThunderPhone; use the inbound test below for that.
Test inbound calls to a VoIP number
POST /v1/phone-numbers/{phone_number_id}/test-inbound calls the number
and checks that your carrier delivers the call back to ThunderPhone. When
it arrives, ThunderPhone hangs up both legs: no agent answers, and nothing
appears in call history or billing. The response reflects the result.
The test uses one of two methods, reported as inbound_probe.method:
loopback: for numbers on a connection with its own outbound SIP trunk (manual SIP, and Twilio once ThunderPhone has provisioned outbound calling in the customer's Twilio account), the number first calls itself through that trunk. The number'svoip_inbound_test_methodsays in advance whether it will. The call goes out through your carrier and back in on the number's inbound routing. When it arrives under the number's own caller ID,inbound_probe.proves_outboundistrue: that call also proves your carrier accepts ThunderPhone's calls as the number. It rings for up to 8 seconds, and waits up to 8 seconds on an answered call. A phone the number forwards to may therefore ring twice: once showing the number's own caller ID, then ThunderPhone's test number if the test falls back. A self-call that is refused, rings out or is answered without arriving is inconclusive (some carriers refuse or don't connect a call from a number to itself), never a failure: the test then falls back toplatform, andinbound_probe.loopbackrecords what the self-call did (outcomerefused,no_arrival,answered_no_arrivalorplatform_error, withsip_status_codeand the carrier'scarrier_message).platform: a ThunderPhone test number calls the number. It rings for up to 8 seconds; if something answers before the call reaches ThunderPhone (a Twilio app that answers and then forwards, a phone menu), the test stays on the line for up to 15 seconds waiting for the forwarded call.
The whole test takes up to about 30 seconds, or about 50 when a self-call falls back.
The test is optional and informational. It never changes the number's
status, voip_verification_status or whether it can route calls, and
ThunderPhone never runs it on its own (re-verification after a connection
change is outbound only). The number must be active and verified
(voip_verification_status of verified or carrier_accepted), otherwise
the request returns 400. It is admin-only, shares verification's rate
limit, and returns 409 while a verification or another inbound test of
the same number is running, or if the number or its connection changed
during the test.
inbound_probe.status is one of:
| Status | Meaning |
|---|---|
pass | The call reached ThunderPhone, with seconds_to_arrival. reason is arrived, or arrived_caller_id_changed when a call to the number arrived during the test under another caller ID, most likely the test call after a carrier changed its caller ID. That call is handled as an ordinary call, so your agent may answer it for a moment |
fail | It did not. reason is no_arrival (it rang for 8 seconds without reaching ThunderPhone), answered_elsewhere (something answered it and it had not reached ThunderPhone 15 seconds later: a phone, voicemail, another service, or a forward to the wrong address), busy, unroutable or forbidden (refused by the carrier). On a Manual SIP connection with Allowed Source IPs, a call that reached ThunderPhone in a way real calls are refused also fails: direct_source_not_allowed (it came straight to ThunderPhone, not through sip.thunderphone.com), edge_source_not_allowed (it came from an address not on the list; detail names it) or edge_source_unverified (its source address couldn't be verified) |
unavailable | ThunderPhone could not run the test (not configured, a problem on ThunderPhone's side, or reason country_not_dialable: ThunderPhone can't place test calls to the number's country yet, currently numbers outside the US and Canada). After an inconclusive self-call that the platform method cannot follow up, reason is loopback_refused (the self-call was refused; see loopback.carrier_message) or loopback_inconclusive (it went out but didn't come back). source_unconfirmed: the call reached ThunderPhone, but where it came from couldn't be read, so whether Allowed Source IPs let it in is unknown. None of these says anything about the number's inbound routing. Nothing is recorded and the previous result stays |
A pass or fail is stored on the number as voip_inbound_status,
voip_inbound_tested_at and voip_inbound_result, and resets to
untested when the VoIP connection's routing or Allowed Source IPs
change, or the number is re-imported. If answered_elsewhere comes back, your carrier answered the
test call somewhere else; it may bill up to about 20 seconds.
curl -X POST https://api.thunderphone.com/v1/phone-numbers/201/test-inbound \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"phone_number_id": 201,
"voip_inbound_status": "passed",
"voip_inbound_tested_at": "2026-04-20T18:30:02.518Z",
"inbound_probe": {
"status": "pass",
"reason": "arrived",
"detail": "A test call to +15551234567 reached ThunderPhone after 2.3 seconds.",
"seconds_to_arrival": 2.3,
"from_number": "+15550100000",
"tested_at": "2026-04-20T18:30:02.518Z",
"method": "platform",
"loopback": null,
"proves_outbound": false
}
}Phone number limits
GET /v1/phone-numbers/limits returns the current organization's ThunderPhone
number cap and usage. Authentication and organization selection are the same as
for listing phone numbers.
{"max_platform_numbers": 25, "platform_numbers_used": 3}Usage counts platform numbers that are provisioning, active, or releasing. Request a higher limit from the Phone numbers page.