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

Phone Numbers

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

MethodPathDescription
GET/v1/phone-numbersList phone numbers
GET/v1/phone-numbers/limitsGet the organization cap and usage
POST/v1/phone-numbersProvision 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}/transferMove a number to another org
GET/v1/phone-numbers/{phone_number_id}/versionsList prior assignments
POST/v1/phone-numbers/{phone_number_id}/verify-voipVerify a VoIP-sourced number
POST/v1/phone-numbers/{phone_number_id}/test-inboundTest that calls to a VoIP-sourced number reach ThunderPhone
GET/v1/outbound-platform-number-requestRead ThunderPhone-number outbound access and the latest request
POST/v1/outbound-platform-number-requestRequest 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
}
FieldTypeDescription
idintegerServer-assigned number id
numberstringE.164-formatted phone number (e.g. +15551234567)
labelstringYour display label for the number (also settable via PATCH)
number_typestringthunderphone (provisioned by ThunderPhone) or voip (brought via a VoIP connection)
sourcestringDeprecated, 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
statusstringprovisioning, active, failed, releasing, or released
inbound_agent_idinteger | nullAgent that answers inbound calls to this number
outbound_agent_idinteger | nullAgent used when placing outbound calls from this number. ThunderPhone numbers can set this after staff approves outbound access for the organization
voip_connection_idinteger | nullLinked VoIP connection (only when number_type="voip")
voip_verification_statusstringunverified, 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_attimestamp | nullLast successful verify
voip_inbound_statusstringResult 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_attimestamp | nullWhen the inbound test behind voip_inbound_status ran; null while untested
voip_inbound_resultobject | nullThat 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_methodstring | nullHow 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_routingstringRead-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_eligiblebooleanWhether 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_reasonstring | nullStable 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_capableboolean | nullCached carrier capability for customer-connected numbers. null means not checked yet. ThunderPhone numbers cannot send SMS
sms_capability_checked_attimestamp | nullWhen the carrier capability was last checked
timezonestring | nullIANA 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
curl https://api.thunderphone.com/v1/phone-numbers \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Python
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
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"}'
Python
number = requests.post(
    "https://api.thunderphone.com/v1/phone-numbers",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
    json={"area_code": "415"},
).json()
FieldTypeRequiredDescription
area_codestringno3-digit area code to prefer
citystringnoUS city name
statestringno2-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
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
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
  }'
Python
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()
FieldTypeDescription
inbound_agent_idinteger | nullPass null to clear
outbound_agent_idinteger | nullPass null to clear. ThunderPhone numbers reject assignment with 400 until staff approves outbound access for the organization
labelstringDisplay label
statusstringUsually 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:

Response
{
  "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.

Response
{
  "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"
  }
}
FieldTypeDescription
platform_outbound_enabledbooleanWhether ThunderPhone numbers can currently place outbound calls
daily_call_limitinteger | nullOutbound calls allowed per UTC day; null until enabled
calls_todayintegerOutbound call attempts from ThunderPhone numbers since 00:00 UTC
resets_attimestampThe next 00:00 UTC
default_daily_call_limitintegerThe daily limit a first request asks for when it sends no requested_count
requestobject | nullThe 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

First request
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"
  }'
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": 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
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
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}'
Python
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()
Response
{
  "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
curl 'https://api.thunderphone.com/v1/phone-numbers/201/versions?limit=20' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
  "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
curl -X POST https://api.thunderphone.com/v1/phone-numbers/201/verify-voip \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
  "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's voip_inbound_test_method says 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_outbound is true: 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 to platform, and inbound_probe.loopback records what the self-call did (outcome refused, no_arrival, answered_no_arrival or platform_error, with sip_status_code and the carrier's carrier_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:

StatusMeaning
passThe 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
failIt 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)
unavailableThunderPhone 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
curl -X POST https://api.thunderphone.com/v1/phone-numbers/201/test-inbound \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
  "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.