--- URL: https://docs.handset.dev/index.html # Handset documentation A complete business phone system for your platform — compliant two-way texting, voice with transcripts and AI summaries, real numbers, and realtime events — through one REST API. Everything here works in test mode before a real phone rings. ## Get started → Quickstart Key to first compliant text in ten minutes: tenant, brand, campaign, number, message. ⚡ Your first five minutes Key in hand to message on screen, in five steps — the onboarding path partners follow. ✓ Test mode A simulated carrier on every account: free numbers, instant compliance, magic numbers for every failure. ## Use a client library TypeScript npm i @handset/sdk Python pip install handset cURL api.handset.dev/v1 ## Build ✉ Messaging Two-way SMS and MMS with threading, delivery receipts, and opt-outs enforced at send time. ☏ Voice Hours-based routing, click-to-call, voicemail, live transcription, AI summaries, DTMF, media streams. # Numbers & tenants A real local number per customer, isolated per tenant — search, buy, port, release by API. § Compliance 10DLC brands and campaigns as API objects with approval webhooks. Instant in test mode. ↩ Webhooks Signed events for everything that happens on the line, retried until you acknowledge them. ⚡ Realtime events The same events, pushed over a WebSocket the moment they commit — browser-safe tokens included. ## Explore more ▤ Handset UI Open-source React components, shadcn-style: inbox, softphone, agent assist. npx shadcn add @handset/… ▶ Live demo A working dispatch app on the real API — your own tenant and number, no signup. ⌁ API reference Every endpoint, parameter, and schema, generated from the OpenAPI spec. ✦ AI & MCP Give any AI agent a phone system: npx -y @handset/mcp , an agent skill, and llms.txt. ! Errors Every error code the API returns: what happened and what to do about it. --- URL: https://docs.handset.dev/quickstart.html # Quickstart Handset gives your platform a complete business phone system — compliant two-way SMS, inbound voice, voicemail, and real numbers — through one REST API at https://api.handset.dev/v1 . Ten minutes to your first text. ## 1. Authenticate Every request carries your API key as a bearer token. Keys are mode-scoped: hs_test_… keys hit the simulated carrier (free numbers, instant compliance), hs_live_… keys hit the real network. Same code, different key. curl ``` # Sanity check: list your tenants curl https://api.handset.dev/v1/tenants \ -H "Authorization: Bearer $HANDSET_API_KEY" ``` ## 2. Create a tenant A tenant is one of your customers — the dental practice or plumbing company inside your product. Numbers, conversations, and business hours all belong to tenants, so one integration serves every customer. curl ``` curl https://api.handset.dev/v1/tenants \ -H "Authorization: Bearer $HANDSET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Bayview Dental", "timezone": "America/Los_Angeles"}' { "id": "tnt_01kzv90afex…", "name": "Bayview Dental", … } ``` ## 3. Buy a number curl ``` # Search available inventory… curl "https://api.handset.dev/v1/phone_numbers/available?area_code=415" \ -H "Authorization: Bearer $HANDSET_API_KEY" # …and purchase one for the tenant. SMS- and voice-ready on arrival. curl https://api.handset.dev/v1/phone_numbers \ -H "Authorization: Bearer $HANDSET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tenant_id": "tnt_…", "phone_number": "+14155550134"}' ``` ## 4. Send a message Threading, opt-out enforcement, and 10DLC checks happen inside this one call. Pass an Idempotency-Key and retries are safe. cURL TypeScript Python cURL ``` curl https://api.handset.dev/v1/messages \ -H "Authorization: Bearer $HANDSET_API_KEY" \ -H "Idempotency-Key: 3f1b2c…" \ -H "Content-Type: application/json" \ -d '{"from": "num_…", "to": "+14805550199", "body": "Your technician is 15 minutes out."}' { "id": "msg_01kzv…", "status": "queued", "conversation_id": "cnv_01kzv…" } ``` TypeScript ``` import { sendMessage } from "@handset/sdk"; const { data } = await sendMessage({ body: { from: "num_…", to: "+14805550199", body: "Your technician is 15 minutes out." }, headers: { "Idempotency-Key": job.id }, }); // data → { id: "msg_01kzv…", status: "queued", conversation_id: "cnv_01kzv…" } ``` Python ``` from handset.api.messaging import send_message from handset.models import MessageCreate msg = send_message.sync(client=client, body=MessageCreate( from_="num_…", to="+14805550199", body="Your technician is 15 minutes out.", )) # msg.id, msg.status, msg.conversation_id ``` ## Where to next Webhooks — receive message.received , voicemail.created , and the rest, signed and retried. Test mode — free numbers, instant compliance, and magic numbers for rehearsing failure. TypeScript SDK — one typed function per endpoint: npm install @handset/sdk . API reference — every endpoint, parameter, and schema. Handset is in early access — request access and we onboard you with keys the same day. --- URL: https://docs.handset.dev/five-minutes.html # Your first five minutes You just received your Handset keys. This page gets you from "key in hand" to "message on screen" in five steps — all in test mode, where the carrier is simulated and everything is free and instant. ## 1. Stash your test key Use the test key ( hs_test_… ) for everything today. It behaves exactly like live — same API, same webhooks — against a simulated carrier. shell ``` export HANDSET_API_KEY="hs_test_…" ``` ## 2. Install the SDK shell ``` npm install @handset/sdk # or: pip install handset ``` ## 3. Give yourself a phone system A tenant is one of your customers; a number is their business line. In test mode numbers are free and live instantly: provision.ts ``` import { client } from "@handset/sdk/client"; import { createTenant, searchAvailableNumbers, purchaseNumber } from "@handset/sdk"; client.setConfig({ headers: { Authorization: `Bearer ${process.env.HANDSET_API_KEY}` } }); const tenant = await createTenant({ body: { name: "My First Customer" } }); const found = await searchAvailableNumbers({ query: { area_code: "415" } }); const number = await purchaseNumber({ body: { tenant_id: tenant.data!.id, phone_number: found.data!.data[0].phone_number! }, }); console.log(number.data!.id); // num_… — a working line, sms + voice ``` ## 4. Send your first text send.ts ``` import { sendMessage } from "@handset/sdk"; const res = await sendMessage({ body: { from: number.data!.id, to: "+14155550123", body: "Hello from my platform." }, }); console.log(res.data!.status); // "queued" → simulated carrier delivers in ~1s ``` Fetch it again a second later and status is delivered . Try to: "+15005550001" to watch a delivery fail on purpose — rehearsing failure is the point of test mode. ## 5. See it in the console Open console.handset.dev and log in with the same test key. The conversation you just created is there — threads, delivery states, usage. Your customers' phone activity, live. That's the loop. Everything else is more of the same objects: webhooks push replies and delivery receipts to your backend, magic numbers script edge cases, and the API reference covers voice, porting, click-to-call, and 10DLC compliance. When you're ready for real traffic, swap in the live key — the code doesn't change. --- URL: https://docs.handset.dev/test-mode.html # Test mode Every account ships with parallel hs_live_… and hs_test_… keys. Test keys run a simulated carrier: numbers are free and instant, compliance approves immediately, and the whole event pipeline — webhooks included — behaves exactly like production. ## What the simulator does Numbers — search and purchase return instantly and cost nothing; the inventory is synthetic but shaped like the real thing. Messages — sends are accepted and delivery receipts arrive moments later as real webhook events. Voice — inbound calls, answers, hangups, and voicemails can be simulated so your call-handling UI is testable without dialing a phone. Compliance — brands vet and campaigns approve immediately, with the same status_changed events production emits over days. ## Magic numbers Rehearse the failures you hope never happen: Value Behavior +15005550001 Send accepted, then delivery fails — message.failed webhook with carrier_rejected +15005550002 Recipient replies STOP after the first delivery — rehearse opt-out handling +15005550003 Dialed party never answers (click-to-call and ring targets) +15005550004 Not portable — port-in checks fail with a reason +15005550005 Port-in goes to action_needed after submission +15005550007 Gathers time out — the party never presses anything +15005550008 Media streams fail to start — call.stream.failed +15005550009 The carrier rejects the send itself; after retries the message ends failed with send_failed +16054551234 A real blocked exchange: calls return destination_not_supported — the high-cost (access-stimulation) block applies in test mode too E911 ZIP 00000 Emergency-address registration is rejected Any other number Delivers instantly with a receipt; dialed parties answer within seconds ## A failure drill curl ``` # Same code as production — only the key differs. curl https://api.handset.dev/v1/messages \ -H "Authorization: Bearer $HANDSET_TEST_KEY" \ -d '{"from": "num_…", "to": "+15005550001", "body": "This one is doomed."}' # moments later, at your webhook endpoint: → message.failed "failure_reason": "carrier_rejected" ``` Mode isolation is strict: a test key can never see live traffic, and vice versa — separate ledgers end to end. --- URL: https://docs.handset.dev/webhooks.html # Webhooks Everything that happens on the line arrives as an event: consistent envelope, HMAC-signed, ordered per resource, and retried with backoff until your endpoint returns a 2xx. ## Register an endpoint curl ``` curl https://api.handset.dev/v1/webhook_endpoints \ -H "Authorization: Bearer $HANDSET_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://yourapp.com/handset/events"}' # The response contains the signing secret — shown exactly once. ``` ## Event catalog Event When message.received An inbound text arrived on a tenant number message.delivered An outbound message reached the handset message.failed Delivery failed, with a carrier-level reason call.started An inbound call began ringing call.completed A call ended, with duration and outcome call.missed A call went unanswered with no voicemail call.transcript A final utterance landed on a transcribing call (mid-call) call.summary The AI summary of a transcribed call is ready call.dtmf A keypress on an active call (digit + who pressed) call.gather A gather finished — collected digits and reason voicemail.created A voicemail landed — recording and transcript attached call.stream.started A media stream went active — audio is flowing to your socket call.stream.stopped A media stream ended, with reason and billed duration call.stream.failed A media stream could not start or died on a carrier error brand.status_changed 10DLC brand vetting progressed campaign.status_changed Campaign approval progressed ## The envelope event.json ``` { "id": "evt_01kzv9f2x8…", "type": "message.received", "created_at": "2026-08-12T18:04:11Z", "tenant_id": "tnt_01kzv90afex…", "data": { "object": "message", … } } ``` ## Verify signatures Each delivery carries a Handset-Signature header: t=,v1= . Recompute HMAC-SHA256 over {timestamp}.{body} with your endpoint secret, compare in constant time, and reject anything older than five minutes. verify.ts ``` // Handset-Signature: t=1755012239,v1=5f3a… const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const ok = timingSafeEqual(expected, v1); // ✓ ``` Return 2xx quickly and process async — slow endpoints get retried, and retries are delivered with the same event id for dedup. --- URL: https://docs.handset.dev/realtime.html # Realtime events Every event your webhooks receive can also arrive over a WebSocket, the moment it commits — the low-latency channel UIs want. Webhooks stay the durable record; the stream is the refresh signal. ## 1. Mint a token from your backend Your API key never reaches the browser. Your server mints a short-lived token (one hour) and hands it to the client: cURL TypeScript Python cURL ``` # → { "token": "hsrt_…", "url": "wss://media.handset.dev/v1/events", "expires_at": … } curl -X POST https://api.handset.dev/v1/realtime/tokens \ -H "Authorization: Bearer $HANDSET_API_KEY" ``` TypeScript ``` import { mintRealtimeToken } from "@handset/sdk"; const grant = await mintRealtimeToken(); // grant.data → { token, url, expires_at } ``` Python ``` from handset.api.events import mint_realtime_token grant = mint_realtime_token.sync(client=client) # grant.token, grant.url, grant.expires_at ``` Keys scoped to a tenant mint tenant-scoped tokens — the stream only ever carries that tenant's events. ## 2. Connect and listen Each frame is the same envelope your webhook endpoints receive — one event catalog everywhere (see Webhooks ): browser.js ``` const ws = new WebSocket(`${grant.url}?token=${grant.token}`); ws.onmessage = (msg) => { const event = JSON.parse(msg.data); // { id, type: "message.received", tenant_id, created_at, data } if (event.type === "call.transcript") render(event.data); }; ``` Tokens expire after an hour and the socket closes — reconnect with a fresh mint. Delivery is best-effort by design: treat events as a signal to refetch, keep a slow poll as the safety net, and let webhooks remain the durable channel. ## Zero-code option: Handset UI If you use Handset UI , one prop does all of the above — token minting through your proxy, connection management, and instant hook refreshes: app.tsx ``` ``` ## What arrives Everything in the event catalog : message.received , message.delivered , call.started , call.transcript (per utterance, mid-call), call.summary , call.stream.* , voicemail.created , and the rest. Account-scoped by your key, tenant-filtered when the token is tenant-scoped. --- URL: https://docs.handset.dev/ai.html # AI & MCP Handset is built to be operated by agents: an MCP server that gives any AI a phone system, a skill that teaches coding agents the integration patterns, and machine-readable docs. ## The MCP server One config line gives Claude Code, Claude Desktop, or Cursor the whole surface — send texts, place calls, read live transcripts and AI summaries, buy numbers, provision tenants: terminal ``` # Claude Code claude mcp add handset -e HANDSET_API_KEY=hs_test_… -- npx -y @handset/mcp ``` mcp.json ``` { "handset": { "command": "npx", "args": ["-y", "@handset/mcp"], "env": { "HANDSET_API_KEY": "hs_test_…" } } } ``` Fifteen curated tools: send_message , make_call , get_transcript , start_transcription , search_numbers , buy_number , create_tenant , and the rest of what an agent actually reaches for. ## Safe by default Use your test-mode key : the agent gets free instant numbers and simulated calls — it can provision, text, and dial with zero real-world consequence, which makes it the fastest way to feel the API. The server refuses live keys unless HANDSET_ALLOW_LIVE=1 is set, and outward-facing tools carry confirm-first warnings in their descriptions. ## The agent skill A companion skill teaches coding agents the patterns that make Handset integrations correct — test-mode-first, the tenant model, idempotent sends, webhooks vs realtime, magic numbers: terminal ``` mkdir -p .claude/skills/handset npx -y @handset/mcp --skill > .claude/skills/handset/SKILL.md ``` ## Machine-readable docs Point an agent at /llms.txt for the index, or /llms-full.txt for every guide page as plain text in one file. ## Building a voice agent? MCP tools cover control; for the audio itself, attach a media stream — fork or bidirectional raw call audio over WebSockets — and drive your own speech loop. The realtime event stream carries transcripts and lifecycle the moment they happen. --- URL: https://docs.handset.dev/sdk.html # TypeScript SDK @handset/sdk is generated from the OpenAPI spec — one typed function per endpoint, ESM and CommonJS, zero dependencies beyond a fetch-capable runtime (Node 18+, Bun, Deno, edge workers, browsers). ## Install & configure shell ``` npm install @handset/sdk ``` Configure the shared client once at startup — every SDK function uses it. The base URL defaults to production; only the key is required. setup.ts ``` import { client } from "@handset/sdk/client"; client.setConfig({ headers: { Authorization: `Bearer ${process.env.HANDSET_API_KEY}` }, }); ``` ## Server-side usage (Node, Next.js, workers) The SDK is isomorphic, but your API key belongs on the server — call Handset from your backend and keep the key out of browser bundles. In Next.js, that means route handlers or server actions. Configure the client once in a server-only module and import SDK functions anywhere server-side: lib/handset.ts ``` import { client } from "@handset/sdk/client"; let configured = false; export function configureHandset() { if (configured) return; client.setConfig({ headers: { Authorization: `Bearer ${process.env.HANDSET_API_KEY}` }, }); configured = true; } ``` app/api/messages/route.ts ``` import { sendMessage } from "@handset/sdk"; import { configureHandset } from "@/lib/handset"; export async function POST(req: Request) { configureHandset(); const { to, body } = await req.json(); const res = await sendMessage({ body: { from: process.env.HANDSET_NUMBER_ID!, to, body } }); if (res.error) return Response.json(res.error, { status: res.response.status }); return Response.json(res.data); } ``` The same pattern works in Express middleware, Bun, Deno, and edge workers — anywhere with fetch . Browser calls to the API are blocked by CORS by design; proxy through your backend as above. ## Every call returns an envelope SDK functions never throw on API errors. Each returns { data, error, response } : send.ts ``` import { sendMessage } from "@handset/sdk"; const res = await sendMessage({ body: { from: "num_01kzv…", to: "+14155550123", body: "On my way!" }, }); if (res.error) { console.error(res.error.error.code); // e.g. "recipient_opted_out" } else { console.log(res.data.id, res.data.status); // msg_…, "queued" } ``` Error codes worth branching on: recipient_opted_out (they sent STOP — do not retry), campaign_not_approved (10DLC in review), rate_limited , and tenant_not_found . Rate limits (20 req/s per key, bursts to 60) are handled for you: the SDK retries 429 s automatically, honoring Retry-After , up to 3 attempts. You'll only see rate_limited if the limit is still exceeded after retries. Tune or disable via client.setConfig({ fetch: withRetry(fetch, { maxAttempts: 1 }) }) . ## Idempotent sends idempotent.ts ``` await sendMessage({ body: { from: "num_…", to: "+1415…", body: "Appointment confirmed." }, headers: { "Idempotency-Key": crypto.randomUUID() }, }); ``` ## The core loop onboard.ts ``` import { createTenant, searchAvailableNumbers, purchaseNumber, sendMessage, listConversations, } from "@handset/sdk"; // 1. Your customer, as a tenant const tenant = await createTenant({ body: { name: "Bayview Dental", timezone: "America/Los_Angeles" }, }); // 2. A number in their area code, live on arrival const found = await searchAvailableNumbers({ query: { area_code: "415" } }); const number = await purchaseNumber({ body: { tenant_id: tenant.data!.id, phone_number: found.data!.data[0].phone_number! }, }); // 3. Send — threading, opt-outs, 10DLC all happen server-side await sendMessage({ body: { from: number.data!.id, to: "+14155550123", body: "Welcome aboard!" }, }); // 4. Read the thread back for your UI const threads = await listConversations({ query: { tenant_id: tenant.data!.id } }); ``` ## Pagination paginate.ts ``` let after: string | undefined; do { const page = await listMessages({ query: { limit: 100, after } }); for (const m of page.data?.data ?? []) handle(m); after = page.data?.has_more ? page.data.next_cursor ?? undefined : undefined; } while (after); ``` ## Types Every request and response shape is exported — the SDK is also your type library: types.ts ``` import type { Message, Conversation, Tenant, Voicemail } from "@handset/sdk"; function renderBubble(m: Message) { /* m.direction, m.status, m.body… */ } ``` Python is official too: pip install handset — same spec, same objects. Python SDK guide → --- URL: https://docs.handset.dev/python.html # Python SDK handset on PyPI — generated from the same OpenAPI spec as the API and the TypeScript SDK. Typed attrs models, sync and asyncio variants for every endpoint, Python 3.11+. ## Install & configure shell ``` pip install handset ``` Create one client and pass it to every call. The token is your API key — start with a test key ( hs_test_… ): simulated carrier, free numbers, instant compliance. setup.py ``` from handset import AuthenticatedClient client = AuthenticatedClient( base_url="https://api.handset.dev/v1", token=os.environ["HANDSET_API_KEY"], ) ``` ## Send a message Endpoints live under handset.api. — one module per endpoint with sync , sync_detailed , asyncio , and asyncio_detailed functions: send.py ``` from handset.api.messaging import send_message from handset.models import SendMessageBody message = send_message.sync( client=client, body=SendMessageBody( from_="num_01kzv…", # a tenant number ID (from_ — from is reserved) to="+14155550134", body="Your technician is 15 minutes out.", ), ) print(message.id, message.status) # msg_…, "queued" ``` ## Errors & status codes sync returns the parsed model on success, or an ErrorBody on API errors. Use sync_detailed when you need the status code: errors.py ``` from handset.models import ErrorBody res = send_message.sync_detailed(client=client, body=body) if isinstance(res.parsed, ErrorBody): code = res.parsed.error.code # e.g. "recipient_opted_out" print(res.status_code, code) else: message = res.parsed ``` Codes worth branching on: recipient_opted_out (they sent STOP — do not retry), campaign_not_approved (10DLC in review), rate_limited , tenant_not_found . Rate limits (20 req/s per key, bursts to 60) are handled for you: the client's transport retries 429 s automatically, honoring Retry-After , up to 3 attempts. Opt out with httpx_args={"transport": httpx.HTTPTransport()} . ## Idempotent sends idempotent.py ``` import uuid send_message.sync(client=client, body=body, idempotency_key=str(uuid.uuid4())) ``` ## The core loop onboard.py ``` from handset.api.tenants import create_tenant from handset.api.phone_numbers import search_available_numbers, purchase_number from handset.api.messaging import list_conversations from handset.models import TenantCreate, PurchaseNumberBody # 1. Your customer, as a tenant tenant = create_tenant.sync(client=client, body=TenantCreate( name="Bayview Dental", timezone="America/Los_Angeles", )) # 2. A number in their area code, live on arrival found = search_available_numbers.sync(client=client, area_code="415") number = purchase_number.sync(client=client, body=PurchaseNumberBody( tenant_id=tenant.id, phone_number=found.data[0].phone_number, )) # 3. Read threads back for your UI threads = list_conversations.sync(client=client, tenant_id=tenant.id) ``` ## Pagination paginate.py ``` from handset.api.messaging import list_messages from handset.types import UNSET after = UNSET while True: page = list_messages.sync(client=client, limit=100, after=after) for m in page.data: handle(m) if not page.has_more: break after = page.next_cursor ``` ## Async Every endpoint has an asyncio twin with the same signature: async.py ``` from handset.api.messaging import send_message message = await send_message.asyncio(client=client, body=body) ``` Building in TypeScript instead? Same objects, same spec: TypeScript SDK guide . --- URL: https://docs.handset.dev/errors.html # Errors Every error is the same envelope: a machine code to branch on, a human message , and a docs_url that links to the code's row on this page. error.json ``` { "error": { "code": "recipient_opted_out", "message": "This recipient texted STOP. You cannot message them again unless they text START.", "docs_url": "https://docs.handset.dev/errors.html#recipient_opted_out" } } ``` HTTP status carries the class of failure: 400 / 422 your request needs a change, 401 / 403 credentials, 404 no such resource (in your account — IDs are account-scoped), 409 conflict, 429 slow down, 5xx us — retry. table.errors { width: 100%; border-collapse: collapse; margin: 12px 0 28px; } table.errors td { padding: 8px 12px 8px 0; border-bottom: 1px solid rgba(120,120,140,.18); vertical-align: top; font-size: 14px; } table.errors td:first-child { white-space: nowrap; } table.errors tr:target td { background: rgba(99,91,255,.08); } ## Authentication & limits missing_api_key No Authorization header. Pass your key as a bearer token: Authorization: Bearer hs_live_… . malformed_authorization The header isn't Bearer . Check the scheme and whitespace. invalid_api_key Unknown, malformed, or revoked key. Check it in the console; test and live are different keys. account_suspended The account is suspended. Contact support to restore access. tenant_restricted_key This key is scoped to one tenant and the request touched another. Use an account-wide key or the right tenant's key. rate_limited Over 20 req/s (burst 60) on this key. The SDKs retry this automatically honoring Retry-After ; if you see it, you're sustainedly over — spread the work out. ## Request shape invalid_json The body isn't valid JSON. invalid_body The body doesn't match the endpoint's schema — the message names the field. unreadable_body The body couldn't be read (truncated or oversized request). missing_fields A required field is absent — the message lists which. not_found No such resource in your account. IDs are account-scoped: another account's ID 404s rather than 403s. idempotency_key_reuse This Idempotency-Key was already used with a different body. Reuse a key only to retry the identical request. invalid_start Unparseable start timestamp — use RFC 3339. invalid_end Unparseable end timestamp — use RFC 3339. invalid_range start must be before end . internal_error Something failed on our side. Retry; contact support if it persists. database_unavailable Brief outage on our side. Retry with backoff. ## Messaging empty_message Provide body , media_urls , or both. body_too_long The body exceeds the maximum length. Split the message. invalid_to The destination isn't a valid E.164 phone number ( +14155550123 ). recipient_opted_out They texted STOP. Do not retry — delivery is blocked until they text START. Design for it: check /v1/opt_outs or handle this code. from_number_not_found The from number isn't one of this tenant's numbers. number_not_sms_capable This number can't send SMS (voice-only). campaign_not_approved The number's 10DLC campaign is still in review. Test mode approves instantly; live approval takes days. missing_consent The opt-in form submission lacked required consent. message_not_found No such message in your account. conversation_not_found No such conversation in your account. ## Numbers & porting invalid_phone Not a valid E.164 phone number. number_unavailable This number was just taken. Search again and pick another. phone_number_not_found No such number in your account. invalid_phone_numbers The list is empty or contains non-E.164 entries. numbers_not_portable At least one number can't be ported — the message lists which. Run POST /v1/port_ins/check first. port_in_not_found No such port-in in your account. port_in_not_submittable Only draft port-ins can be submitted. port_in_not_cancellable This port-in has progressed past the point of cancellation. invalid_service_address The service address is incomplete — street, city, state, and ZIP are required. e911_address_invalid The address failed E911 validation. Verify it with the postal service format. ## Voice invalid_to_number The customer number to dial isn't valid E.164. invalid_connect_to The agent number to bridge isn't valid E.164. call_not_active DTMF and gather commands need an in-progress call. destination_not_supported The exchange is a known high-cost destination (access stimulation) and can't be dialed — from any leg or ring target. call_not_found No such call in your account. voicemail_not_found No such voicemail in your account. recording_not_found No such recording in your account. routing_config_not_found No such routing config in your account. routing_config_in_use Numbers still reference this routing config — detach them first. ## 10DLC compliance brand_not_found No such brand in your account. brand_not_vetted The brand hasn't completed vetting; campaigns need a vetted brand. campaign_not_found No such campaign in your account. legal_name_required The brand's registered legal name is required. invalid_ein The EIN must be nine digits ( 12-3456789 accepted). invalid_entity_type Entity type must be one of the documented values (e.g. private_profit ). invalid_billing_phone_number The brand contact phone isn't valid E.164. invalid_contact_email The brand contact email isn't a valid address. invalid_use_case Use case must be one of the documented campaign use cases. description_required Campaigns need a description of the traffic. invalid_sample_messages Provide 1–5 realistic sample messages. ## Tenants & webhooks tenant_not_found No such tenant in your account. name_required Tenants need a display name. invalid_timezone Not an IANA timezone ( America/Phoenix ). external_ref_taken Another tenant already carries this external_ref . They're unique per account. webhook_endpoint_not_found No such webhook endpoint in your account. url_required Webhook endpoints need a URL. invalid_url The webhook URL must be a valid https:// address. unknown_event_type The event type filter names an event we don't emit — see Webhooks for the list.