---
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.