SwitchID API Documentation
The SwitchID Verification API checks a US phone number against a person or business name that you supply. Phase 1 offers one check, core_phone. It returns the number's carrier and line type, the caller-ID name where one is registered, and a comparison between that caller-ID name and the name you sent. Only US numbers are supported. Results are evidence for your own review: the API does not return approve or deny decisions.
The email, device, IP and card signals described on the SwitchID product page are not yet available through this API. This page documents only what the API returns today, as of contract version 2026-10-15.2.
Base URL and authentication#
https://www.switchlabs.dev/api/switchid/v1Requests and responses are JSON over HTTPS. Responses carry Cache-Control: no-store because they contain consumer data.
| Key prefix | Environment | Behaviour |
|---|---|---|
sid_test_... | test | Runs against a deterministic sandbox and never reaches a paid provider. Usage is recorded but never invoiced. |
sid_live_... | live | Performs real carrier and caller-ID lookups. Metered and invoiced monthly. |
- Send the key on every request as
Authorization: Bearer sid_test_...orAuthorization: Bearer sid_live_.... A missing, unknown, revoked or expired key returns 401unauthorized. - Each key belongs to one account and one environment. Results carry the matching
environment, and a key can read only requests and usage from its own account and environment. - Keys are issued with the scopes
verify:write,requests:read,usage:read. A key without the scope an endpoint needs returns 403forbidden_scope. - Call the API from your server. Do not ship a key in a browser, mobile app or public repository.
Getting a key
Create a sandbox key on the SwitchID sandbox page. For live access, contact us.
Idempotency#
Every check can cost money, so every POST carries a key that identifies one logical check. Retrying with the same key is never billed twice.
Idempotency-Keyis required on every POST. It must be 1 to 128 printable characters. Without a valid key the request returns 400missing_idempotency_keybefore anything runs.- Use one key per logical check, for example your application id plus a step name, or a UUID you store with the application.
- The same key with the same body returns the original result, with the same
request_id, and is never billed twice. For this comparison the phone number is compared after normalization, the name ignores case and repeated spaces, and the order ofadd_onsdoes not matter. - The same key with a different body returns 409
idempotency_conflict. - If the first request with a key is still running, a repeat returns 202 with
{ "request_id": "...", "status": "pending" }. PollGET /requests/{id}instead of sending a new check. - After a network error, a timeout or a 5xx response, retry with the same key. A same-key retry is always safe: it runs the check only if the first attempt never started, and otherwise returns the stored outcome.
- A stored failure or rejection is final for its key. Replaying the key returns the same error and does not run the check again. Start a new check with a new key only after
GET /requests/{id}confirms the failure was not billed and its cause is resolved. Never change keys to get around a pending request or aledger_unavailableresponse. - Keys are remembered for at least 30 days, separately for each account, environment and endpoint.
POST /verify#
/verifyVerify a phone number against a supplied nameHeaders
| Header | Required | Value |
|---|---|---|
Authorization | Yes | Bearer sid_test_... |
Idempotency-Key | Yes | A unique key for this logical check |
Content-Type | Yes | application/json |
Body
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Yes | US number in any common format, such as +12025550100 or (202) 555-0100. A number that cannot be parsed returns 422 invalid_phone. Non-US numbers, including Canada and the Caribbean, return 422 unsupported_country. US territories such as Puerto Rico and Guam are currently treated as non-US. |
name | string | Yes | The person or business name you want compared with the registered caller-ID name. Up to 200 characters, no control characters. |
subject_type | string | Yes | person or business. Selects the name-comparison rules. |
reference_id | string | No | Your own identifier, up to 128 characters. Stored and echoed back in subject.reference_id, never interpreted. |
add_ons | array of strings | No | Phase 1 accepts only an empty list or ["core_phone"]. Any other value returns 403 forbidden_sku. The core_phone check always runs. |
Send Content-Type: application/json. Unknown fields are rejected with 400 invalid_request, and the details array names the field.
curl -sS -X POST https://www.switchlabs.dev/api/switchid/v1/verify \
-H "Authorization: Bearer sid_test_..." \
-H "Idempotency-Key: app-2026-000123-phone-1" \
-H "Content-Type: application/json" \
-d '{
"phone": "+12025550100",
"name": "John Smith",
"subject_type": "person",
"reference_id": "APP-2026-000123"
}'The sandbox returns this for +12025550100. Your request_id and timestamps will differ.
{
"request_id": "3f2b8c1e-7d4a-4e9b-9c61-5a0d2e8f4b17",
"environment": "test",
"created_at": "2026-10-16T14:03:21.418Z",
"completed_at": "2026-10-16T14:03:21.463Z",
"product_version": "2026-10-15.2",
"rule_version": "core-phone.v2",
"result_state": "completed",
"subject": {
"phone_e164": "+12025550100",
"phone_national": "(202) 555-0100",
"subject_type": "person",
"supplied_name": "John Smith",
"reference_id": "APP-2026-000123"
},
"checks": {
"requested": [
"core_phone"
],
"completed": [
"core_phone"
]
},
"phone": {
"valid": true,
"country": "US",
"line_type": "mobile",
"line_type_raw": "mobile",
"carrier_name": "T-MOBILE USA INC",
"carrier_type": "wireless",
"ported": false,
"rate_center": {
"city": "WASHINGTON",
"state": "DC"
}
},
"caller_id": {
"state": "available",
"name": "SMITH JOHN",
"error_code": null
},
"name_match": {
"state": "match",
"score": 98,
"reasons": [
"last_name_exact",
"first_name_exact"
]
},
"signals": {
"voip_or_virtual": false,
"toll_free": false
},
"provenance": [
{
"provider": "sandbox",
"sku": "core_phone",
"cache_hit": false,
"attempted_at": "2026-10-16T14:03:21.463Z"
}
],
"billing": {
"disposition": "billable",
"sku": "core_phone",
"unit_price_microdollars": 15000,
"cache_hit": false
},
"warnings": [
"rate_center_is_not_subscriber_address"
]
}Responses
| Status | Meaning |
|---|---|
| 200 | A completed check, or a result with result_state unavailable for a number reported as not allocated or invalid. |
| 202 | The same Idempotency-Key is still being processed. The body is { "request_id": "...", "status": "pending" }. |
| 400, 401, 403, 409, 422, 429, 500, 502, 503 | Error envelope. See Errors for every code. |
Reading a result#
Each field group in a 200 result, and how to use it.
name_match.state unavailable means there was no usable caller-ID name to compare (caller_id.state is placeholder, unavailable or error). It is never a mismatch. Treat it as no evidence either way.
| Field | Values | How to read it |
|---|---|---|
result_state | completed, unavailable, error | completed: the check ran and the fields below are filled in. unavailable: the number is reported as not allocated or invalid; it comes back as HTTP 200 with an empty checks.completed and is not billed. error is part of the schema, but a check that fails today is returned as an error response rather than a 200 body. |
subject | object | What you sent, normalized: phone_e164, phone_national, subject_type, supplied_name and reference_id. |
checks.requested, checks.completed | ["core_phone"] | Which checks were asked for and which finished. |
phone.line_type | mobile, landline, voip, toll_free, premium, pager, unknown | Normalized line type. phone.line_type_raw keeps the original value from the lookup. |
phone.carrier_name, phone.carrier_type | carrier_type: wireless, landline, voip, unknown | The carrier the number currently belongs to. carrier_type can be unknown even when line_type is known. |
phone.valid | true, false, null | The lookup's validity flag when one is returned. It is not an activity or reachability signal. When it is missing you also get the provider_validity_not_returned warning. |
phone.ported | true, false, null | Whether the number has moved between carriers. It is not evidence that the subscriber changed. |
phone.rate_center | { city, state } or null | The rate-center location the number is assigned to. It is not the subscriber's address, and it always comes with the rate_center_is_not_subscriber_address warning. |
caller_id.state | available, placeholder, unavailable, error | available: a usable registered name is in caller_id.name. placeholder: the carrier returned a generic string such as WIRELESS CALLER, and caller_id.name is null. unavailable: no caller-ID name is on file. error: the caller-name lookup returned an error code, shown in caller_id.error_code. |
caller_id.name | string or null | The registered name as returned, usually upper case. Caller-ID names hold at most 15 characters, so long names arrive cut off, and many list the last name first (SMITH JOHN). |
name_match.state | match, partial_match, mismatch, ambiguous, unavailable | match: the names agree once case, word order, punctuation, common nicknames and initials, legal suffixes such as LLC, and caller-ID truncation are allowed for. partial_match: they agree only in part, for example the same last name with a different first name. ambiguous: the overlap is too weak to call, for example only the first name agrees or two business names share only generic words. mismatch: a usable caller-ID name exists and does not agree with the name you sent. unavailable: there was no usable caller-ID name to compare, or nothing usable was left of the supplied name. |
name_match.score | 0 to 100, or null | Similarity evidence, not a probability that the person owns the number. null when the state is unavailable. |
name_match.reasons | array of strings | Machine-readable explanations such as last_name_exact, caller_id_truncated_prefix or caller_id_placeholder. Store them with the result; the list of possible reasons is not fixed. |
signals.voip_or_virtual | true, false, null | true for VoIP lines. Descriptive only: many legitimate people and businesses use VoIP. null when the line type could not be determined. |
signals.toll_free | true, false, null | true for toll-free numbers. |
provenance[] | provider, sku, cache_hit, attempted_at | Where the facts came from. provider is sandbox for test keys and carrier_lookup for live keys. attempted_at is when the underlying lookup ran; a result served from cache keeps its original time. |
billing | disposition: billable, not_billable, pending_reconciliation | Whether this request counts toward your bill. unit_price_microdollars is the price that applied, in integer microdollars ($0.015 is 15000). cache_hit is true when the result came from your account's cache. pending_reconciliation means the accounting record is not confirmed yet; keep the request id. |
warnings | array of strings | Notes on how to read this result. The values you can see today are listed below. |
product_version, rule_version | 2026-10-15.2, core-phone.v2 | The API contract version and the matching-rules version that produced the result. Store them with the result. |
Warnings
| Warning | Meaning |
|---|---|
rate_center_is_not_subscriber_address | phone.rate_center is present. It is the number's rate-center location, not where the subscriber lives. |
voip_is_descriptive_not_fraud | The line is VoIP. That alone is not a fraud signal. |
caller_id_placeholder, caller_id_unavailable, caller_id_error | There is no usable caller-ID name, so name_match.state is unavailable. |
provider_validity_not_returned | The lookup did not return a validity flag, so phone.valid is null. |
served_from_tenant_cache | The result came from your account's cache. No new lookup was made, and the name comparison was recomputed. |
free_recheck_within_24h | A cached re-check within 24 hours of the original lookup. It is not billed. |
provider_reports_invalid_number | The number is not allocated or not valid. result_state is unavailable and the request is not billed. |
GET /requests/{id}#
/requests/{id}Fetch a previous request by idReturns the stored outcome of an earlier POST /verify made by your account in the same environment. Needs the requests:read scope. Use it to poll after a 202, and to inspect any response that carried a request_id before deciding whether to start another check.
| Status | Meaning |
|---|---|
| 200 | A completed request. The body is the same result POST /verify returned. |
| 200 | A stored-outcome resource for a request that failed, was rejected or is waiting for accounting reconciliation. The lookup itself succeeded; read status and error for the original outcome. |
| 202 | Still running. The body is { "request_id": "...", "status": "pending" }. |
| 404 | not_found: no request with that id exists for this key's account and environment. |
curl -sS https://www.switchlabs.dev/api/switchid/v1/requests/7c9e2b14-5a3f-4d8e-a1b6-3e0f9c2d4a85 \
-H "Authorization: Bearer sid_test_..."A request to the timeout sandbox number, +12025550106, reads back like this afterwards:
{
"request_id": "7c9e2b14-5a3f-4d8e-a1b6-3e0f9c2d4a85",
"status": "failed",
"error": {
"code": "provider_unavailable",
"message": "This Idempotency-Key previously produced an error; the stored outcome is returned."
},
"billing_disposition": "not_billable"
}Stored outcome status
| status | Meaning |
|---|---|
| failed | The check failed and was not billed. Final for its key. |
| rejected | The check was refused before any lookup, for example because a spend cap was reached. Not billed. Final for its key. |
| pending_reconciliation | The check may have completed, but its accounting record is not confirmed. Do not start a duplicate check; contact us with the request id. |
GET /usage#
/usageUsage summary for your key's account and environment| Query | Type | Required | Description |
|---|---|---|---|
from | date (YYYY-MM-DD) | No | Inclusive start date, UTC. Defaults to the first day of the current month. |
to | date (YYYY-MM-DD) | No | Inclusive end date, UTC. Defaults to today. |
- Needs the
usage:readscope. The summary covers the key's own account and environment, so a test key reports only test usage. - With neither parameter, the window is the current UTC month to date. To report on an earlier month, pass both
fromandto. A window longer than 366 days, or afromafterto, returns 400invalid_request. period.fromandperiod.toare the UTC instants actually covered.period.tois exclusive: the start of the day afterto, or the current time whentois omitted.billable_requestscounts requests that carried a charge or made a fresh lookup.cache_hitscounts every result served from your account's cache, charged or free. Failed requests andunavailableresults do not appear.- Amounts are integer microdollars. In the example, 42 requests at $0.015 come to 630000 microdollars, or $0.63.
curl -sS "https://www.switchlabs.dev/api/switchid/v1/usage?from=2026-10-01&to=2026-10-15" \
-H "Authorization: Bearer sid_test_..."{
"tenant_id": "6c0f2a9e-1b7d-4c3e-8f25-9d4a7e1b3c58",
"environment": "test",
"period": {
"from": "2026-10-01T00:00:00.000Z",
"to": "2026-10-16T00:00:00.000Z"
},
"by_sku": [
{
"sku": "core_phone",
"billable_requests": 42,
"cache_hits": 5,
"amount_microdollars": 630000
}
],
"total_amount_microdollars": 630000
}Sandbox numbers#
Test keys never reach a paid provider. These numbers, in the reserved fictional 555-01xx block, always produce the same scenario, so you can exercise every branch of your integration. They behave this way only with sid_test_ keys. "Billable" below describes the billing.disposition the result reports; test usage is never invoiced.
| Number | Scenario | Send | Result |
|---|---|---|---|
+12025550100 | Person, mobile | "John Smith", person | 200. Mobile line with caller ID SMITH JOHN: caller_id.state available and name_match.state match. Billable. |
+12025550101 | Placeholder caller ID | Any name | 200. Mobile line whose caller ID is WIRELESS CALLER, so caller_id.state is placeholder, caller_id.name is null and name_match.state is unavailable. Billable. |
+12025550102 | Landline, no caller ID | Any name | 200. Landline with no caller-ID name on file: caller_id.state unavailable, name_match.state unavailable. Billable. |
+12025550103 | VoIP person | "Jane Doe", person | 200. Ported VoIP line with caller ID DOE JANE: signals.voip_or_virtual true, the voip_is_descriptive_not_fraud warning, and name_match.state match. Billable. |
+12025550104 | Business landline, truncated caller ID | "Acme Roofing LLC", business | 200. Landline with caller ID ACME ROOFING LL, cut off at 15 characters. The comparison allows for the truncation and the legal suffix and returns match. Billable. |
+12025550105 | Caller-name error | Any name | 200. Carrier and line type are returned, but the caller-name lookup fails: caller_id.state error, caller_id.error_code CNAM_LOOKUP_FAILED, name_match.state unavailable. Billable, and never served from cache. |
+12025550106 | Provider timeout | Any name | 502 provider_unavailable with a request_id, after one automatic retry. Not billed. Replaying the same key returns the same stored failure. |
+12025550107 | Invalid number | Any name | 200 with result_state unavailable, an empty checks.completed, billing.disposition not_billable and the provider_reports_invalid_number warning. Not billed. |
+12025550108 | Toll-free | Any name | 200. phone.line_type toll_free and signals.toll_free true. The caller ID TOLL FREE CALL is a placeholder, so name_match.state is unavailable. Billable. |
+12025550109 | Provider HTTP 500 | Any name | 502 provider_unavailable with a request_id. No automatic retry. Not billed. |
Any other US number returns a deterministic synthetic result: the same number always gives the same line type, an invented carrier name, rate center SANDBOX, DC, and a caller-ID name of the form SAMPLE followed by six hexadecimal characters. Because that name is synthetic, comparing it with a real name normally returns mismatch. Validation still applies in the sandbox, so unparseable and non-US numbers return 422.
Errors#
Every error uses the same envelope: { "error": { "code", "message", "request_id?", "details?" } }, with the HTTP status listed below.
{
"error": {
"code": "idempotency_conflict",
"message": "This Idempotency-Key was already used with a different request.",
"request_id": "3f2b8c1e-7d4a-4e9b-9c61-5a0d2e8f4b17"
}
}- Branch on
error.code. Themessageis written for people and may change. request_idis present when the request was recorded. Keep it: it is how you and we find the request later.detailsis present for field validation errors, as a list of{ path, message }, and forforbidden_sku, naming the rejected add-ons.- Both 429 codes send a
Retry-Afterheader in whole seconds.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
unauthorized | 401 | The API key is missing, malformed, unknown, revoked or expired. | Check the Authorization: Bearer header and the key. Retrying unchanged will not help. |
forbidden_scope | 403 | The key lacks the scope this endpoint needs: verify:write, requests:read or usage:read. | Use a key with that scope, or contact us for one. |
forbidden_sku | 403 | The core_phone check is not enabled for this account and environment, or add_ons named something other than core_phone. | Remove unsupported add-ons. If the check is not enabled, contact us. |
tenant_suspended | 403 | The account is suspended. | Contact us. |
invalid_request | 400 | The body or query failed validation: malformed JSON, a missing or unknown field, or a value out of range. | Fix the request using details, then send it again. |
invalid_phone | 422 | The phone number could not be parsed as a valid number. | Correct the number. Do not retry it unchanged. |
unsupported_country | 422 | The number is valid but is not a US number. | Do not retry. Only US numbers are supported. |
missing_idempotency_key | 400 | The Idempotency-Key header is missing or is not 1 to 128 printable characters. | Add a valid key and send the request again. |
idempotency_conflict | 409 | This Idempotency-Key was already used with a different body. request_id names the original request. | Resend the original body to read its result, or use a new key for a new check. |
rate_limited | 429 | The per-key or per-account requests-per-minute limit was reached. Nothing was recorded for this key. | Wait the number of seconds in Retry-After, then retry with the same key. |
spend_cap_reached | 429 | The account's daily request cap or monthly spend cap was reached. The request is stored as rejected and not billed. | Honour Retry-After before trying again. The rejection is stored against this key, so a later attempt needs a new key. Contact us if the cap is too low. |
enforcement_unavailable | 503 | Limits, entitlement or request recording could not be confirmed, so nothing paid ran. | Without a request_id, retry with the same key after a short wait. With one, check GET /requests/{id} before starting another check. |
provider_unavailable | 502 | The lookup timed out or failed upstream. Not billed. | The failure is stored against this key, so a same-key retry returns it again. Confirm with GET /requests/{id}, then start a new check with a new key. |
provider_unconfigured | 503 | Lookups are not available for this environment yet. Not billed. | Contact us. The failure is stored against this key. |
ledger_unavailable | 500 | The check may have completed, but its accounting record could not be confirmed. Replaying the key returns this error until it is reconciled. | Keep the request_id and the key, do not start a duplicate check, and contact us. |
not_found | 404 | No request with this id exists for this key's account and environment. | Check the id, and use a key from the environment that created the request. |
internal_error | 500 | An unexpected error. | With a request_id, check GET /requests/{id} before starting another check. Without one, retry with the same key. |
Billing and rate limits#
Billing
- A completed
core_phonecheck is billable even when no usable caller-ID name is on file (caller_id.stateplaceholder,unavailableorerror), because the carrier and line type were still looked up. - Provider and system failures are not billed. Neither are rejected requests or numbers reported as invalid (
result_stateunavailable). The one exception isledger_unavailable: that check may have completed, and once it is reconciled it is billed at most once. - Replaying an
Idempotency-Keyis never billed twice. - Re-checking the same number with the same
subject_typewithin 24 hours of the original lookup is free. The result is served from your account's cache withbilling.dispositionnot_billableand thefree_recheck_within_24hwarning. A result whose caller-name lookup failed is not cached, so checking that number again runs, and bills, a new lookup. - Results are reused from your account's cache for 30 days by default; your agreement can set a different window. After the first 24 hours, a cached result is charged as your agreement sets for cache hits.
billing.cache_hitandbilling.unit_price_microdollarson each result show what applied. The name comparison is always recomputed against the name you send. - Each result reports the unit price that applied in
billing.unit_price_microdollars, in integer microdollars: $0.015 is 15000. - Test usage (
sid_test_keys) is never invoiced. Test results still report the billing disposition that would apply, andGET /usageshows them for the test environment. Live usage is invoiced monthly.
Rate limits
POST /verifyis limited per API key and per account in each environment: 120 requests per minute each by default. Going over either returns 429rate_limitedwithRetry-After.- An account can also have a daily request cap and a monthly spend cap. Reaching one returns 429
spend_cap_reachedwithRetry-After.
Downloads and links#
OpenAPI document
Download the OpenAPI 3.1 document (contract version 2026-10-15.2). It is the source of truth for every field on this page.
Reference scripts
Two short scripts show a correct integration against the sandbox.
- verify.sh
- A bash script using curl, with python3 to format the JSON. It sends one check to the sandbox with a fresh
Idempotency-Key, then replays the same key to show that a replay returns the stored outcome instead of starting a new paid check. Run it asSWITCHID_API_KEY=sid_test_... bash verify.sh, optionally followed by a phone number, name and subject type. - verify.mjs
- A Node.js script (Node 18 or later, built-in fetch). It generates a UUID
Idempotency-Key, retries network errors with the same key, readsGET /requests/{id}when a 202, 429 or 5xx response carries a request id, waits out shortRetry-Aftervalues, and treatsname_match.stateunavailableas no evidence rather than a mismatch.
Contact us for a copy of either script.