Docs › SDKs
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
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.
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:
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; }
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 }:
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 429s 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
await sendMessage({ body: { from: "num_…", to: "+1415…", body: "Appointment confirmed." }, headers: { "Idempotency-Key": crypto.randomUUID() }, });
The core loop
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
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:
import type { Message, Conversation, Tenant, Voicemail } from "@handset/sdk"; function renderBubble(m: Message) { /* m.direction, m.status, m.body… */ }