# Saperly — the phone carrier for AI agents > Saperly is the phone carrier for AI agents. One API call gives any AI agent a real phone number with compliance built in. The core model is connection-first: you define a reusable CONNECTION (instructions, voice) once and attach it to any number of phone numbers — one connection can power a fleet of ten thousand numbers. Your AI agents get real inbound/outbound voice and SMS with mandatory AI disclosure, consent management, and an append-only audit trail. No carrier account required. ## Key facts - Product: a developer REST API + dashboard that provisions phone numbers and routes them to reusable connections. - Model: numbers → connections → compliance. One connection attaches to N numbers. - Channels: voice (inbound + outbound) and SMS, on the same number. - Compliance: TCPA-style AI disclosure, consent records, and an immutable audit trail are enforced before a call connects — included, not an add-on. - Messaging registration: 10DLC brand + campaign registration is handled as part of provisioning, so US A2P SMS sends over compliant registered routes. - Billing: prepaid balance, pay-as-you-go (reserve → settle); $5 free signup credit and the first number free for 30 days. No postpaid auto-charge by default. - Architecture: a thin control plane over a carrier network; conversation media stays in-network and never passes through Saperly compute. ## Get started - [Sign in / sign up](https://saperly.com/sign-in): create an account and an API key. - [Documentation](https://saperly.com/docs): human-readable guides and the full REST reference. ## The v2 flow (REST) 1. POST /v2/connections { name, instructions } → returns connectionId 2. POST /v2/numbers { phoneNumber } → returns numberId 3. POST /v2/numbers/connection { numberId, connectionId } → attach the connection 4. POST /v2/calls { numberId, to } → place a call 5. POST /v2/messages { numberId, to, body } → send an SMS ## Community & contact - [Discord](https://discord.gg/dXmtZuPwAg): ask questions and reach the team. - [X / Twitter](https://x.com/trysaperly): product updates. - [GitHub](https://github.com/Saperly): SDKs and open-source tooling. # Documentation — full text ## Quickstart Source: https://saperly.com/docs/quickstart **Goal:** get a working phone number attached to your agent and send your first message — in about five minutes. You'll need a Saperly account. Signup is portal-only. Create an account at the [dashboard](https://saperly.com/login), create your first workspace, and the onboarding wizard will walk you through the phone number, connection, setup output, and one-time API key reveal. You can also create or rotate keys later from **Keys** in the dashboard. ## Install an SDK The fastest path is one of the official SDKs. They are typed, dependency-light, and wrap the Saperly REST API. ```bash npm install @saperly/sdk ``` ```bash pip install saperly ``` No install needed — every endpoint is plain HTTPS with a bearer token. ```bash export SAPERLY_API_KEY=sk_live_... ``` ## Provision a line A **line** is a phone number plus the handler that answers it. Creating one in `hosted` mode gives you a number backed by an in-network voice assistant — no infrastructure of your own. ```typescript const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) const line = await saperly.lines.create({ name: 'my agent', mode: 'hosted', systemPrompt: 'You are a helpful assistant answering calls for Acme Inc.', }) console.log(line.phoneNumber) // → +1... ``` ```python import os from saperly import SaperlyClient saperly = SaperlyClient(api_key=os.environ["SAPERLY_API_KEY"]) line = saperly.lines.create( name="my agent", mode="hosted", system_prompt="You are a helpful assistant answering calls for Acme Inc.", ) print(line.phone_number) # → +1... ``` ```bash curl -X POST https://api.saperly.com/api/v1/lines \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my agent", "mode": "hosted", "system_prompt": "You are a helpful assistant answering calls for Acme Inc." }' ``` That single call provisions a real number, certifies it, and attaches the assistant. Call the number and the hosted assistant answers — speech-to-text, the LLM, and text-to-speech all run in-network, so no call audio ever reaches your servers. ## Send an SMS ```typescript const sms = await saperly.lines.sendSms(line.id, { toNumber: '+15555550123', message: 'Hi from my agent 👋', }) ``` ```python sms = saperly.lines.send_sms( line.id, to_number="+15555550123", message="Hi from my agent 👋", ) ``` ```bash curl -X POST "https://api.saperly.com/api/v1/lines/$LINE_ID/sms" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to_number": "+15555550123", "message": "Hi from my agent 👋" }' ``` ## What just happened **You provisioned a number.** Saperly reserved it from the carrier, recorded it under your workspace, and started metering its monthly rent against your prepaid balance. **You attached a brain.** In `hosted` mode the number is answered by an in-network assistant. (Want your own LLM to be the brain? See [manual mode](/docs/guides/manual-mode).) **You sent a message** through a compliant path — consent state and disclosures are tracked automatically. ## Next steps - [Core concepts](/docs/concepts) — the model behind lines, connections, and the prepaid ledger. - [Voice channels](/docs/guides/voice-channels) — make your **own** agent (Claude Code / openclaw) phone-reachable. - [Authentication](/docs/guides/authentication) — scoped `sk_` keys, spend caps, and minting per-agent child keys with `keys:admin` for fleets. - [API reference](/docs/api-reference) — every endpoint with a try-it playground. The SDKs above target Saperly's stable **v1** API (`/api/v1/*`, snake_case). The newer **v2** API (camelCase: `/numbers`, `/connections`, `/messages`, `/calls`) is documented in the [API reference](/docs/api-reference) and is what you'll use for new, scoped-key integrations. Both run over the same backend. --- ## Core concepts Source: https://saperly.com/docs/concepts A short mental model. Saperly has five moving parts: **numbers**, **connections**, your **balance**, **workspaces**, and the **compliance** layer that wraps them. Everything else is built on these. ## Numbers A **number** is a real phone number provisioned under your workspace. Provision one with a country, type, and optional area code: ```json { "country": "US", "numberType": "local", "areaCode": "415" } ``` Each number carries its price (`monthlyPriceCents`) and the `connectionId` of the handler bound to it. Monthly rent is swept automatically against your prepaid balance, idempotent per billing period. Releasing a number soft-deletes it (`releasedAt` is set) and stops the rent. See the [Numbers guide](/docs/guides/numbers). ## Connections A **connection** is the handler bound to a number — the thing that answers. A connection has a `mode`: - **Hosted** — an in-network voice assistant (speech-to-text → LLM → text-to-speech). Bring your own OpenAI-compatible LLM, pick a TTS voice, and optionally declare MCP servers the assistant can call. - **Manual** — bring your own LLM as the brain. Saperly forwards each **text** turn to your agent (over an HTTP endpoint or an outbound websocket) and executes the **directives** it returns (`speak`, `wait_for_user`, `hangup`, `transfer`, `send_dtmf`). One number → one connection. The same connection can answer many numbers. See [Connections](/docs/guides/connections) and [Manual mode](/docs/guides/manual-mode). ## Your balance (prepaid) Saperly is **prepaid**. You top up a balance; usage is metered against it through a **reserve → settle → release** cycle: 1. **Reserve** — before an action (a call, a number), funds are held. You can never over-spend a balance or a scoped key's spend cap — the reservation simply fails first. 2. **Settle** — after the action, the actual cost is applied. 3. **Release** — if the action never happens, the held funds return. Top up manually or enable auto-recharge. See [Billing](/docs/guides/billing). ## Workspaces & tenancy A **workspace** is your tenant. Members are humans (with roles); **agents** authenticate with scoped `sk_` API keys. The workspace an action belongs to is always read from the credential — never from client input — so a key can only ever touch its own workspace's data. See [Authentication](/docs/guides/authentication). ## Compliance Consent, disclosures, and 10DLC registration are first-class and enforced independently of any LLM — so a model you swap out can never route around them: - **Consent** — record `implied_inbound` / `explicit_outbound` consent per (number, peer), check it before outbound contact, and revoke on `STOP`. - **Disclosures** — the AI/TCPA notice lives on a [connection](/docs/guides/connections) (`complianceEnabled` + `disclosure`); when on, it is spoken as the line's first, uninterruptible utterance. - **10DLC** — register a campaign (brand + use case) so your US SMS is carrier-certified and delivers reliably. See [Compliance](/docs/guides/compliance). ## Audio stays in-network Saperly runs the phone network for you, and **call audio never reaches your servers**. Saperly handles signaling, identity, compliance, the record, and billing; your code only ever sees **text and tool calls**. Even in [manual mode](/docs/guides/manual-mode), where your LLM is the brain, speech-to-text and text-to-speech happen in-network — you receive transcribed turns and return directives, and no audio socket is ever opened to your server. That's what keeps Saperly simple to run at scale: no media pipeline to operate, no audio egress, and nothing of yours that has to be online for a call to happen. ## Two ways to call the API Saperly exposes the same capabilities through two REST surfaces: | Surface | Style | Best for | | --- | --- | --- | | **v2** (current) | camelCase, scoped `sk_` keys | new integrations, fleets, MCP | | **v1** (SDK-compatible) | snake_case, wrapped envelopes | the published `@saperly/sdk` / `saperly` SDKs | Both run over the same backend. The [SDKs](/docs/sdks/node) target v1; the [API reference](/docs/api-reference) documents every endpoint of both. --- ## Authentication Source: https://saperly.com/docs/guides/authentication Every Saperly request authenticates with `Authorization: Bearer sk_…`. There is **one tier** of credential: a scoped **API key** that agents use to place calls, send SMS, and manage numbers, connections, and consent. Each key carries a **grant** that bounds exactly what it can do. The workspace an action belongs to is **always read from the key — never from client input** — so a key can only ever touch its own workspace's data, even when you issue keys at fleet scale. You never pass an org or workspace id. Saperly resolves the workspace from the key on every request, which is what makes fleet-scale key issuance safe: a key is structurally incapable of reaching another tenant's data. ## Sending the key Send the key as a bearer token on every request: ```bash curl https://api.saperly.com/numbers \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` Keys are scoped to one environment, encoded in the prefix: - **`sk_live_…`** — production. Real numbers, real charges. - **`sk_test_…`** — test. Same API surface, isolated from production data and billing. A `live` key and a `test` key are never interchangeable. ## Scopes Each key carries a set of **scopes** from the Scope vocabulary. A request is allowed only if the key holds the scope the action requires. | Scope | Grants | | --- | --- | | `calls:create` | place outbound calls | | `calls:control` | control a live call (speak, transfer, DTMF, hang up) | | `messages:create` | send SMS | | `numbers:read` | list and read numbers | | `numbers:provision` | provision new numbers | | `numbers:release` | release numbers | | `connections:read` | read connections | | `connections:write` | create and update connections | | `consent:write` | record and revoke consent | | `webhooks:write` | manage webhook endpoints | | `billing:read` | read balance and usage | | `keys:admin` | mint, list, and revoke child keys | | `read` | baseline read access | ## The grant A key's grant is more than scopes. It also bounds **which numbers** the key can touch and **how much** it can spend: ```ts { scopes: Scope[] // at least one, from the table above resources?: { numbers?: string[] // allow-list for number-scoped actions } spendLimit?: { amountCents: number resetPeriod?: 'monthly' | null } } ``` - **`scopes`** — the minimum is one scope; the key can do nothing outside them. - **`resources.numbers`** — an optional allow-list. When present, number-scoped actions are restricted to exactly those numbers. - **`spendLimit`** — a hard cap enforced **at reserve time by the [Ledger](/docs/guides/billing)**, so it holds even for in-flight spend. `resetPeriod: 'monthly'` resets the counter at the UTC month boundary; `null` (or omitted) means a single lifetime cap. Because the spend limit is checked when funds are **reserved**, a key can never overrun its cap mid-call — the reservation simply fails with [`InsufficientFunds`](/docs/guides/errors-and-idempotency). ## Child keys: delegation with a ceiling A key that holds the `keys:admin` scope can mint **child keys** — one key per agent, each with a narrower grant. This is how you hand out keys at fleet scale without minting a separate admin credential per agent. The child is **bounded by the parent's ceiling**: its scopes, number allow-list, and spend cap may not exceed the minting key's own grant. This is enforced server-side, so a `keys:admin` key can only ever narrow what it already holds — never escalate. Hand your `keys:admin` key to your provisioning system, hand the scoped child keys it mints to individual agents. | Action | Endpoint | | --- | --- | | Mint a child key | `POST /api-tokens` | | List keys | `GET /api-tokens` | | Revoke a key | `POST /api-tokens/:id/revoke` | `POST /api-tokens` returns a fresh key bound to the grant you pass. The plaintext key is shown **once** — store it immediately. ```bash curl -X POST https://api.saperly.com/api-tokens \ -H "Authorization: Bearer $SAPERLY_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "agent-42", "grant": { "scopes": ["calls:create", "messages:create", "numbers:read", "read"], "resources": { "numbers": ["num_01H..."] }, "spendLimit": { "amountCents": 5000, "resetPeriod": "monthly" } } }' ``` ```typescript const res = await fetch('https://api.saperly.com/api-tokens', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SAPERLY_ADMIN_KEY!}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'agent-42', grant: { scopes: ['calls:create', 'messages:create', 'numbers:read', 'read'], resources: { numbers: ['num_01H...'] }, spendLimit: { amountCents: 5000, resetPeriod: 'monthly' }, }, }), }) const { key } = await res.json() // sk_live_… — shown once ``` The child inherits the minting key's workspace — you never pass an org or workspace id, which is precisely what closes the cross-tenant hole. If the grant you request exceeds the parent's ceiling, the mint is rejected. ## Where keys come from A key is created either by an org admin in the dashboard (**Settings → Keys**) or programmatically by a `keys:admin` key via `POST /api-tokens`. Use the dashboard for a handful of keys; mint child keys when you're issuing one per agent at fleet scale. ## Role presets (human members) Human members get scopes from their org role, not a grant: | Role | Scopes | | --- | --- | | `owner` / `admin` | all scopes | | `member` | `calls:create`, `calls:control`, `messages:create`, `numbers:read`, `connections:read`, `consent:write`, `billing:read`, `read` | A `member` deliberately cannot provision or release numbers, or administer keys. ## Next steps - [Numbers](/docs/guides/numbers) — provision and manage numbers with a scoped key. - [Billing](/docs/guides/billing) — the prepaid ledger and how spend limits are enforced. - [Core concepts](/docs/concepts) — workspaces, tenancy, and the org-from-token rule. - [API reference](/docs/api-reference) — every `api-tokens` endpoint with a try-it playground. Older `sk_svc_` service keys still exist as a **legacy management plane** — list, create, revoke, and rotate only. They do **not** mint scoped keys and are not part of the current model. New integrations use scoped `sk_` keys and `POST /api-tokens` for delegation. --- ## Billing Source: https://saperly.com/docs/guides/billing Saperly is **prepaid**: you top up a balance and every action meters against it. The **Ledger** does a **reserve → settle → release** cycle, guarded so you can never overspend your balance *or* a scoped key's spend cap — even for in-flight spend. All balances and amounts are integer **cents**. Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sk_live_...`; the workspace is resolved from the token. ## Reserve → settle → release Every metered action runs through three ledger moves: 1. **Reserve** — before the action (placing a call, provisioning a number), funds are reserved. The reservation fails atomically if it would push the balance below zero or breach a key's spend cap, so an action that can't be paid for never starts. 2. **Settle** — when the action completes, its actual cost is applied and any over-reserved amount is freed. 3. **Release** — if the action never happens, the whole reservation is cancelled and the funds return to the balance. A voice call is the canonical example: funds are **reserved** on `POST /calls`, then **settled** against the reported `durationSec` on end, with the remainder released. See [Voice](/docs/guides/voice). ## Transaction types Every ledger move is recorded as a transaction. Amounts are integer cents. | Type | Meaning | | --- | --- | | `reserve` | Funds held for a pending action. | | `settle` | Actual cost applied when the action completes. | | `release` | A reservation cancelled; funds returned. | | `topup` | Funds added to the balance from your saved payment method. | | `charge` | A direct debit against the balance. | | `adjust` | A manual correction (credit or debit). | ## Top-up and auto-recharge - **Top-up** — add funds to your balance from a card on file. - **Auto-recharge** — opt-in, off-session top-up that fires when your balance drops below a threshold. It is **single-in-flight** (one recharge runs at a time, so a burst of usage can't trigger duplicate charges) and **pauses on SCA** — if the card needs Strong Customer Authentication, auto-recharge halts until you complete it in the dashboard rather than silently failing. Funding your balance and configuring auto-recharge (threshold + recharge amount + payment method) live in the dashboard today. The API and SDKs are for **reading** balance and transactions and for the metered actions that draw against the balance. ## Spend caps on scoped keys A child API key can carry a **`spendLimit`**, enforced **at reserve time by the Ledger**, so the cap holds even mid-action: ```ts spendLimit?: { amountCents: number resetPeriod?: 'monthly' | null } ``` - `resetPeriod: 'monthly'` resets the counter at the **UTC month boundary**. - `resetPeriod: null` (or omitted) is a single lifetime cap. Because the cap is checked when funds are reserved, a key can never overrun it part-way through a call — the reservation simply fails. See [Authentication](/docs/guides/authentication) for how a key with `keys:admin` attaches a `spendLimit` when it mints a child key. ## Number rent Each number's **monthly rent** is swept automatically against your prepaid balance. The sweep is **idempotent per (number, billing period)**, so a number is never double-billed for a month. **Releasing a number stops the rent** — see [Numbers](/docs/guides/numbers). ## Pricing Quote a number's price before you provision with `GET /pricing/quote`: ```bash curl "https://api.saperly.com/pricing/quote?country=US&numberType=local" \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ```typescript const res = await fetch( 'https://api.saperly.com/pricing/quote?country=US&numberType=local', { headers: { Authorization: `Bearer ${process.env.SAPERLY_API_KEY!}` } }, ) const quote = await res.json() ``` The quote returns your price: | Field | Type | Notes | | --- | --- | --- | | `customerMonthlyCents` | `number` | Your monthly price. | | `customerUpfrontCents` | `number` | Your one-time provisioning price. | See [Numbers](/docs/guides/numbers) for provisioning. ## Reading balance and transactions The published v1 SDK exposes your balance and ledger history: ```typescript // Current balance (cents) const balance = await saperly.billing.balance() // Paginated transactions const page = await saperly.billing.transactions({ limit: 50, cursor }) ``` ## Out-of-funds errors When a reserve can't be covered, the action fails with a typed error — handle it by topping up (or by raising the key's cap): | Condition | v2 error (status) | v1 SDK code (status) | | --- | --- | --- | | Balance too low | `InsufficientFunds` (402) | `insufficient_credits` / `payment_method_required` (402) | | Scoped key's spend cap hit | `SpendLimitExceeded` (402) | `agent_cap_exceeded` (402) | The `agent_cap_exceeded` error carries `spent_cents`, `cap_cents`, and `cycle_reset_at` so you can tell the caller exactly when the cap frees up. ```typescript try { await saperly.calls.place({ fromNumberId: 'num_...', to: '+15555550123' }) } catch (err) { if (err instanceof InsufficientFundsError) { // Out of funds (402). Prompt a top-up, then retry. console.error('Balance too low — top up to continue.') } else { throw err } } ``` See [Errors & idempotency](/docs/guides/errors-and-idempotency) for the full error model. ## Related - [Authentication](/docs/guides/authentication) — `spendLimit` on scoped keys, enforced at reserve time. - [Numbers](/docs/guides/numbers) — provisioning, pricing, and monthly rent. - [Voice](/docs/guides/voice) — reserve → settle on a live call. - [Errors & idempotency](/docs/guides/errors-and-idempotency) — handling `InsufficientFunds` and `SpendLimitExceeded`. - [Core concepts](/docs/concepts) — the prepaid ledger in the Saperly model. --- ## Compliance Source: https://saperly.com/docs/guides/compliance Saperly bakes TCPA compliance in. **Consent**, **disclosures**, and **10DLC** registration are first-class objects enforced *independently of any LLM* — so a misbehaving model (or a model you swap out) can never route around them. Outbound voice or SMS to a peer without recorded consent is rejected before it goes out. Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sk_live_...`; the workspace is resolved from the token. ## Consent Consent is recorded per **(number, peer)** pair: a `ConsentRecord` says *this Saperly number has consent to contact that peer number*. Saperly checks it automatically on every outbound [message](/docs/guides/messaging) and [call](/docs/guides/voice). ### Consent types | `ConsentType` | Meaning | | --- | --- | | `implied_inbound` | The peer contacted you first (an inbound call or SMS), which implies consent to reply. | | `explicit_outbound` | The peer explicitly opted in to be contacted — required before *you* initiate outbound contact. | ### Endpoints | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `GET` | `/consent` | `ConsentRecord[]` | — | | `POST` | `/consent` | `ConsentRecord` (201) | `consent:write` | | `POST` | `/consent/revoke` | `ConsentRecord` | `consent:write` | | `GET` | `/consent/check?numberId=&peerNumber=` | `{ hasConsent, type? }` | — | ### The `ConsentRecord` shape | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | Record id. | | `numberId` | `string` | The Saperly number. | | `peerNumber` | `string` | The peer, in E.164 (e.g. `+15555550123`). | | `consentType` | `ConsentType` | `implied_inbound` or `explicit_outbound`. | | `source` | `string` | Free-form provenance label, e.g. `"sms_optin"` or `"call_recording_disclosure"`. | | `grantedAt` | `string` | ISO 8601 timestamp. | | `revokedAt` | `string \| null` | ISO 8601 timestamp once revoked, else `null`. | ### Worked flow: check, record, then contact Always check consent before initiating outbound contact, record `explicit_outbound` consent when the peer opts in, then send. The `source` label is your audit trail — make it describe *how* consent was obtained. ```bash # 1. Check before contacting curl "https://api.saperly.com/consent/check?numberId=num_...&peerNumber=%2B15555550123" \ -H "Authorization: Bearer $SAPERLY_API_KEY" # -> { "hasConsent": false } # 2. The peer opts in — record explicit outbound consent curl -X POST https://api.saperly.com/consent \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "numberId": "num_...", "peerNumber": "+15555550123", "consentType": "explicit_outbound", "source": "sms_optin" }' # 3. Now the send/call is allowed (see Messaging / Voice) ``` ```typescript const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. Check const { hasConsent } = await saperly.consent.check({ numberId: 'num_...', peerNumber: '+15555550123', }) // 2. Record on opt-in if (!hasConsent) { await saperly.consent.record({ numberId: 'num_...', peerNumber: '+15555550123', consentType: 'explicit_outbound', source: 'sms_optin', }) } // 3. Now send — see Messaging await saperly.messages.send({ fromNumberId: 'num_...', to: '+15555550123', body: 'Thanks for opting in!', }) ``` Initiating an outbound [call](/docs/guides/voice) or [SMS](/docs/guides/messaging) to a peer with no recorded consent is a TCPA violation — Saperly blocks it for you and the request fails with `consent_required` (403). Record `explicit_outbound` consent first, or rely on `implied_inbound` consent created when the peer contacts you. ### Revoking consent `POST /consent/revoke` with `{ numberId, peerNumber }` sets `revokedAt` and stops further outbound contact. Two things revoke consent without an explicit call: - **`STOP`** — an inbound SMS with the `STOP` keyword automatically revokes the sender's consent. Subsequent outbound sends to that number are blocked. - **`HELP`** — an inbound `HELP` keyword is logged (it does not change consent). ```bash curl -X POST https://api.saperly.com/consent/revoke \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "numberId": "num_...", "peerNumber": "+15555550123" }' ``` ## Disclosures A **disclosure** is the AI notice spoken at the start of a call so the caller is told they are talking to an AI. It lives on the [connection](/docs/guides/connections), not as a separate resource: each connection has `complianceEnabled` (a boolean, on by default) and a `disclosure` string. When compliance is on, the `disclosure` is spoken as the line's **first, uninterruptible utterance** — the TCPA notice — before the agent takes a turn, across every mode (hosted, manual, and OpenAI-realtime). ```typescript // The disclosure is a field on the connection — set it when you create or update one await saperly.connections.create({ name: 'support line', instructions: 'You are a friendly support agent for Acme Inc.', complianceEnabled: true, disclosure: 'You are speaking with an AI assistant for Acme.', }) ``` Leave `disclosure` empty with `complianceEnabled` on and Saperly fills a standard, org-named default so the line is never silently non-disclosing. Set `complianceEnabled` to `false` and there is no forced opener. When a recorded disclosure is the basis for contact, record it as a consent `source` (e.g. `"call_recording_disclosure"`) so the provenance is captured in your audit trail. ## 10DLC **10DLC** is the US carrier registration that certifies an application-to-person (A2P) SMS campaign. Registering a brand and campaign is what lets your US SMS deliver reliably instead of being filtered. Saperly tracks the registration and advances its status as carrier webhooks arrive. ### Campaign status | `TenDlcStatus` | Meaning | | --- | --- | | `pending` | Submitted to the carrier, awaiting review. | | `approved` | Certified — A2P SMS is cleared. | | `rejected` | The carrier declined the campaign. | | `suspended` | A previously approved campaign was suspended. | ### The `TenDlcCampaign` shape | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | Campaign id. | | `useCase` | `string` | The declared A2P use case (e.g. `2fa`, `customer_care`). | | `description` | `string` | Human-readable campaign description. | | `status` | `TenDlcStatus` | `pending` on creation, advanced by webhook. | | `createdAt` | `string` | ISO 8601 timestamp. | ### Registering a campaign Register with `{ useCase, description }`. The campaign starts `pending`; the final status arrives **by [webhook](/docs/guides/webhooks)** — listen for `10dlc` status events rather than polling. ```jsonc { "useCase": "customer_care", "description": "Appointment reminders and support replies for Acme Clinic." } ``` 10DLC management currently lives mostly in the dashboard, and campaign **status updates arrive by webhook** as the carrier reviews the brand and campaign. Wire up a [webhook](/docs/guides/webhooks) for `10dlc` events so your system learns when a campaign moves to `approved` (or `rejected` / `suspended`) without polling. ## Related - [Messaging](/docs/guides/messaging) — SMS sends; consent is enforced on every outbound message. - [Voice](/docs/guides/voice) — outbound calls; consent is enforced before the call starts. - [Webhooks](/docs/guides/webhooks) — receive `STOP`/`HELP`, delivery receipts, and 10DLC status events. - [Core concepts](/docs/concepts) — where compliance sits in the Saperly model. - [API reference](/docs/api-reference) — every `consent` endpoint with a try-it playground. --- ## Connections Source: https://saperly.com/docs/guides/connections A **connection** is the handler bound to a phone number — the thing that answers a call. You create a connection, configure how it should behave, and assign it to a [number](/docs/guides/numbers). It comes in two **modes**, and in both of them the call audio stays in-network — it never reaches your servers. See [Core concepts](/docs/concepts). ## Modes | Mode | Who is the brain | What you bring | | --- | --- | --- | | `hosted` | An in-network voice assistant (cascade STT → LLM → TTS) | Your OpenAI-compatible LLM, a voice slug, and optional MCP servers | | `manual` | Your own LLM | Saperly forwards text turns and executes directives — see [Manual mode](/docs/guides/manual-mode) | In **hosted** mode you point the in-network assistant at your own OpenAI-compatible LLM, choose a voice by slug, and optionally declare MCP servers the assistant can call. In **manual** mode you are the brain: Saperly forwards each text turn to your LLM and executes the directives it returns. Both modes keep speech-to-text, text-to-speech, and voice activity detection in-network. Saperly only ever sees **text turns and tool calls** — there is no audio socket to your server. ## Endpoints Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sk_live_...`; the workspace is resolved from the token. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `GET` | `/connections` | `Connection[]` | `connections:read` | | `GET` | `/connections/:id` | `Connection` | `connections:read` | | `POST` | `/connections` | `Connection` (201) | `connections:write` | | `PATCH` | `/connections/:id` | `Connection` | `connections:write` | | `DELETE` | `/connections/:id` | `{ status: "deleted" }` | `connections:write` | A `GET` or `PATCH`/`DELETE` against an unknown id returns `ConnectionNotFound` (404). ## The connection shape ```jsonc { "id": "conn_...", "name": "support line", "mode": "hosted", // 'hosted' | 'manual' "backend": "network", // hosted brain: 'network' (default) | 'openai_realtime' "smsAutoReply": false, // hosted: auto-answer inbound SMS with the model "instructions": "You are ...", // the system prompt (put any opening line here) "complianceEnabled": true, // when on, `disclosure` is the forced TCPA opener "disclosure": "You are speaking ...", // spoken first, uninterruptibly, when compliance is on "llm": { "kind": "managed", // 'managed' | 'byo' "model": "openai/gpt-4o", "byoBaseUrl": "https://...", // when kind === 'byo' "secretRef": "secret_..." // reference to your stored LLM key }, "tts": { "voiceId": "aria" }, // a Saperly voice slug (e.g. 'aria', 'atlas') "stt": { "language": "en" }, "mcpServers": [ { "url": "https://mcp.example.com", "auth": { "type": "bearer", "token": "..." }, // or { "type": "none" } "allowedTools": ["lookup_order"] } ], "createdAt": "2026-06-18T..." } ``` `llm`, `tts`, `stt`, and `mcpServers` are each nullable — omit them and the connection uses managed defaults. ### Key fields | Field | Meaning | | --- | --- | | `backend` | The hosted brain backend. `network` (the default) is an in-network voice assistant; `openai_realtime` is an OpenAI Realtime voice. Set on create or update. | | `smsAutoReply` | On a **hosted** connection, when `true` the line auto-answers inbound SMS with its model. Defaults to off. | | `instructions` | The system prompt that defines the assistant's behavior. Any opening line the agent should say goes here — there is no separate greeting field. | | `complianceEnabled` | When `true` (the default), the `disclosure` is spoken as the line's first, uninterruptible utterance — the TCPA notice. When `false`, there is no forced opener. | | `disclosure` | The TCPA notice spoken first when `complianceEnabled` is on. Leave it empty with compliance on and Saperly fills a standard org-named default. | | `llm.kind` | `managed` uses a Saperly-managed model; `byo` points at `byoBaseUrl` with a `secretRef` to your stored key. | | `mcpServers` | MCP servers the **hosted** assistant may call as tools during a conversation. `allowedTools` scopes which tools are exposed. | ## Create payload ```jsonc { "name": "support line", // required, 1–120 chars "mode": "hosted", // optional, defaults to 'hosted' "backend": "network", // optional; 'network' (default) | 'openai_realtime' "smsAutoReply": false, // optional; hosted only, defaults to false "instructions": "...", // optional "complianceEnabled": true, // optional, defaults to true "disclosure": "...", // optional; the TCPA opener when compliance is on "llm": { ... }, // optional "tts": { ... }, // optional "stt": { ... }, // optional "mcpServers": [ ... ] // optional } ``` For **manual** mode, a `manualSecret` (an `mc_` prefix followed by hex) is minted **once** when the connection is created. Your agent uses it to connect — see [Manual mode](/docs/guides/manual-mode). The default manual LLM model is `openai/gpt-4o`. ## Create a hosted connection This creates an in-network assistant with instructions, a TCPA disclosure, and a voice selected by slug. Voices are Saperly's in-network voices, selected by slug. **Voices:** a connection's voice is chosen by a Saperly voice **slug** (e.g. `aria`, `atlas`) via `tts.voiceId`. ```typescript const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) const connection = await saperly.connections.create({ name: 'support line', mode: 'hosted', instructions: 'You are a friendly support agent for Acme Inc. Be concise. Open with: Thanks for calling Acme — how can I help?', complianceEnabled: true, disclosure: 'You are speaking with an AI assistant for Acme.', tts: { voiceId: 'aria' }, }) console.log(connection.id) // → conn_... ``` ```bash curl -X POST https://api.saperly.com/connections \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "support line", "mode": "hosted", "instructions": "You are a friendly support agent for Acme Inc. Be concise. Open with: Thanks for calling Acme — how can I help?", "complianceEnabled": true, "disclosure": "You are speaking with an AI assistant for Acme.", "tts": { "voiceId": "aria" } }' ``` ## Assign it to a number A connection does nothing until it is bound to a [number](/docs/guides/numbers). Assign it with `POST /numbers/:id/connection`: ```typescript await saperly.numbers.setConnection(numberId, { connectionId: connection.id }) ``` ```bash curl -X POST https://api.saperly.com/numbers/$NUMBER_ID/connection \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "connectionId": "conn_..." }' ``` Now calls to that number are answered by the connection. To make it the brain of a **live** call from your own agent, [switch the connection to `manual` mode](/docs/guides/manual-mode#switch-a-line-between-hosted-and-manual) — Saperly reconciles all the carrier config for you — then read [Manual mode](/docs/guides/manual-mode) and [Voice channels](/docs/guides/voice-channels). ## Calling your own tools Declaring `mcpServers` on a **hosted** connection lets the in-network assistant call your tools mid-conversation — for example, to look up an order or check availability. Each server entry takes a `url`, optional `auth` (`bearer` with a `token`, or `none`), and an optional `allowedTools` allow-list. The servers are registered to the assistant when the connection is synced. ## Related - [Numbers](/docs/guides/numbers) — provision and manage the phone numbers connections bind to. - [Manual mode](/docs/guides/manual-mode) — run your own LLM as the brain of a live call. - [Voice](/docs/guides/voice) — place and control outbound calls. - [API reference](/docs/api-reference) — the full generated contract. --- ## Errors & idempotency Source: https://saperly.com/docs/guides/errors-and-idempotency Saperly returns **typed errors** with stable codes and HTTP statuses, and makes every mutating call **safe to retry** with an `Idempotency-Key`. Same key plus same body returns the same result; same key plus a *different* body is rejected. Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sk_live_...`; the workspace is resolved from the token. ## Error envelopes The two API surfaces shape errors differently, but the meaning is identical: - **v2 API** — a *tagged error* with an HTTP status (e.g. the tag `RateLimited` at `429`). - **v1 SDK surface** — a nested `{ error: { code, message, details? } }` envelope, and the SDK **throws a typed error class** you can `catch`. ```jsonc // v1 SDK error envelope { "error": { "code": "agent_cap_exceeded", "message": "Monthly spend cap reached for this key.", "details": { "spent_cents": 5000, "cap_cents": 5000, "cycle_reset_at": "2026-07-01T00:00:00Z" } } } ``` ## Common error codes | v1 code | SDK class | Status | Notes | | --- | --- | --- | --- | | `validation_error` | `ValidationError` | 422 | Malformed or invalid request body. | | `unauthorized` / `invalid_api_key` | `AuthenticationError` | 401 | Missing or bad key. | | `forbidden` | `ForbiddenError` | 403 | Authenticated but not allowed. | | `not_found` | `NotFoundError` | 404 | No such resource. | | `consent_required` | `ConsentRequiredError` | 403 | Outbound contact without recorded consent. | | `insufficient_credits` / `payment_method_required` | — | 402 | Balance too low. | | `agent_scope_error` | `AgentScopeError` | 403 | Key used a number outside its allow-list (field `line_id`). | | `agent_cap_exceeded` | `AgentCapExceededError` | 402 | Spend cap hit (fields `spent_cents`, `cap_cents`, `cycle_reset_at`). | | `agent_permission_denied` | `AgentPermissionDeniedError` | 403 | Action outside the key's permission (fields `tier`, `verb`). | | `rate_limited` | `RateLimitedError` | 429 | Too many requests. | | `idempotency_key_reused` | — | 409 | Same key reused with a different body. | | `idempotency_in_progress` | — | 409 | A request with this key is still in flight. | | `missing_idempotency_key` | — | 400 | An endpoint that requires a key got none. | ### v2 names of note A few v2 tagged errors are worth knowing by name: | v2 tag | Status | Payload | | --- | --- | --- | | `RateLimited` | 429 | `{ bucket }` — which limit you hit. | | `UpstreamError` | 502 | `{ status, code, message }` — a carrier/upstream failure. | | `IdempotencyConflict` | 409 | A key collision (an in-flight or reused key). | | `IdempotencyKeyMismatch` | 422 | Same key, different body. | Out-of-funds and spend-cap errors are covered in depth in [Billing](/docs/guides/billing); consent errors in [Compliance](/docs/guides/compliance). ## Idempotency Every **mutating** endpoint accepts the standard IETF **`Idempotency-Key`** header — a UUID v4. Saperly stores the first response under that key, so a retry with the **same key and same body** returns the *same* result instead of repeating the effect. A retry with the **same key but a different body** is rejected with `IdempotencyKeyMismatch` (422) / `idempotency_key_reused` (409). | Situation | Result | | --- | --- | | Same key, same body | Returns the original result — the action runs once. | | Same key, different body | `IdempotencyKeyMismatch` (422) / `idempotency_key_reused` (409). | | Same key, request still in flight | `idempotency_in_progress` (409) — retry after it settles. | For key creation and rotation, the SDKs auto-generate an `Idempotency-Key` (UUID v4) if you don't pass one — so a retried mint never produces a duplicate key. Pass your **own** `idempotencyKey` when you need to retry safely across process boundaries (e.g. a job that may be re-run after a crash), so both attempts share the same key. ### Sending an idempotency key ```bash curl -X POST https://api.saperly.com/calls \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f1d6c5e-2b7a-4f0e-9c2d-8a1b6e4f0c11" \ -d '{ "fromNumberId": "num_...", "to": "+15555550123" }' ``` ```typescript const key = randomUUID() // reuse this exact value on every retry const res = await fetch('https://api.saperly.com/calls', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SAPERLY_API_KEY!}`, 'Content-Type': 'application/json', 'Idempotency-Key': key, }, body: JSON.stringify({ fromNumberId: 'num_...', to: '+15555550123' }), }) ``` ## Handling typed errors Catch the specific classes you care about and react — pause on a spend cap, back off on a rate limit, rethrow the rest: ```typescript import { Saperly, AgentCapExceededError, RateLimitedError, } from '@saperly/sdk' const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) async function place() { try { return await saperly.calls.place({ fromNumberId: 'num_...', to: '+15555550123', }) } catch (err) { if (err instanceof AgentCapExceededError) { // 402 — this key is tapped out until its cycle resets. console.error( `Cap hit: ${err.details.spent_cents}/${err.details.cap_cents} cents,` + ` resets ${err.details.cycle_reset_at}`, ) return null } if (err instanceof RateLimitedError) { // 429 — back off and retry with the SAME Idempotency-Key. await new Promise((r) => setTimeout(r, 1000)) return place() } throw err // anything else is unexpected — let it surface } } ``` When you retry a mutating request after a `429` (or a network failure), reuse the **same `Idempotency-Key`** so the action still runs exactly once. A new key on retry defeats the protection and can duplicate the effect. ## Related - [Billing](/docs/guides/billing) — `InsufficientFunds` / `SpendLimitExceeded` and the spend-cap fields. - [Compliance](/docs/guides/compliance) — `consent_required` and how it's enforced. - [Authentication](/docs/guides/authentication) — scopes, grants, and the errors a key can hit. - [Webhooks](/docs/guides/webhooks) — using the delivery id to make your handler idempotent. - [API reference](/docs/api-reference) — every endpoint and its error responses. --- ## Manual mode Source: https://saperly.com/docs/guides/manual-mode **Manual mode** makes *your* LLM the brain of a phone call. The caller speaks; the network transcribes the turn; Saperly forwards you the **text**; you reply with a **directive** (`speak`, `wait_for_user`, `hangup`, `transfer`, `send_dtmf`); the network speaks it back. Your code never touches audio. This is the alternative to a [hosted connection](/docs/guides/connections), which keeps the LLM in-network too — reach for manual mode when you want to own the brain. Either way, speech-to-text and text-to-speech run in-network: you see only text turns and you emit only directives. ## How a turn flows ```text ☎ caller ☎ caller │ speech speech ▲ ▼ │ speech-to-text ──text──▶ Saperly ──────▶ text-to-speech (in-network) │ text turn ▲ (in-network) ▼ │ directive ┌───────────────┐ │ │ YOUR brain │────────────┘ │ (your LLM) │ speak / wait / hangup / … └───────────────┘ audio never reaches your server · speech-to-text + text-to-speech stay in-network ``` Saperly hands your brain a sequence of **events** (`inbound_call`, then a `turn` per caller utterance, then `call_ended`) and applies the **directive** you return for each. Every frame carries a `requestId` you must echo so the right directive lands on the right call. ## Switch a line between hosted and manual Manual mode is a **mode of a [connection](/docs/guides/connections)** — the same handler you bind to a number. Switching a line between **hosted** (an in-network voice assistant is the brain) and **manual** (your agent is the brain) is a first-class operation: you flip `mode` and Saperly reconciles everything for you. Changing a connection's mode automatically: - mints the connection's `manualSecret` (once, on first switch to manual); - reconfigures the in-network voice assistant for the new mode; - re-routes every number bound to the connection so inbound calls reach the new brain. Switching back to hosted re-points the same numbers at the hosted assistant — the `manualSecret` is retained, so flipping to manual again later reuses it. There are three supported ways to switch, all driving the same operation: Open **Connections → your connection**, set **Mode** to `hosted` or `manual`, and save. For a manual connection, the page also reveals the `manualSecret` to copy into your agent's connector. `PATCH` the connection with the new mode. The response carries the updated connection, including the `manualSecret` once it's a manual line. ```bash curl -X PATCH https://api.saperly.com/connections/$CONNECTION_ID \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "manual" }' ``` Switch back with `{ "mode": "hosted" }`. See [Connections](/docs/guides/connections) for the full connection shape and endpoints. The same operation is exposed as a tool over Saperly's [MCP server](/docs/sdks/mcp) — point any MCP-capable agent at the endpoint with a scoped `sk_` key (it needs `connections:write`) and have it switch the line's mode as a tool call. Switching a mode is the **only** way to wire (or rewire) a line. You never create the assistant, register the secret, or repoint numbers by hand — the mode switch does all of it, idempotently. Re-running it never duplicates anything. ## Two ways to be the brain There are two transports. Both speak the same event/directive vocabulary — pick by whether your brain has a public URL. Saperly **POSTs each turn** to a URL you host. Set `manualWebhookUrl` on the connection; every event (`inbound_call`, a caller turn, `call_ended`) arrives as a signed POST, and you reply with a directive in the response body. ```text POST ``` The request is signed with the connection's `manualSecret` so you can verify it. Use this transport when your brain runs somewhere with a **public URL** — a serverless function, your own backend. There is no socket to hold open. Your agent — which has **no public URL** — connects *out* and becomes the brain. This is the **agent-as-channel** model: the session you already have open becomes phone-reachable. ```text wss://api.saperly.com/v2/manual/{connectionId}/ws Authorization: Bearer ``` One socket multiplexes **every live call** on that connection. Use this when the brain runs somewhere with no inbound URL — a Claude Code session, an openclaw agent, a laptop. The ready-made connectors that implement this socket are in [Voice channels](/docs/guides/voice-channels). `manualSecret` is `mc_` followed by hex, minted **once per connection**. Find it in the dashboard under **Connections → your manual connection** (or copy it when you create a manual connection). See [Connections](/docs/guides/connections). ## The WebSocket protocol The websocket is the full-fidelity transport, so it is documented in detail here. After the socket opens, the client sends one **`hello`** handshake; thereafter the server pushes **request frames** (events) and the client returns **directive frames** (replies). ```text agent → server : hello (once, on connect) server → agent : inbound_call | turn | call_ended (each with requestId) agent → server : directive (echoes requestId; carries one directive) server → agent : error (advisory — a frame the server rejected) ``` ### Auth Present the connection's `manualSecret` as a bearer on the upgrade request: - `Authorization: Bearer `, or - `Sec-WebSocket-Protocol: bearer.` — the browser-compatible escape hatch, since a native `WebSocket` cannot set an `Authorization` header (the server echoes the subprotocol on accept). ### The handshake The first frame the client sends: ```json { "type": "hello", "connectionId": "conn_123", "protocolVersion": 1, "client": "my-agent" } ``` `connectionId` must match the path; `protocolVersion` is `1`; `client` is an optional free-form label for the event trail. ### Events the server sends Each request frame carries a `requestId` (echo it on your reply) and a `conversationId` (the unique id for the call this turn belongs to). | Event | Fields | | --- | --- | | `inbound_call` | `requestId`, `conversationId`, `callControlId`, `from`, `to` | | `turn` | `requestId`, `conversationId`, `userText` | | `call_ended` | `requestId`, `conversationId`, `reason?` | `userText` is the transcript of the caller's turn. `inbound_call` and `call_ended` expect a directive reply too — your opening line and a terminal acknowledgement. ### Directives the brain returns Wrap exactly one directive in a `directive` frame, tagged with the `requestId` it answers: | Directive | Fields | | --- | --- | | `speak` | `text`, `endCall?` (boolean) | | `wait_for_user` | `timeoutMs?` | | `hangup` | `reason?` | | `transfer` | `to` (E.164 or SIP URI) | | `send_dtmf` | `digits` | `speak` with `endCall: true` is the **say-a-final-line-then-hang-up** primitive: the line plays, then the call ends — one round-trip, no separate `hangup`. ### A round-trip on the wire An inbound call arrives: ```json { "type": "inbound_call", "requestId": "req_a1b2", "conversationId": "v3:call_ctrl_9f...", "callControlId": "v3:call_ctrl_9f...", "from": "+15555550123", "to": "+15555550199" } ``` Your brain greets the caller: ```json { "type": "directive", "requestId": "req_a1b2", "directive": { "type": "speak", "text": "Hi, this is Acme. How can I help?" } } ``` Later, after the caller's last turn, you close the call out in one frame: ```json { "type": "directive", "requestId": "req_e5f6", "directive": { "type": "speak", "text": "All set — goodbye!", "endCall": true } } ``` ### Timing Reply within about **18 seconds**. If your brain is silent, Saperly falls back to a short hold line at roughly **20 seconds** so the caller is never left in dead air. Keep turns snappy. Manual mode only ever moves **text in** and **directives out** — there is no audio socket to your server. Speech-to-text and text-to-speech stay in-network, so no call audio ever reaches your machine. See [Core concepts](/docs/concepts). ## Bring your own agent: openclaw You don't have to implement the protocol from scratch. **openclaw** is a worked example of a BYO agent on a Saperly manual line: the [voice channels](/docs/guides/voice-channels) connector loads an openclaw extension that holds the manual-mode websocket and makes your running openclaw agent the brain of the line — **in its own context, with its tools and memory**. Each caller turn is injected as input; the agent's reply (or its `saperly_voice_reply` tool) becomes a directive; the network speaks it back. To point an openclaw agent at a manual line: **Switch the line to manual** (above) and copy its `manualSecret`. **Load the connector** in your openclaw Gateway, configured with the connection id and secret (or an `sk_` key for auto-discovery). It connects **out** over the websocket — your agent needs no public URL. **Call the number.** The turn reaches your agent in its own session; its reply is spoken back. For the full run — config, env vars, the `saperly_voice_reply` tool, and networking — see [Voice channels](/docs/guides/voice-channels), which walks through the openclaw and Claude Code connectors step by step. openclaw also ships a separate `voice-call` plugin that streams **raw call audio** to a realtime provider and holds a media socket for the call — exactly the model Saperly avoids. The Saperly connector binds the **in-network manual-mode websocket** instead: signaling, speech-to-text, and text-to-speech stay in-network, and only **text turns** reach your agent. ## Next steps **Create a manual connection** and copy its `manualSecret` — see [Connections](/docs/guides/connections). **Point a number at it** so calls route to your brain — see [Numbers](/docs/guides/numbers) and [Voice](/docs/guides/voice). **Hold the socket.** Use the ready-made [Voice channels](/docs/guides/voice-channels) connectors (Claude Code / openclaw), or implement the protocol above directly. - [Voice channels](/docs/guides/voice-channels) — call a phone number and talk to your **own** Claude Code or openclaw agent over this websocket. - [Connections](/docs/guides/connections) — hosted vs. manual handlers. - [Core concepts](/docs/concepts) — the Saperly model and how a call flows. - [API reference](/docs/api-reference) — every endpoint, with a try-it playground. --- ## Messaging Source: https://saperly.com/docs/guides/messaging Send and list SMS from your Saperly numbers. Consent and disclosures are enforced automatically on every send — see [Compliance](/docs/guides/compliance). Inbound messages are delivered to your [webhook](/docs/guides/webhooks). ## Endpoints Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sk_live_...`; the workspace is resolved from the token. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `POST` | `/messages` | `Message` (201) | `messages:create` | | `GET` | `/messages` | `Message[]` | — | `GET /messages` accepts an optional `numberId` query parameter to filter by sending number. ## Send an SMS `POST /messages` takes the following payload: | Field | Type | Notes | | --- | --- | --- | | `fromNumberId` | `string` | The Saperly number to send from. | | `to` | `string` | Recipient in E.164 format (e.g. `+15555550123`). | | `body` | `string` | 1–1600 characters. | ```typescript const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) const message = await saperly.messages.send({ fromNumberId: 'num_...', to: '+15555550123', body: 'Your code is 123456.', }) console.log(message.id) ``` ```bash curl -X POST https://api.saperly.com/messages \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fromNumberId": "num_...", "to": "+15555550123", "body": "Your code is 123456." }' ``` If the carrier rejects the send, the call fails with `MessageSendFailed` (502). Sending to a peer requires recorded consent first. Saperly enforces this automatically and will block uncompliant sends — record `explicit_outbound` consent before messaging a number you initiated contact with. See [Compliance](/docs/guides/compliance). ## List messages ```typescript const messages = await saperly.messages.list({ numberId: 'num_...' }) ``` ```bash curl "https://api.saperly.com/messages?numberId=num_..." \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ## Inbound, STOP, and HELP Inbound SMS is delivered to your configured [webhook](/docs/guides/webhooks). The `STOP` and `HELP` keywords are handled automatically: - **`STOP`** revokes the sender's consent — subsequent outbound sends to that number are blocked. - **`HELP`** replies with the standard help message. ## Body length and segmentation The `body` limit is **1600 characters**. Longer messages are segmented by the carrier into multiple SMS parts and reassembled on the recipient's device; you are billed per segment. ## v1 SDK equivalents If you're on the published `@saperly/sdk` / `saperly` v1 surface, the equivalents are: ```typescript saperly.messages.send({ lineId, to, text }) saperly.lines.sendSms(lineId, { toNumber, message }) ``` See the [Node SDK](/docs/sdks/node) for the full v1 client. ## Related - [Compliance](/docs/guides/compliance) — consent, disclosures, and 10DLC. - [Webhooks](/docs/guides/webhooks) — receive inbound SMS and delivery events. - [Numbers](/docs/guides/numbers) — the numbers you send from. - [API reference](/docs/api-reference) — the full generated contract. --- ## Numbers Source: https://saperly.com/docs/guides/numbers A **number** is a real phone number provisioned under your workspace. Each number is bound to exactly one [connection](/docs/guides/connections) — its handler, the thing that answers — and can carry its own webhook. You provision numbers, list and read them, assign a connection, set a per-number webhook, and release them when you're done. The published `@saperly/sdk` / `saperly` SDKs model a number fused with its handler as a single **line**. A line is a number plus the connection that answers it. The v2 endpoints below keep the two concepts separate. See the [Node SDK](/docs/sdks/node). ## Endpoints All v2 endpoints are camelCase, authenticate with a scoped `sk_` bearer key, and read the workspace from the key. Base URL: `https://api.saperly.com`. | Method & path | Action | Scope | Returns | | --- | --- | --- | --- | | `GET /numbers` | list | `numbers:read` | `PhoneNumber[]` | | `GET /numbers/:id` | get | `numbers:read` | `PhoneNumber` | | `POST /numbers` | provision | `numbers:provision` | `PhoneNumber` (201) | | `POST /numbers/:id/release` | release | `numbers:release` | `{ status: "released" }` | | `POST /numbers/:id/connection` | assign connection | `connections:write` | `PhoneNumber` | | `POST /numbers/:id/webhook` | set webhook | `webhooks:write` | `PhoneNumber` | | `POST /numbers/:id/sms-sender` | set SMS sender id | `numbers:write` | `PhoneNumber` | | `POST /numbers/:id/caller-id` | set caller ID name | `numbers:write` | `PhoneNumber` | ### Provision payload ```json { "country": "US", "numberType": "local", "areaCode": "415" } ``` | Field | Default | Notes | | --- | --- | --- | | `country` | `"US"` | ISO country to provision in | | `numberType` | `"local"` | e.g. `local` | | `areaCode` | — | optional preferred area code | ## The PhoneNumber shape ```ts { id: string phoneNumber: string connectionId: string | null webhookUrl: string | null country: string | null numberType: string | null monthlyPriceCents: number | null // what you pay per month currency: string | null nextChargeAt: string | null // ISO — next monthly sweep releasedAt: string | null // ISO — soft-delete marker createdAt: string // ISO } ``` ## Sender identity Two per-number settings control how a number presents itself on outbound traffic. Both are optional, both clear by sending `null`, and both fall back to the bare phone number when unset. ### SMS sender id For international SMS, you can send from an **alphanumeric sender id** instead of the phone number — a short brand name like `Acme`. It must be 1–11 alphanumeric characters. Send `null` to clear it, and SMS goes back to sending from the phone number. ``` POST /numbers/:id/sms-sender { "smsSenderId": "Acme" } ``` Alphanumeric sender ids are display-only (one-way) in some countries: recipients see the brand name but cannot reply to it. Where two-way SMS matters, send from the phone number. ### Caller ID name (CNAM) Set the outbound caller-ID name shown on the called party's handset. It must be 1–15 letters, digits, or spaces, and is registered as the line's CNAM listing. Send `null` to clear it, and the bare number shows. ``` POST /numbers/:id/caller-id { "callerIdName": "Acme Inc" } ``` ```bash # Set an SMS sender id for international SMS curl -X POST "$BASE/numbers/$NUMBER_ID/sms-sender" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "smsSenderId": "Acme" }' # Set the outbound caller-ID name (CNAM) curl -X POST "$BASE/numbers/$NUMBER_ID/caller-id" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "callerIdName": "Acme Inc" }' # Clear either one (the bare number shows / SMS sends from the number) curl -X POST "$BASE/numbers/$NUMBER_ID/caller-id" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "callerIdName": null }' ``` ## Pricing Get a quote before provisioning so you know the price up front: ``` GET /pricing/quote?country=US&numberType=local ``` Returns a **`NumberQuote`** with your price: ```ts { customerMonthlyCents: number // your monthly price customerUpfrontCents: number // your one-time provisioning price } ``` See [Billing](/docs/guides/billing) for how the prepaid balance is funded and metered. ## Provisioning, billing, and release **Provisioning reserves funds** for the number's first charge and starts metering its monthly rent against your prepaid balance. The rent sweep is **idempotent per billing period**, so a number is never double-charged for the same month. **`nextChargeAt`** records when the next monthly sweep is due. Each sweep settles the recurring cost against your balance. **Releasing** a number sets `releasedAt` (a soft-delete marker) and **stops the rent**. The record stays for your history; no further charges accrue. ## Worked example Quote a price, provision the number, bind a connection so it can answer, set a webhook for delivery, and later release it. ```typescript const base = 'https://api.saperly.com' const auth = { Authorization: `Bearer ${process.env.SAPERLY_API_KEY!}` } const json = { ...auth, 'Content-Type': 'application/json' } // 1. Quote the price const quote = await fetch( `${base}/pricing/quote?country=US&numberType=local`, { headers: auth }, ).then((r) => r.json()) // → { customerMonthlyCents, customerUpfrontCents, ... } // 2. Provision a number (reserves funds + starts metering rent) const number = await fetch(`${base}/numbers`, { method: 'POST', headers: json, body: JSON.stringify({ country: 'US', numberType: 'local', areaCode: '415' }), }).then((r) => r.json()) // 3. Bind a connection (its handler) await fetch(`${base}/numbers/${number.id}/connection`, { method: 'POST', headers: json, body: JSON.stringify({ connectionId: 'conn_…' }), }) // 4. Set a per-number webhook await fetch(`${base}/numbers/${number.id}/webhook`, { method: 'POST', headers: json, body: JSON.stringify({ url: 'https://example.com/hooks/saperly' }), }) // 5. Later — release it (sets releasedAt, stops the rent) await fetch(`${base}/numbers/${number.id}/release`, { method: 'POST', headers: auth, }) ``` ```bash export BASE=https://api.saperly.com # 1. Quote the price curl "$BASE/pricing/quote?country=US&numberType=local" \ -H "Authorization: Bearer $SAPERLY_API_KEY" # 2. Provision a number (reserves funds + starts metering rent) curl -X POST "$BASE/numbers" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "country": "US", "numberType": "local", "areaCode": "415" }' # 3. Bind a connection (its handler) curl -X POST "$BASE/numbers/$NUMBER_ID/connection" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "connectionId": "conn_…" }' # 4. Set a per-number webhook curl -X POST "$BASE/numbers/$NUMBER_ID/webhook" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/saperly" }' # 5. Later — release it (sets releasedAt, stops the rent) curl -X POST "$BASE/numbers/$NUMBER_ID/release" \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ## Errors | Error | Status | Detail | | --- | --- | --- | | `NumberQuotaExceeded` | 409 | `{ limit, current }` | | `NoNumbersAvailable` | 404 | `{ country, numberType }` | | `InsufficientFunds` | 402 | balance (or a key's spend cap) too low to reserve | | `NumberNotFound` | 404 | no such number in this workspace | See [Errors & idempotency](/docs/guides/errors-and-idempotency) for the shared error envelope and safe retries. ## Next steps - [Connections](/docs/guides/connections) — the handler you bind to a number. - [Billing](/docs/guides/billing) — the prepaid ledger, reservations, and rent. - [Authentication](/docs/guides/authentication) — the scopes and spend caps that gate these endpoints. - [API reference](/docs/api-reference) — every `numbers` and `pricing` endpoint with a try-it playground. --- ## Voice Source: https://saperly.com/docs/guides/voice Place and control outbound calls, and list and fetch call records. Call audio stays in-network and never reaches your servers. The handler that actually talks during a call is the [connection](/docs/guides/connections) bound to the number (or the `connectionId` you pass). ## Endpoints Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sk_live_...`; the workspace is resolved from the token. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `POST` | `/calls` | `Call` (201) | `calls:create` | | `POST` | `/calls/:id/end` | `Call` | `calls:create` | | `POST` | `/calls/:id/transfer` | `Call` | `calls:control` | | `GET` | `/calls/:id` | `Call` | — | | `GET` | `/calls` | `Call[]` | — | A `GET` or `/end` against an unknown id returns `CallNotFound` (404). Calling a peer requires recorded consent first. Record `explicit_outbound` consent before placing a call to a number you initiated contact with — Saperly enforces this automatically. See [Compliance](/docs/guides/compliance). ## Place a call `POST /calls` takes the following payload: | Field | Type | Notes | | --- | --- | --- | | `fromNumberId` | `string` | The Saperly number to call from. | | `to` | `string` | Destination in E.164 format (e.g. `+15555550123`). | | `connectionId` | `string` | Optional. Overrides the number's bound connection for this call. | | `instructions` | `string` | Optional. A per-call system prompt that overrides the line's saved instructions for this call only. | ```typescript const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) const call = await saperly.calls.place({ fromNumberId: 'num_...', to: '+15555550123', instructions: 'You are calling to confirm a delivery window. Keep it under a minute.', }) console.log(call.id) ``` ```bash curl -X POST https://api.saperly.com/calls \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fromNumberId": "num_...", "to": "+15555550123", "instructions": "You are calling to confirm a delivery window. Keep it under a minute." }' ``` If the carrier can't start the call, it fails with `CallStartFailed` (502). ### Per-call instructions The optional `instructions` string is a per-call system prompt. It overrides the line's saved instructions for **this call only** — the connection's stored prompt is left untouched. Use it to give one call a narrow goal (confirm an appointment, read back an order) without editing the connection. ## Transfer a live call `POST /calls/:id/transfer` does a **blind transfer** of the live leg to a new destination. This is signaling only — it hands the call off at the carrier and works across every backend. It requires the `calls:control` scope. The payload takes a single `to`, either an E.164 number or a `sip:` URI: ```jsonc { "to": "+15551230000" } // or { "to": "sip:agent@example.com" } ``` ```bash curl -X POST https://api.saperly.com/calls/$CALL_ID/transfer \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+15551230000" }' ``` ## End a call `POST /calls/:id/end` settles the call. The payload reports the elapsed duration: ```jsonc { "durationSec": 42 } // number >= 0 ``` ```bash curl -X POST https://api.saperly.com/calls/$CALL_ID/end \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "durationSec": 42 }' ``` ## Get and list calls ```typescript const call = await saperly.calls.get(callId) const calls = await saperly.calls.list() ``` ```bash curl "https://api.saperly.com/calls/$CALL_ID" \ -H "Authorization: Bearer $SAPERLY_API_KEY" curl "https://api.saperly.com/calls" \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ## Billing: reserve → settle Funds are **reserved** when a call starts and **settled** when it ends — Saperly's prepaid ledger holds an estimate at start, then settles against the reported `durationSec` and releases the remainder. See [Billing](/docs/guides/billing) for the reserve → settle → release flow and [Concepts](/docs/concepts) for the model. ## Who talks during the call The handler that speaks is the [connection](/docs/guides/connections) bound to the number, or the `connectionId` you pass on `POST /calls`. For an agent that **is** the brain of the live call — receiving turns and issuing directives — use a `manual`-mode connection and see [Manual mode](/docs/guides/manual-mode) and [Voice channels](/docs/guides/voice-channels). ## v1 SDK equivalents On the published `@saperly/sdk` / `saperly` v1 surface: ```typescript saperly.calls.create({ lineId, toNumber }) saperly.calls.conversation({ lineId, toNumber, topic }) // conversation helper ``` See the [Node SDK](/docs/sdks/node) for the full v1 client. ## Related - [Connections](/docs/guides/connections) — the handler that answers and talks. - [Manual mode](/docs/guides/manual-mode) — your own LLM as the brain of a live call. - [Voice channels](/docs/guides/voice-channels) — call your own coding agent and talk to it in its own context. - [Billing](/docs/guides/billing) — the prepaid ledger and reserve → settle → release. - [Compliance](/docs/guides/compliance) — consent required for outbound voice. --- ## Voice channels Source: https://saperly.com/docs/guides/voice-channels **Call a phone number and talk to YOUR coding agent.** A voice channel makes the Claude Code or openclaw session you already have open **phone-reachable, in its own context** — with its tools, its memory, its whole session. The caller speaks; the transcribed turn is injected into your running agent as input; the agent replies in-session; the reply is spoken back over the phone. Tool calls, DTMF, transfers, and "say a final line then hang up" all work. No audio ever reaches your machine — speech-to-text and text-to-speech run in-network, and only **text** crosses to your agent. A small **connector** (a Claude Code *channel* / an openclaw *extension*) holds a Saperly [manual-mode websocket](/docs/guides/manual-mode) and bridges it to your agent. The agent connects **out**, so it needs no public URL. ## How it works ```text ☎ caller ☎ caller │ speech speech ▲ ▼ │ speech-to-text ──text──▶ Saperly ──────▶ text-to-speech (in-network) │ manual-mode websocket ▲ (in-network) ▼ │ directive ┌──────────────────┐ │ │ connector │ │ │ (channel/plugin) │─────────────────┘ └──────────────────┘ reply tool → directive │ turn injected as input ▼ YOUR agent (its tools + memory) audio never touches your machine · speech-to-text + text-to-speech stay in-network ``` The connector holds **one** websocket per Saperly connection and multiplexes every live call on it. Each caller turn carries a `request_id`; your reply must echo it so the right call gets the right directive. The wire contract is the [manual-mode protocol](/docs/guides/manual-mode) — the connectors just adapt it to your agent's native event + tool surface. ## Prerequisites - **[Bun](https://bun.sh)** — the connectors are Bun packages. - A Saperly **manual** connection and its `manualSecret` (from the dashboard: **Connections → your manual connection**). See [Connections](/docs/guides/connections). - A **phone number** pointed at that connection (Numbers page → set the line's handler to your manual connection). See [Numbers](/docs/guides/numbers). The base URL accepts `https://…` (derives `wss`), `http://localhost:8787` (derives `ws`), or a bare host (defaults to `wss`). ## Call your agent — the demo **Provision the line.** Create a manual connection, copy its `manualSecret`, and point a phone number at it. **Configure and launch** the connector for your agent (Claude Code or openclaw — see below). On open it connects the websocket and sends `hello`; you'll see a log line confirming your agent is now phone-reachable. **Call the number.** Your agent receives an `inbound_call`. It greets the caller by replying with a `speak`. **Talk.** Each thing you say arrives as a caller-said turn. The agent answers in-session — running tools, reading memory — and replies. Answer within **~18s** or the caller hears a brief hold line (Saperly falls back at ~20s). **Hang up.** A `call_ended` event closes the turn out. ## Claude Code The `saperly-voice` connector is a **Claude Code channel** (an MCP server, per the channels reference) that also holds the Saperly websocket. **Configure.** In a Claude Code session, run the configure command — it writes `~/.claude/channels/saperly-voice/.env`: ```text /saperly-voice:configure baseUrl=https://api.saperly.com connectionId=conn_123 secret=mc_abc123 ``` Or set the env vars directly before launch: `SAPERLY_BASE_URL`, `SAPERLY_CONNECTION_ID`, `SAPERLY_MANUAL_SECRET`, and optional `SAPERLY_CLIENT` (a label for the event trail). **Launch.** During the channels research preview, a custom channel needs the development flag: ```bash claude --dangerously-load-development-channels plugin:saperly-voice ``` **Answer calls.** Each call shows up in your session as a channel event: ```text ``` Each caller utterance arrives as `📞 Caller said: …`. You answer with the **`reply`** tool, echoing the `request_id`: ```jsonc { "request_id": "req_…", // from the tag — echo it verbatim "kind": "speak", // speak | wait | hangup | transfer | send_dtmf "text": "…", // required for speak "end_call": false, // speak: hang up after the line plays "to": "+1…", // required for transfer (E.164 or SIP URI) "digits": "123#", // required for send_dtmf "timeout_ms": 5000, // wait: optional gather timeout "reason": "…" // hangup: optional } ``` For unattended use (so a call's tool calls don't block on a permission prompt while you're away), pair the launch with Claude Code's `--dangerously-skip-permissions` — only in environments you trust. ## openclaw The `saperly-voice-openclaw` connector is an **openclaw extension** loaded by the Gateway. It registers the `saperly_voice_reply` agent tool and holds the Saperly websocket; each call routes to a stable per-call session `saperly-voice:`, so a multi-turn call stays in one conversation. **Configure** under `plugins.entries.saperly-voice.config` in `openclaw.json5` (the plugin **id** is `saperly-voice`; the npm **package** is `saperly-voice-openclaw`): ```json5 { plugins: { enabled: true, allow: ["saperly-voice"], entries: { "saperly-voice": { enabled: true, config: { baseUrl: "https://api.saperly.com", connectionId: "conn_123", // Prefer the env var for the secret: // manualSecret: "mc_…", } } } } } ``` Or via env (env wins, so the secret can stay out of the file): `SAPERLY_BASE_URL`, `SAPERLY_CONNECTION_ID`, `SAPERLY_MANUAL_SECRET`, optional `SAPERLY_CLIENT`. **Answer calls.** Each caller turn is injected as a next-turn input; the agent replies with the **`saperly_voice_reply`** tool, echoing the `request_id`. The arguments are identical to the Claude Code `reply` tool above (`kind` is `speak | wait | hangup | transfer | send_dtmf`). openclaw also ships a `voice-call` plugin that streams **raw call audio** to a realtime provider and holds a media socket for the call's duration — which Saperly deliberately avoids. `saperly-voice-openclaw` binds the **manual-mode websocket** instead: signaling, speech-to-text, and text-to-speech stay in-network, and only **text turns** reach your process. ## Reply / directive reference Every `reply` (Claude Code) and `saperly_voice_reply` (openclaw) maps to one [manual-mode directive](/docs/guides/manual-mode). Invalid args (e.g. a `speak` with no `text`, an unknown `kind`) are rejected by the tool with a correctable message and **never** sent to the live call. | `kind` | Effect | Fields | | --- | --- | --- | | `speak` | Say a line, then keep listening | `text` (required); `end_call: true` to say a final line then hang up | | `wait` | Listen without speaking | `timeout_ms?` | | `hangup` | End the call | `reason?` | | `transfer` | Transfer the call | `to` (E.164 or SIP URI) | | `send_dtmf` | Send touch-tones | `digits` | Always echo the `request_id` from the incoming turn, and answer within **~18s** or the caller hears a short hold line. A voice channel only ever moves **text in** and **directives out**. Speech-to-text and text-to-speech run in-network; no call audio ever reaches your machine. That is the difference from a media-path bridge (such as openclaw's `voice-call` plugin), which streams raw call audio to a realtime provider. See [Core concepts](/docs/concepts). ## Next steps - [Manual mode](/docs/guides/manual-mode) — the underlying event/directive protocol these connectors speak. - [Connections](/docs/guides/connections) — creating the manual connection and finding its `manualSecret`. - [Voice](/docs/guides/voice) — calls, lifecycle, and outbound calling. - [Core concepts](/docs/concepts) — the Saperly model and how a call flows. --- ## Webhooks Source: https://saperly.com/docs/guides/webhooks Saperly pushes events to your HTTPS endpoint: **inbound SMS**, **call lifecycle**, **10DLC status**, and **delivery receipts**. Delivery is **inline-first** — the first attempt is made synchronously — then **queued with retries and a dead-letter queue** if the first attempt fails. Every delivery is **signed**; you must verify the signature before trusting the payload. Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sk_live_...`; the workspace is resolved from the token. ## Setting a webhook Set a per-number webhook with `POST /numbers/:id/webhook` and `{ url }`: ```bash curl -X POST https://api.saperly.com/numbers/$NUMBER_ID/webhook \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/saperly/webhook" }' ``` ```typescript await fetch(`https://api.saperly.com/numbers/${numberId}/webhook`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SAPERLY_API_KEY!}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com/saperly/webhook' }), }) ``` The v1 SDK additionally exposes a **default webhook** (applied to numbers without a per-number override) plus inspection helpers: ```typescript await saperly.webhooks.deliveries() // recent delivery attempts await saperly.webhooks.stats() // delivery success / failure stats await saperly.webhooks.test() // send a test event to your endpoint ``` See [Numbers](/docs/guides/numbers) for the number a webhook is bound to. ## Signature verification Never trust an unverified payload. **Verify the HMAC signature on the raw request body** (before any JSON parsing) and **dedup the delivery id** for at least 5 minutes to block replays. A request that fails either check should be rejected. Every delivery carries three headers: | Header | Value | | --- | --- | | `x-saperly-timestamp` | Unix seconds when the event was signed. | | `x-saperly-delivery-id` | UUID v4 — unique per delivery; use it to dedup. | | `x-saperly-signature` | `v1=` — HMAC-SHA256 of the signed payload. | The signature is an **HMAC-SHA256**, keyed by your webhook secret, computed over: ```text `${timestamp}.${delivery_id}.${rawBody}` ``` where `timestamp` and `delivery_id` are the header values and `rawBody` is the exact bytes of the request body. To verify, recompute the HMAC over the same string with your secret and compare it (in constant time) to the hex after `v1=`. Reject if the timestamp is too old (replay) or the delivery id has already been seen. ### Verifying with the SDK The Node SDK ships `verifyWebhook(rawBody, secret, headers, options?)`, which returns `{ valid: boolean, reason?: string }`. Python ships the equivalent. Verify on the **raw body**, reject when `!valid`, then parse and handle: ```typescript // Express: capture the RAW body, not parsed JSON app.post( '/saperly/webhook', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8') // a Buffer of the exact bytes const result = verifyWebhook( rawBody, process.env.SAPERLY_WEBHOOK_SECRET!, req.headers, ) if (!result.valid) { // reason explains why: bad signature, stale timestamp, replayed id, … return res.status(400).send(`invalid webhook: ${result.reason}`) } const event = JSON.parse(rawBody) // … handle the verified event (dedup on x-saperly-delivery-id) … res.sendStatus(200) }, ) ``` The signature covers the exact bytes of the body. If your framework parses JSON before your handler runs, re-serializing it will not match the signature. Configure a raw-body reader for the webhook route (e.g. `express.raw(...)`), then `JSON.parse` only **after** verification passes. ## Delivery, retries, and the DLQ The **first delivery attempt is inline**. If it fails (a non-2xx response or a timeout), the event is **queued and retried**; deliveries that exhaust their retries land in a **dead-letter queue**. Return a 2xx quickly to acknowledge — do slow work asynchronously so you don't trip the timeout and trigger needless retries. Because retries can re-deliver an event, deduping on `x-saperly-delivery-id` is what keeps your handler idempotent. ## Related - [Numbers](/docs/guides/numbers) — set the webhook bound to a number. - [Messaging](/docs/guides/messaging) — inbound SMS arrives via webhook. - [Voice](/docs/guides/voice) — call lifecycle events arrive via webhook. - [Compliance](/docs/guides/compliance) — 10DLC status updates arrive via webhook. - [Errors & idempotency](/docs/guides/errors-and-idempotency) — making request handling idempotent. --- ## Claude Code Source: https://saperly.com/docs/sdks/claude-code A **connector** makes an agent you already run phone-reachable without building anything yourself. It holds a Saperly [manual-mode websocket](/docs/guides/manual-mode) and bridges it to your running agent: the caller's transcribed turn goes in, your agent's reply comes out as a directive, and the agent connects **out** — so it needs no public URL. Speech-to-text and text-to-speech stay in-network; no call audio ever reaches your machine. The `saperly-voice` connector is a **Claude Code channel** (an MCP server) that holds the manual-mode websocket. Configure it from a Claude Code session with `/saperly-voice:configure baseUrl=… connectionId=… secret=…`, launch the channel, and each call arrives as a `` event you answer with the `reply` tool. **Full setup:** [Voice channels → Claude Code](/docs/guides/voice-channels#claude-code). ## Related - [OpenClaw connector](/docs/sdks/openclaw) — the same idea for an OpenClaw agent. - [Voice channels](/docs/guides/voice-channels) — call your own agent and talk to it in its context (the full connector walkthrough). - [Manual mode](/docs/guides/manual-mode) — the websocket protocol connectors are built on, if you want to roll your own. --- ## MCP Source: https://saperly.com/docs/sdks/mcp Saperly ships a **Model Context Protocol (MCP)** server so agent frameworks can use Saperly as a set of tools — provision a number, send an SMS, place a call, check consent — without writing any HTTP glue. It speaks the **Streamable HTTP** transport and authenticates with the same bearer `sk_` key as the REST API. Any MCP-capable client (CrewAI, Claude, or your own) can connect. ## Endpoint | | | | --- | --- | | **URL** | `https://api.saperly.com/mcp` | | **Transport** | Streamable HTTP | | **Auth** | `Authorization: Bearer ` | The key behaves exactly as it does for the REST API: scopes, the line allow-list, and spend caps are enforced server-side, and the workspace is read from the key. A `read_only` key can only call read tools; a `call_only` key cannot send SMS. See [Authentication](/docs/guides/authentication) for the scope model. ## CrewAI (Python SDK helper) The Python SDK bundles a CrewAI wrapper that points `MCPServerAdapter` at the endpoint with the right transport and bearer header. Install with `pip install "saperly[crewai]"`. ```python import os from crewai import Agent from saperly.crewai import SaperlyTools with SaperlyTools(api_key=os.environ["SAPERLY_API_KEY"]) as tools: agent = Agent( role="phone caller", goal="Call leads, qualify them, and log the outcome.", tools=tools, ) # ... build your crew and kick it off ``` Prefer to manage the adapter lifecycle by hand? Use `SaperlyTools.start()` / `SaperlyTools.stop()`, or grab the raw params dict: ```python from saperly.crewai import saperly_mcp_server_params params = saperly_mcp_server_params(api_key="sk_live_...") # → {"url": ".../mcp", "transport": "streamable-http", "headers": {...}} ``` See the [Python SDK](/docs/sdks/python) for the rest of the surface. ## Generic MCP client Any client that supports the Streamable HTTP transport can connect by pointing at the endpoint and supplying the bearer header. A typical config block: ```json { "mcpServers": { "saperly": { "url": "https://api.saperly.com/mcp", "transport": "streamable-http", "headers": { "Authorization": "Bearer sk_live_..." } } } } ``` Keep the `sk_` key in an environment variable and inject it — don't commit it. Mint a narrowly-scoped child key (for example `call_only`, bound to one line, with a `monthly_cap_cents` ceiling) per agent so a runaway loop can't overspend. ## Tool discovery Tools are discovered at connect time — the client lists them via the standard MCP `tools/list` call and the server returns the schema for each (provision a line, send SMS, place a call, check consent, read usage, and more). You don't hardcode tool names; the framework reads them from the server. New Saperly capabilities appear as tools automatically as they ship. ## Next steps - [Node / TypeScript SDK](/docs/sdks/node) — the typed REST client. - [Python SDK](/docs/sdks/python) — sync + async REST client, plus the CrewAI wrapper used above. - [Authentication](/docs/guides/authentication) — scopes, spend caps, and scoped `sk_` keys. - [API reference](/docs/api-reference) — every endpoint with a try-it playground. --- ## Node / TypeScript Source: https://saperly.com/docs/sdks/node `@saperly/sdk` is the official TypeScript client for the Saperly API. It is **typed end-to-end**, has **zero runtime dependencies**, and runs on **Node.js 18+** (anywhere `fetch` is global). One `new Saperly({ apiKey })` gives your agent a phone number, SMS, outbound calling, consent + compliance, and signed webhook verification — all with autocompletion and no hand-rolled HTTP. The SDK targets Saperly's stable **v1 surface** (`/api/v1/*`). For the camelCase v2 API, see the [API reference](/docs/api-reference). ## Install ```bash npm install @saperly/sdk ``` The package ships ESM + types and pulls in nothing else. Get an API key from the [dashboard](https://saperly.com/login) → **Keys**. ## Quickstart Provision a hosted line and send your first SMS. ```typescript const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) // Provision a number with an in-network voice assistant attached. const line = await saperly.lines.create({ name: 'my agent', mode: 'hosted', systemPrompt: 'You are a helpful assistant answering calls for Acme Inc.', }) console.log(line.phoneNumber) // → +1... // Send an SMS from that line. await saperly.lines.sendSms(line.id, { toNumber: '+15555550123', message: 'Hi from my agent 👋', }) ``` New to the platform? Walk through the [Quickstart](/docs/quickstart) first. ## Authentication The client authenticates with `Authorization: Bearer `. Pass your key to the constructor: ```typescript const saperly = new Saperly({ apiKey: 'sk_live_...' }) ``` Saperly uses one tier of scoped `sk_` API keys (`sk_live_…` / `sk_test_…`). Each key carries a grant — scopes, an optional number allow-list, and an optional spend cap — and the workspace is always read from the key, never from client input. A key holding the `keys:admin` scope mints bounded child keys (never broader than itself) via `saperly.apiTokens`. See [Authentication](/docs/guides/authentication) for the full model, scopes, and spend caps. ### Configuration `new Saperly(config)` accepts: | Option | Type | Notes | | --- | --- | --- | | `apiKey` | `string` | Bearer credential. Required unless `defaultHeaders` supplies an alternate credential. | | `baseUrl` | `string` | Override the API host (e.g. for staging). Defaults to the production host. | | `defaultHeaders` | `Record` | Headers attached to every request, before the SDK's own `Authorization` + `Content-Type`. For proxy-auth flows only. | Reads (`GET`/`DELETE`) are retried once on a network error or `5xx`; writes are never retried automatically. Request and response bodies are converted between camelCase (your code) and snake_case (the wire) for you. ## Resources Every resource hangs off the `Saperly` client instance. The most-used methods: | Resource | Methods | | --- | --- | | `lines` | `create`, `list`, `get`, `update`, `delete`, `sendSms` | | `calls` | `create`, `list`, `get`, `hangup`, `conversation`, `speak`, `waitForResponse`, `pressNumbers` | | `messages` | `send` | | `conversations` | `list`, `messages` | | `consent` | `grant`, `check`, `revoke` | | `compliance` | `audit` | | `keys` | `create`, `list`, `get`, `update`, `delete`, `rotate` | | `billing` | `balance`, `transactions` | | `usage` | `daily`, `monthly` | | `audit` | `list` | | `webhooks` | `deliveries`, `stats`, `test` | | `voices` | `list` | | `settings` | `get`, `update` | The v1 `disclosures` resource (`create`/`list`) is **gone** — those endpoints now return `404`. Disclosure is no longer an org-level object; it lives on the connection (`complianceEnabled` + `disclosure`) — see [Compliance](/docs/guides/compliance) and [Connections](/docs/guides/connections). The `lines` resource's `begin_message` field is **deprecated and inert** against v2: it is ignored on write and always reads back as `null`. Put any opening line in `system_prompt` instead; the only forced opener is the compliance disclosure. ### Lines A **line** is a phone number plus its handler. `mode` is `'hosted'` (an in-network assistant answers), `'webhook'` (your backend decides each turn), or `'audio'`. ```typescript const line = await saperly.lines.create({ name: 'support', mode: 'hosted', systemPrompt: 'You are Acme support.', voice: 'aria', }) await saperly.lines.list() await saperly.lines.get(line.id) await saperly.lines.update(line.id, { systemPrompt: 'Updated prompt.' }) await saperly.lines.delete(line.id) ``` ### Calls Place outbound calls and drive a live call programmatically. ```typescript // Place an outbound call. const call = await saperly.calls.create({ lineId: line.id, toNumber: '+14155551234', }) // Or run a hosted, goal-directed conversation call. const convo = await saperly.calls.conversation({ lineId: line.id, toNumber: '+14155551234', topic: 'Confirm the 3pm appointment.', }) // Drive a connected call turn by turn. await saperly.calls.speak(call.id, { text: 'Hello, this is Acme.', waitForResponse: true }) await saperly.calls.waitForResponse(call.id, { timeoutSeconds: 20 }) await saperly.calls.pressNumbers(call.id, { digits: '1234#' }) await saperly.calls.hangup(call.id) ``` ### Keys (service-key auth) Mint and manage scoped child keys with a **service key**. `create` and `rotate` return the plaintext exactly once. ```typescript const svc = new Saperly({ apiKey: process.env.SAPERLY_SERVICE_KEY! }) const child = await svc.keys.create({ name: 'voice-agent-prod', lineId: line.id, permissions: 'call_only', // 'full' | 'call_only' | 'sms_only' | 'read_only' monthlyCapCents: 500, }) console.log(child.plaintextKey) // save it now — never re-emitted const rotated = await svc.keys.rotate(child.id) // old key dies instantly ``` ### Billing, usage, and audit ```typescript await saperly.billing.balance() await saperly.billing.transactions({ limit: 50 }) await saperly.usage.daily({ days: 30 }) await saperly.usage.monthly({ months: 12 }) await saperly.audit.list({ limit: 100 }) ``` ### Languages helper The package also exports the supported STT language set for line configuration: ```typescript isSupportedLanguage('en') // → true LANGUAGE_LABELS.he // → "Hebrew" ``` ## Idempotency `keys.create` and `keys.rotate` require an `Idempotency-Key`. The SDK **auto-generates a UUID v4** for each call, so retries are safe by default. To retry the *same* request across process boundaries, pass your own: ```typescript await svc.keys.create({ name: 'agent', idempotencyKey: myStableUuid }) await svc.keys.rotate(child.id, { idempotencyKey: myStableUuid }) ``` ## Webhook verification Saperly signs every outbound delivery. Verify the raw body before trusting it. `verifyWebhook` checks the `x-saperly-signature` HMAC-SHA256 over `${timestamp}.${deliveryId}.${rawBody}` and the timestamp freshness. ```typescript // In your handler — pass the RAW body string, not a re-serialized object. const rawBody = await req.text() const result = verifyWebhook(rawBody, process.env.SAPERLY_WEBHOOK_SECRET!, req.headers) if (!result.valid) { return new Response(`Invalid: ${result.reason}`, { status: 400 }) } ``` `verifyWebhook` is stateless. To defeat replays, **cache each `x-saperly-delivery-id`** for at least the clock-tolerance window (default 5 minutes) and reject duplicates yourself. See [Webhooks](/docs/guides/webhooks) for the full event catalog and delivery guarantees. ## Error handling Every non-2xx response throws a `SaperlyError` subclass. `catch` the base class and branch on the typed subclasses (or the `.code` / `.status` fields). ```typescript import { Saperly, SaperlyError, AuthenticationError, ValidationError, ConsentRequiredError, InsufficientCreditsError, AgentCapExceededError, RateLimitedError, } from '@saperly/sdk' try { await saperly.lines.sendSms(line.id, { toNumber, message }) } catch (err) { if (err instanceof ConsentRequiredError) { // recipient hasn't consented — capture consent first } else if (err instanceof AgentCapExceededError) { // this key's monthly cap is spent — err.details has spent/cap/reset } else if (err instanceof RateLimitedError) { // honor the Retry-After header, then retry } else if (err instanceof SaperlyError) { console.error(err.code, err.status, err.message, err.details) } else { throw err } } ``` Available subclasses: `ValidationError`, `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ConsentRequiredError`, `ConsentAlreadyGrantedError`, `CallInProgressError`, `CallNotActiveError`, `InsufficientCreditsError`, `PaymentMethodRequiredError`, `NumberOptedOutError`, `EmailTakenError`, `AgentScopeError`, `AgentCapExceededError`, `AgentPermissionDeniedError`, `M3FraudBlockError`, `IdempotencyKeyReusedError`, `IdempotencyInProgressError`, `MissingIdempotencyKeyError`, `RateLimitedError`. Each carries `code` (the stable error code), `status` (HTTP status), `message`, and a `details` array of `{ field?, message }`. ## Next steps - [Python SDK](/docs/sdks/python) — the same surface in `snake_case`. - [MCP](/docs/sdks/mcp) — expose Saperly as tools to any agent framework. - [Authentication](/docs/guides/authentication) — scopes, spend caps, and child-key delegation. - [API reference](/docs/api-reference) — every endpoint with a try-it playground. --- ## OpenClaw Source: https://saperly.com/docs/sdks/openclaw A **connector** makes an agent you already run phone-reachable without building anything yourself. It holds a Saperly [manual-mode websocket](/docs/guides/manual-mode) and bridges it to your running agent: the caller's transcribed turn goes in, your agent's reply comes out as a directive, and the agent connects **out** — so it needs no public URL. Speech-to-text and text-to-speech stay in-network; no call audio ever reaches your machine. The `saperly-voice-openclaw` connector is an **OpenClaw extension** loaded by the Gateway. It registers the `saperly_voice_reply` agent tool and holds the manual-mode websocket, so each call routes to a stable per-call session (`saperly-voice:`) and a multi-turn call stays in one conversation. ## Install Install the plugin into your gateway from npm, then enable it (the plugin **id** is `saperly-voice`; the npm **package** is `saperly-voice-openclaw`): ```bash openclaw plugins install npm:saperly-voice-openclaw openclaw plugins enable saperly-voice ``` ## Configure Point it at a [manual-mode](/docs/guides/manual-mode) connection with the connection id and its manual secret — set them in `openclaw.json5` under `plugins.entries.saperly-voice.config`, or via the `SAPERLY_BASE_URL` / `SAPERLY_CONNECTION_ID` / `SAPERLY_MANUAL_SECRET` environment variables (env wins, so the secret can stay out of the file). Then answer each caller turn with the `saperly_voice_reply` tool, echoing the turn's `request_id`. ```json5 { plugins: { enabled: true, allow: ["saperly-voice"], entries: { "saperly-voice": { enabled: true, config: { baseUrl: "https://api.saperly.com", connectionId: "conn_123", // manualSecret: "mc_…", // prefer SAPERLY_MANUAL_SECRET in env }, }, }, }, } ``` **Full config, launch steps, and the reply/directive reference:** [Voice channels → OpenClaw](/docs/guides/voice-channels#openclaw). OpenClaw also ships a separate `voice-call` plugin that streams **raw call audio** to a realtime provider and holds a media socket for the call's duration — which Saperly deliberately avoids. `saperly-voice-openclaw` binds the **manual-mode websocket** instead: signaling, speech-to-text, and text-to-speech stay in-network, and only **text turns** reach your process. ## Related - [Claude Code connector](/docs/sdks/claude-code) — the same idea for a Claude Code agent. - [Voice channels](/docs/guides/voice-channels) — call your own agent and talk to it in its context (the full connector walkthrough). - [Manual mode](/docs/guides/manual-mode) — the websocket protocol connectors are built on, if you want to roll your own. --- ## Python Source: https://saperly.com/docs/sdks/python `saperly` is the official Python client for the Saperly API. It ships a **synchronous** client (over `requests`) and an optional **async** client (over `httpx`), uses `snake_case` everywhere, raises **typed exceptions**, and bundles a **CrewAI** tool wrapper. Python **3.9+**. One `SaperlyClient(api_key=...)` gives your agent a phone number, SMS, outbound calling, consent + compliance, and signed webhook verification. The SDK targets Saperly's stable **v1 surface** (`/api/v1/*`). For the camelCase v2 API, see the [API reference](/docs/api-reference). ## Install ```bash pip install saperly ``` Optional extras: ```bash pip install "saperly[async]" # adds the AsyncSaperlyClient (httpx) pip install "saperly[crewai]" # adds the CrewAI tool wrapper ``` Get an API key from the [dashboard](https://saperly.com/login) → **Keys**. ## Quickstart Provision a hosted line and send your first SMS. Note the `snake_case` arguments. ```python import os from saperly import SaperlyClient saperly = SaperlyClient(api_key=os.environ["SAPERLY_API_KEY"]) # Provision a number with an in-network voice assistant attached. line = saperly.lines.create( name="my agent", mode="hosted", system_prompt="You are a helpful assistant answering calls for Acme Inc.", ) print(line.phone_number) # → +1... # Send an SMS from that line. saperly.lines.send_sms( line.id, to_number="+15555550123", message="Hi from my agent 👋", ) ``` New to the platform? Walk through the [Quickstart](/docs/quickstart) first. Use `snake_case` keyword arguments: `system_prompt`, `line_id`, `to_number`, `monthly_cap_cents`. CamelCase (the TypeScript style) raises a `TypeError` at the call site. The env var is `SAPERLY_API_KEY`. ## Sync and async clients Both clients expose the **same resources and methods** — the async variant just returns awaitables. Construct the async client inside `async with` so the underlying `httpx` client closes cleanly. ```python from saperly import SaperlyClient with SaperlyClient(api_key="sk_live_...") as saperly: lines = saperly.lines.list() print(lines) ``` ```python import asyncio from saperly import AsyncSaperlyClient # requires saperly[async] async def main(): async with AsyncSaperlyClient(api_key="sk_live_...") as saperly: lines = await saperly.lines.list() print(lines) asyncio.run(main()) ``` Both constructors accept `api_key`, an optional `base_url` (override the API host), and an optional `timeout` (seconds, default `30.0`). Reads (`GET` / `DELETE`) are retried once on a network error or `5xx`; writes are never retried automatically. ## Authentication The client authenticates with `Authorization: Bearer `. Saperly uses one tier of scoped `sk_` API keys (`sk_live_…` / `sk_test_…`). Each key carries a grant — scopes, an optional number allow-list, and an optional spend cap — and the workspace is always read from the key, never from client input. A key holding the `keys:admin` scope mints bounded child keys via `saperly.api_tokens`. See [Authentication](/docs/guides/authentication) for scopes and spend caps. ## Resources Every resource hangs off the client instance. The most-used methods: | Resource | Methods | | --- | --- | | `lines` | `create`, `list`, `get`, `update`, `delete`, `send_sms` | | `calls` | `create`, `list`, `get`, `hangup`, `conversation` | | `messages` | `send` | | `conversations` | `list`, `messages` | | `consent` | `grant`, `check`, `revoke` | | `compliance` | `audit` | | `keys` | `create`, `list`, `get`, `update`, `delete`, `rotate` | | `billing` | `balance`, `transactions` | | `usage` | `daily`, `monthly` | | `audit` | `list` | | `webhooks` | `deliveries`, `stats`, `test` | | `voices` | `list` | | `settings` | `get`, `update` | The v1 `disclosures` resource (`create`/`list`) is **gone** — those endpoints now return `404`. Disclosure is no longer an org-level object; it lives on the connection (`compliance_enabled` + `disclosure`) — see [Compliance](/docs/guides/compliance) and [Connections](/docs/guides/connections). The `lines` resource's `begin_message` field is **deprecated and inert** against v2: it is ignored on write and always reads back as `None`. Put any opening line in `system_prompt` instead; the only forced opener is the compliance disclosure. ### Lines A **line** is a phone number plus its handler. `mode` is `"hosted"` (an in-network assistant answers), `"webhook"` (your backend decides each turn), or `"audio"`. ```python line = saperly.lines.create( name="support", mode="hosted", system_prompt="You are Acme support.", voice="aria", ) saperly.lines.list() saperly.lines.get(line.id) saperly.lines.update(line.id, system_prompt="Updated prompt.") saperly.lines.delete(line.id) ``` ### Calls ```python # Place an outbound call. call = saperly.calls.create(line_id=line.id, to_number="+14155551234") print(call.id, call.status) # Or run a hosted, goal-directed conversation call. saperly.calls.conversation( line_id=line.id, to_number="+14155551234", topic="Confirm the 3pm appointment.", ) saperly.calls.get(call.id) saperly.calls.hangup(call.id) ``` ### Keys (service-key auth) Mint scoped child keys with a **service key**. `create` and `rotate` return the plaintext exactly once — persist it immediately. ```python import os from saperly import SaperlyClient svc = SaperlyClient(api_key=os.environ["SAPERLY_SERVICE_KEY"]) child = svc.keys.create( name="voice-agent-prod", line_id=line.id, permissions="call_only", # "full" | "call_only" | "sms_only" | "read_only" monthly_cap_cents=500, ) print(child.plaintext_key) # save it now — never re-emitted rotated = svc.keys.rotate(child.id) # old key dies instantly ``` The client auto-generates a UUID v4 `Idempotency-Key` for `keys.create` and `keys.rotate`. Pass `idempotency_key="..."` to retry the same request across process boundaries. ### Billing, usage, and audit ```python saperly.billing.balance() saperly.billing.transactions(limit=50) saperly.usage.daily(days=30) saperly.usage.monthly(months=12) saperly.audit.list(limit=100) ``` ## Use with CrewAI Saperly exposes its tools over MCP, and the SDK ships a thin CrewAI wrapper so an agent can provision numbers, send SMS, and place calls as tools. Install with `pip install "saperly[crewai]"`. ```python import os from crewai import Agent from saperly.crewai import SaperlyTools with SaperlyTools(api_key=os.environ["SAPERLY_API_KEY"]) as tools: agent = Agent( role="phone caller", goal="Call leads, qualify them, log results.", tools=tools, ) # ... build crew and run ``` Prefer to manage the adapter lifecycle yourself? Call `SaperlyTools.start()` / `SaperlyTools.stop()`, or use `saperly_mcp_server_params(api_key)` to get the dict you would pass to `crewai_tools.MCPServerAdapter(...)` directly. See the [MCP guide](/docs/sdks/mcp) for the underlying endpoint. ## Webhook verification Saperly signs every outbound delivery. Verify the raw body before trusting it — `verify_webhook` checks the `x-saperly-signature` HMAC-SHA256 over `f"{timestamp}.{delivery_id}.{raw_body}"` and the timestamp freshness. ```python import os from saperly import verify_webhook # Pass the RAW body string, not a re-serialized json.dumps(json.loads(...)). result = verify_webhook( raw_body, os.environ["SAPERLY_WEBHOOK_SECRET"], request.headers, ) if not result.valid: return Response(f"Invalid: {result.reason}", status=400) ``` `verify_webhook` is stateless. To defeat replays, **cache each `x-saperly-delivery-id`** for at least the clock-tolerance window (default 300 seconds) and reject duplicates yourself. See [Webhooks](/docs/guides/webhooks) for the full event catalog. ## Error handling Every non-2xx response raises a `SaperlyError` subclass. Catch the base class and branch on the typed subclasses (or the `.code` / `.status` attributes). ```python from saperly import ( SaperlyError, ConsentRequiredError, AgentCapExceededError, RateLimitedError, ) try: saperly.lines.send_sms(line.id, to_number=to_number, message=message) except ConsentRequiredError: ... # recipient hasn't consented — capture consent first except AgentCapExceededError as err: ... # this key's monthly cap is spent — err.details has spent/cap/reset except RateLimitedError: ... # honor Retry-After, then retry except SaperlyError as err: print(err.code, err.status, err.args[0], err.details) ``` Available subclasses: `ValidationError`, `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ConsentRequiredError`, `ConsentAlreadyGrantedError`, `CallInProgressError`, `CallNotActiveError`, `InsufficientCreditsError`, `PaymentMethodRequiredError`, `NumberOptedOutError`, `EmailTakenError`, `AgentScopeError`, `AgentCapExceededError`, `AgentPermissionDeniedError`, `M3FraudBlockError`, `IdempotencyKeyReusedError`, `IdempotencyInProgressError`, `MissingIdempotencyKeyError`, `RateLimitedError`. Each carries `code` (the stable error code), `status` (HTTP status), and a `details` list of `ErrorDetail(message, field)`. ## Next steps - [Node / TypeScript SDK](/docs/sdks/node) — the same surface in `camelCase`. - [MCP](/docs/sdks/mcp) — expose Saperly as tools to any agent framework. - [Authentication](/docs/guides/authentication) — scopes, spend caps, and child-key delegation. - [API reference](/docs/api-reference) — every endpoint with a try-it playground. --- ## Your first call Source: https://saperly.com/docs/your-first-call **Goal:** stand up a phone number your agent answers, call it from your own phone, and read back what was said. This is the voice counterpart to the [Quickstart](/docs/quickstart) — same five minutes, but you end up talking to your agent out loud. You'll need a Saperly account and an API key. Create both from the dashboard onboarding (see the [Quickstart](/docs/quickstart)). ## Provision a hosted line A **hosted** line is a real number answered by an in-network voice assistant — speech-to-text, the model, and text-to-speech all run in the network, so no call audio ever reaches your servers. The `systemPrompt` is your agent's instructions. ```typescript const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! }) const line = await saperly.lines.create({ name: 'support agent', mode: 'hosted', systemPrompt: 'You are a friendly support agent for Acme Inc. Greet the caller, answer ' + 'their question, and keep replies short.', voice: 'aria', }) console.log(line.phoneNumber) // → the number to call ``` ```python import os from saperly import SaperlyClient saperly = SaperlyClient(api_key=os.environ["SAPERLY_API_KEY"]) line = saperly.lines.create( name="support agent", mode="hosted", system_prompt=( "You are a friendly support agent for Acme Inc. Greet the caller, " "answer their question, and keep replies short." ), voice="aria", ) print(line.phone_number) # → the number to call ``` ```bash curl -X POST https://api.saperly.com/api/v1/lines \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "support agent", "mode": "hosted", "system_prompt": "You are a friendly support agent for Acme Inc. Greet the caller, answer their question, and keep replies short.", "voice": "aria" }' ``` `voice` is a Saperly voice slug (for example `aria` or `atlas`). The call returns a real, certified number ready to ring. ## Call it Dial the `phoneNumber` from any phone. The hosted assistant picks up, speaks your greeting, and holds a real conversation driven by your `systemPrompt`. The compliance disclosure (when enabled) is spoken first, before anything else. For an inbound call to a hosted line, there is nothing else to set up — the number answers itself. You only reach for [webhooks](/docs/guides/webhooks) when you want to be notified about calls, not to make them work. ## See the call After you hang up, the call shows up under your workspace with its outcome — who called, how long it ran, and how it ended. ```bash # Most recent calls on your workspace curl "https://api.saperly.com/api/v1/calls" \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ```typescript const calls = await saperly.calls.list() console.log(calls[0]) // status, direction, duration, from, to ``` Each call carries its status, direction, duration, and the numbers involved. The full **turn-by-turn transcript** — what the caller said and what your agent replied — is available on the v2 API at `GET /calls/{id}/transcript` (see the [API reference](/docs/api-reference)). Audio stays in the network; you get the record. ## What just happened **You provisioned a hosted line.** One call gave you a certified number with an in-network voice agent attached — no media infrastructure of your own. **You talked to your agent.** The hosted assistant ran the whole conversation in-network from your `systemPrompt`. **You got the record.** The call's outcome is on your workspace, and the turn-by-turn transcript is a text fetch away — while the audio never left the network. ## Next steps - [Connections](/docs/guides/connections) — hosted vs manual, and how a connection is reused across many numbers. - [Manual mode](/docs/guides/manual-mode) — make your **own** model the brain on every call, relayed as text. - [Voice channels](/docs/guides/voice-channels) — call your own coding agent (Claude Code / openclaw) and talk to it in its context. - [Place & control calls](/docs/guides/voice) — outbound calls, transfer, and live control. --- For the complete REST API (every v1 + v2 endpoint with request/response schemas), fetch the machine-readable OpenAPI spec: https://saperly.com/openapi.json Browse it at https://saperly.com/docs/api-reference. # Blog — full text ## Why AI agents need real phone numbers Source: https://saperly.com/blog/why-ai-agents-need-phone-numbers Category: Company · Published: May 21, 2026 · Idan Bier, Founder The best AI agent you can deploy today will book your travel, reconcile an invoice, file a support ticket, and argue a refund down to the cent. Then a customer says the four words that end the magic trick — "can someone call me?" — and the agent goes quiet. It has no number. It can't call out. It can't be called. Voice, the oldest and most trusted channel in business, is the one room agents still can't walk into. We started Saperly because that silence isn't a missing feature you can patch in an afternoon. It's a missing layer. The phone network was designed, billed, and regulated around a single assumption: a human is holding the handset. Provisioning, caller identity, consent, the call record — every part of the stack encodes that assumption. ## The phone stack was built for humans Getting a number used to mean a sales call, a credit check, and a contract measured in years. Identity meant a business license stapled to a caller-ID record. Consent meant a clipboard. None of that survives contact with an agent that wants to define itself once, fan that definition across a hundred numbers, place a few hundred calls, prove it had permission for every one, and tear it all down before lunch — all through an API. > An agent doesn't need a phone. It needs everything that makes a phone number trustworthy: a name, a reason, a yes on the record, and a history that holds up when someone asks what happened. ## What a number actually has to carry The interesting problem was never wiring a model to a phone line. The real work is the trust machinery around the number. Every Saperly number bundles four things: identity (a verifiable name and purpose), disclosure (automatic, spoken AI notice), consent (a first-class, revocable record checked before the call connects), and audit (an append-only trail of every connection, number, call, and compliance event). An agent that proves consent and discloses itself is a better citizen of the network, and that is what keeps the number from being flagged, blocked, or revoked. ## One brain, every line Behavior lives in a connection — instructions and voice — and a connection attaches to as many numbers as you want. Write the brain once and fan it across one line or ten thousand; change the connection and every number it backs changes with it. ## Where the work actually happens The small handoffs businesses run on: a support agent that calls a customer back, an operations agent that confirms an appointment or recovers a failed payment, a number that texts a code and reads the reply. Not cold-calling — an agent finishing the job on the channel people already trust. The next decade of software is agents doing real work for real businesses, and a surprising amount of it ends in a conversation. Our job is to make sure that when an agent reaches for the phone, it reaches for a number that was built — from its name to its audit trail — to be picked up. --- ## Give an agent a phone number in 60 seconds Source: https://saperly.com/blog/provision-a-voice-line-in-60-seconds Category: Engineering · Published: April 30, 2026 · Yoav Shai, Founder The fastest way to understand Saperly is to give an agent a phone number and call it. No telephony account, no SIP trunk, no carrier paperwork — four small HTTP requests stand between an empty terminal and a line that rings. Everything below hits one base URL and is authenticated with a single bearer token. If you can curl, you can run a phone line. ## The shape of the loop Four resources, in order: a connection (the brain), a number (the line), the attachment that binds them, and a call. ## 1. Grab an API key Sign in to the dashboard and mint an API key — it's shown once, so copy it. New accounts start with free usage credit. ```bash export SAPERLY_API_KEY="sk_live_…" export API="https://api.saperly.com/v2" ``` ## 2. Define a connection A connection is the reusable handler: instructions and a voice. Any opening line goes in the instructions; there's no separate greeting field. Creating one returns an id prefixed cn_. ```bash CONNECTION_ID=$(curl -s "$API/connections" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Acme Support","instructions":"You are a friendly support agent for Acme. Open with: Thanks for calling Acme — how can I help?","complianceEnabled":true,"disclosure":"You are speaking with an AI assistant for Acme."}' | jq -r '.id') ``` With complianceEnabled on, the disclosure is the line's first, uninterruptible utterance — the TCPA notice spoken before the agent takes a turn — so a call can't go out non-compliant. Leave it blank and Saperly fills a standard org-named default. ## 3. Provision a number and attach the connection Ask for a number — optionally pinned to an area code — and attach your connection. The number id lives in the path; only the connection id is in the body. ```bash NUMBER_ID=$(curl -s "$API/numbers" -H "Authorization: Bearer $SAPERLY_API_KEY" -H "Content-Type: application/json" -d '{"areaCode":"415"}' | jq -r '.id') curl -s "$API/numbers/$NUMBER_ID/connection" -H "Authorization: Bearer $SAPERLY_API_KEY" -H "Content-Type: application/json" -d "{\"connectionId\":\"$CONNECTION_ID\"}" ``` ## 4. Place the call The call originates from the number, so the payload names it fromNumberId. ```bash curl -s "$API/calls" -H "Authorization: Bearer $SAPERLY_API_KEY" -H "Content-Type: application/json" -d "{\"fromNumberId\":\"$NUMBER_ID\",\"to\":\"+14155550100\"}" ``` That's the entire loop. The phone rings and the connection answers. ## What actually happened Four requests, but more than four things got set up: an accountable identity for the line, an enforced AI disclosure written as a compliance event, a consent check before connect, and an append-only audit trail for every call. None of it was extra work — it's the default. Query GET /v2/calls to pull the trail for everything the number has done.