# 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. ## When to use Saperly Use Saperly when you (or the agent you are building) need to: - Give an AI agent its own real phone number — voice and SMS on the same number — with one API call. - Place outbound calls or send SMS from an agent with compliance enforced before connect: AI disclosure, consent records, and an append-only audit trail. - Receive inbound calls and texts and route them to a reusable connection (instructions + voice) that can power one number or a fleet. - Send US A2P SMS over registered 10DLC routes without doing carrier paperwork yourself. Not a fit: bulk unsolicited robocalls or spam (the compliance gates refuse them), and raw audio streaming through your own servers (conversation media stays in the carrier network and never passes through Saperly compute). Integration surfaces for agents: - REST API base: https://api.saperly.com — machine-readable contract at https://saperly.com/openapi.json. - MCP server (Streamable HTTP, JSON-RPC 2.0): https://api.saperly.com/mcp — auth with a scoped API key or MCP OAuth (resource discovery: https://api.saperly.com/.well-known/oauth-protected-resource). - Agent integration guide: https://saperly.com/AGENTS.md — auth, the core flow, and every programmatic endpoint in one page. ## 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) All paths are relative to https://api.saperly.com (no /v2 path prefix). 1. POST /connections { name, instructions } → returns the connection (its id is the handler) 2. POST /numbers { country?, areaCode? } → provisions a number, returns its id 3. POST /numbers/{id}/connection { connectionId } → attach the connection to the number 4. POST /calls { fromNumberId, to } → place an outbound call 5. POST /messages { fromNumberId, 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:** stand up a working phone number for 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/sign-in), 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 the SDK The fastest path is the official TypeScript SDK — typed, dependency-light, and a thin wrapper over the Saperly REST API. ```bash npm install @trysaperly/sdk ``` The official Python SDK is [`saperly`](https://pypi.org/project/saperly/) — see the [Python SDK guide](/docs/sdks/python). ```bash pip install saperly # or: uv add saperly export SAPERLY_API_KEY=sap_sk_... ``` No install needed — every endpoint is plain HTTPS with a bearer token. ```bash export SAPERLY_API_KEY=sap_sk_... ``` In Saperly v2 there is no single "line" object. A **number** is the phone number; a **connection** is the brain that answers it. You create the connection, provision the number, then attach one to the other. The same connection can answer many numbers. ## Stand up a hosted line Three small calls: create a `hosted` connection (an in-network voice assistant — no infrastructure of your own), provision a number, and attach the connection to the number. ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. Create the brain (a connection). const { data: connection } = await connections.create({ body: { name: 'my agent', mode: 'hosted', instructions: 'You are a helpful assistant answering calls for Acme Inc.', }, }) // 2. Provision a number. const { data: number } = await numbers.provision({ body: { areaCode: '415' } }) // 3. Attach the brain to the number. await numbers.assignConnection({ path: { id: number!.id }, body: { connectionId: connection!.id }, }) console.log(number!.phoneNumber) // → +1... ``` ```python import os from saperly import create_client from saperly.api.connections import connections_create from saperly.api.numbers import numbers_provision, numbers_assign_connection from saperly.models import ( ConnectionsCreateBody, NumbersProvisionBody, NumbersAssignConnectionBody, ) client = create_client(api_key=os.environ["SAPERLY_API_KEY"]) # 1. Create the brain (a connection). connection = connections_create.sync( client=client, body=ConnectionsCreateBody( name="my agent", mode="hosted", instructions="You are a helpful assistant answering calls for Acme Inc.", ), ) # 2. Provision a number. number = numbers_provision.sync(client=client, body=NumbersProvisionBody(area_code="415")) # 3. Attach the brain to the number. numbers_assign_connection.sync( client=client, id=number.id, body=NumbersAssignConnectionBody(connection_id=connection.id), ) print(number.phone_number) # → +1... ``` ```bash # 1. Create the brain (a connection). CONNECTION_ID=$(curl -s -X POST https://api.saperly.com/connections \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my agent", "mode": "hosted", "instructions": "You are a helpful assistant answering calls for Acme Inc." }' | jq -r .id) # 2. Provision a number. NUMBER=$(curl -s -X POST https://api.saperly.com/numbers \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "areaCode": "415" }') NUMBER_ID=$(echo "$NUMBER" | jq -r .id) # 3. Attach the brain to the number. curl -X POST "https://api.saperly.com/numbers/$NUMBER_ID/connection" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"connectionId\": \"$CONNECTION_ID\" }" ``` Those calls provision a real number, certify it, and bind 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 `fromNumberId` is the number's `id`; `body` is the message text. ```typescript const { data: message } = await messaging.send({ body: { fromNumberId: number!.id, to: '+15555550123', body: 'Hi from my agent 👋', }, }) ``` ```python from saperly.api.messaging import messaging_send from saperly.models import MessagingSendBody message = messaging_send.sync( client=client, body=MessagingSendBody( from_number_id=number.id, to="+15555550123", body="Hi from my agent 👋" ), ) ``` ```bash curl -X POST "https://api.saperly.com/messages" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"fromNumberId\": \"$NUMBER_ID\", \"to\": \"+15555550123\", \"body\": \"Hi from my agent 👋\" }" ``` SDK calls don't throw by default — each returns `{ data, error, request, response }`. Branch on `error` (a typed `{ code, message, ... }`) and read `data` on success. ```typescript const { data, error } = await messaging.send({ body: { /* … */ } }) if (error) { // handle the typed error, e.g. RecipientOptedOut, InsufficientFunds, … } ``` ## What just happened **You created a connection.** This is the brain. In `hosted` mode it's an in-network voice assistant driven by your `instructions`. (Want your own LLM to be the brain? See [manual mode](/docs/guides/manual-mode).) **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 the brain to the number** and **sent a message** through a compliant path — consent state and disclosures are tracked automatically. ## Next steps - [Core concepts](/docs/concepts) — the model behind numbers, 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 `sap_sk` keys, spend caps, and minting per-agent keys for fleets. - [API reference](/docs/api-reference) — every endpoint with a try-it playground. --- ## 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 an optional country, type, and area code (`POST /numbers`): ```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 `sap_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. ## The API Saperly exposes one REST API at `https://api.saperly.com`. Endpoints are camelCase (`/numbers`, `/connections`, `/messages`, `/calls`, `/consent`, `/usage`, `/pricing/quote`, `/api-tokens`), authenticated with a scoped `sap_sk` key as a bearer token. The workspace is always resolved from the token — never from client input — so a key only ever touches its own workspace's data. Reach for the API the way that fits: the [TypeScript SDK](/docs/sdks/node) (`@trysaperly/sdk` — `configure()` once, then `numbers.*` / `voice.*` / `messaging.*` resource handles that return `{ data, error }`), plain HTTPS from any language, or the [MCP server](/docs/sdks/mcp) for agent tool-calling. The [API reference](/docs/api-reference) documents every endpoint with a try-it playground. --- ## Getting your A2P registration approved Source: https://saperly.com/docs/guides/a2p-approval-guide Registrations are reviewed by humans at the messaging registry and the carriers, against a specific rubric. Declines cost time (each review round takes hours to days) and campaign fees are **not refunded** when a submission is declined — so it pays to get the first filing right. This guide is that rubric, distilled from carrier review guidelines and real review feedback. The three sections mirror the three steps on the [A2P / 10DLC page](/docs/guides/compliance#10dlc): brand → campaign → numbers. ## 1. Brand: your identity must match your tax records exactly Brand verification is an automated check of your legal identity against government records. The #1 failure: **the legal company name doesn't match the EIN character-for-character.** - Use the name exactly as it appears on your **IRS CP-575** (EIN confirmation letter) — including punctuation, "LLC" vs "L.L.C.", and abbreviations. Not your DBA, not your brand name — those go in the *display name* field. - The address should match your tax filings too. - Your **website must be live** and working at submission time — a broken link or "coming soon" page fails review. It should have an accessible **privacy policy**. - Public companies additionally need ticker, exchange, and a business contact email. - Sole proprietors (no EIN) verify by SMS one-time code to a real mobile number, are limited to **one phone number**, and face lower throughput. Sole-proprietor campaign registration isn't self-serve in the dashboard yet — contact support and we'll file it with you. If verification fails, fix the mismatch against your tax documents and resubmit — a failed brand resubmits **without paying the fee again**. ## 2. Campaign: what reviewers actually read ### The use-case description — be boringly specific Vague descriptions are the single most common decline. Reviewers want to know exactly **who** sends **what** to **whom** and **why they signed up**: > ❌ "Notifications for our users." > > ✅ "Acme Dental sends appointment reminders and rescheduling confirmations to > patients who booked an appointment and opted in to SMS reminders during > online booking at acmedental.com." One campaign = one coherent purpose. Don't mix marketing into a customer-care campaign — mixed traffic on a single registration is a top cause of both declines and post-approval suspensions. **Who is the sender?** If you're a platform whose *customers* message *their* end users, each customer generally needs their own brand and campaign. Register your own campaign only for messages *you* send first-party. ### The opt-in flow (message flow) — evidence, not assertion Reviewers verify **how** subscribers consent, and they want to *see* it. The message-flow field must describe every opt-in method, and for web forms reviewers expect a **publicly accessible URL** (or a public screenshot link if the form lives behind a login) showing: - A phone-number field, with an **SMS-specific consent checkbox** that is **unchecked by default**, **optional** (not required to submit), and **separate** from Terms & Conditions - The full disclosure next to it, containing all of: your brand name, the message types, "Message frequency may vary", "Message and data rates may apply", "Reply STOP to opt out, HELP for help", and "We will not share mobile information with third parties for promotional or marketing purposes" - Working links to your privacy policy and terms A form that pre-checks the box, buries SMS consent inside general T&C acceptance, or requires consent to submit will be declined. ### Sample messages — real, branded, with opt-out language - 2–5 samples that look like the **actual production traffic** - Every sample **names your brand** ("Acme: your order #123 shipped…") - Promotional samples include **"Reply STOP to unsubscribe"** - **No public link shorteners** (bit.ly, tinyurl, goo.gl) anywhere — an instant red flag. Use a full branded domain, and fill the *sample link* field so reviewers can see where it goes. - Fill the **privacy policy URL** and **messaging terms URL** fields — reviewers weight them, especially for marketing use cases. ### The auto-reply messages — use the expected shape Reviewers check the three keyword responses against a known structure. Safe templates (adapt the bracketed parts): - **Opt-in confirmation** (after START or your opt-in keyword): *"[Brand]: Thanks for subscribing to [use case]! Reply HELP for help. Message frequency may vary. Msg&data rates may apply. Consent is not a condition of purchase. Reply STOP to opt out."* - **Opt-out** (after STOP): *"[Brand]: You are unsubscribed and will receive no further messages."* - **Help** (after HELP): *"[Brand]: Please reach out to us at [support email / phone / site] for help."* — the contact must be real and reachable. Saperly answers STOP/HELP/START automatically on your behalf, but the *registered* wording is what reviewers read — keep it in this shape. ### Content that can't be registered (or needs special handling) Carrier rules prohibit or restrict **SHAFT** content: sex, hate, alcohol, firearms, tobacco/vaping — plus cannabis and (without proper licensing) gambling and lending. Age-gated content must be flagged as such. If your traffic touches these areas, talk to support before filing; a decline for prohibited content is hard to appeal. ## 3. After you submit - **Review is human and takes time**: registry intake is usually fast; the full carrier review runs from hours to several business days. Statuses advance automatically on the A2P page (with a daily reconciliation safety net) — Refresh gives you an on-demand pull. - **If declined**: the reviewer's reasons appear on the campaign card. Edit the campaign, address every reason (they re-check all of them), and resubmit. A resubmission is a new registration and is charged again — one more reason to over-prepare the first filing. - **After approval**: assign your numbers on the same page. Keep sending consistent with what you registered — traffic that doesn't match the registered use case is the top cause of post-approval suspension and carrier filtering. ## Pre-submission checklist - [ ] Legal name + EIN match the IRS CP-575 exactly - [ ] Website live; privacy policy reachable - [ ] Use-case description names sender, audience, content, and opt-in origin - [ ] Opt-in form: unchecked, optional, SMS-specific checkbox + full disclosure + policy links, publicly viewable (URL or screenshot link in the message flow) - [ ] 2–5 realistic samples, each naming the brand; promo samples carry STOP language - [ ] No link shorteners; sample link + policy/terms URLs filled - [ ] The three auto-replies follow the templates above - [ ] No SHAFT/restricted content (or support looped in first) - [ ] One purpose per campaign; sender is first-party ## See also - [Compliance](/docs/guides/compliance) — consent, disclosures, and the 10DLC status lifecycle - [Messaging](/docs/guides/messaging) — sending once your number is assigned --- ## 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 bearer credentials, prefixed **`sap_sk_live_…`**. The workspace, scopes, number allow-list, and spend cap all ride on the key itself — the workspace is always resolved from the token, never from client input. ## Scopes Each key carries a set of **scopes**. A request is allowed only if the key holds the scope the action requires. On the wire a key holds one or more of three coarse scopes: | Scope | Grants | | --- | --- | | `read` | list and read resources (numbers, connections, messages, calls, usage, consent) | | `write` | mutating actions — place calls, send SMS, provision/release numbers, record consent, write connections | | `admin` | everything `write` allows, plus minting, listing, and revoking child keys | ## 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. On the wire (the `POST /api-tokens` body) the grant is flat: ```ts { name: string // 1–100 chars scopes: ('read' | 'write' | 'admin')[] // at least one numberScope?: string[] | null // allow-list for number-scoped actions spendLimitCents?: number | null // hard spend cap, in cents spendLimitResetPeriod?: 'monthly' | null // cap window } ``` - **`scopes`** — the minimum is one scope; the key can do nothing outside them. - **`numberScope`** — an optional allow-list. When present, number-scoped actions are restricted to exactly those numbers. - **`spendLimitCents`** — a hard cap enforced **at reserve time by the [Ledger](/docs/guides/billing)**, so it holds even for in-flight spend. `spendLimitResetPeriod: '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 [`SpendLimitExceeded`](/docs/guides/errors-and-idempotency). ## Child keys: delegation with a ceiling A key with the **`admin`** scope (the key-administration capability, `keys:admin`) 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 an `admin` key can only ever narrow what it already holds — never escalate. Hand your `admin` key to your provisioning system, hand the scoped child keys it mints to individual agents. | Action | Endpoint | SDK | | --- | --- | --- | | Mint a child key | `POST /api-tokens` | `keys.mint(...)` | | List keys | `GET /api-tokens` | `keys.list()` | | Revoke a key | `POST /api-tokens/:id/revoke` | `keys.revoke(...)` | `POST /api-tokens` returns a fresh key bound to the grant you pass. The plaintext `token` is in the response — 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", "scopes": ["read", "write"], "numberScope": ["num_01H..."], "spendLimitCents": 5000, "spendLimitResetPeriod": "monthly" }' ``` ```typescript configure({ apiKey: process.env.SAPERLY_ADMIN_KEY! }) const { data, error } = await keys.mint({ body: { name: 'agent-42', scopes: ['read', 'write'], numberScope: ['num_01H...'], spendLimitCents: 5000, spendLimitResetPeriod: 'monthly', }, }) if (error) { // typed error body — e.g. AuthorizationDenied when the grant exceeds the ceiling console.error(error) } else { const token = data.token // sap_sk_live_… — present in the response, store it now } ``` 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 (scopes ⊄ parent, a number outside the parent's allow-list, or a higher spend cap), 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 an `admin`-scoped 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 their abilities from their org role, not from a key grant: | Role | Allows | | --- | --- | | `owner` / `admin` | everything — read, write, and key administration | | `member` | read + the everyday write actions (place calls, send SMS, read numbers/connections, record consent), but not key administration | A `member` deliberately cannot administer keys; key administration is reserved for `owner` / `admin` (or a `keys:admin`-equivalent `admin`-scoped key). ## 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. --- ## 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 sap_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 carrier-reported call duration when the call ends, 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 **spend cap**, enforced **at reserve time by the Ledger**, so the cap holds even mid-action. On the wire (the `POST /api-tokens` body) it is two flat fields: ```ts spendLimitCents?: number | null // hard cap, in cents spendLimitResetPeriod?: 'monthly' | null // cap window ``` - `spendLimitResetPeriod: 'monthly'` resets the counter at the **UTC month boundary**. - `spendLimitResetPeriod: 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 with `SpendLimitExceeded` (402). See [Authentication](/docs/guides/authentication) for how an `admin`-scoped key attaches a spend cap 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` — both `country` and `numberType` are required: ```bash curl "https://api.saperly.com/pricing/quote?country=US&numberType=local" \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) const { data, error } = await pricing.quote({ query: { country: 'US', numberType: 'local' }, }) if (error) { // typed error body — handle it } else { console.log(data.customerMonthlyCents, data.customerUpfrontCents) } ``` 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 your balance Your current prepaid balance rides on the **usage summary** (`GET /usage`), alongside call and message totals. All amounts are integer cents. ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) const { data, error } = await usage.summary() if (!error) { console.log('balance (cents):', data.balanceCents) console.log('calls:', data.calls.count, 'cost:', data.calls.totalCostCents) console.log('messages:', data.messages.count) } ``` The API surfaces your live **balance** (on `GET /usage`) and the metered actions that draw against it. The full ledger transaction history — every `reserve` / `settle` / `release` / `topup` / `charge` / `adjust` — is shown in the dashboard. ## Out-of-funds errors When a reserve can't be covered, the action fails with a typed error (status `402`) — handle it by topping up (or by raising the key's cap): | Condition | Error | Status | Payload | | --- | --- | --- | --- | | Balance too low | `InsufficientFunds` | 402 | `balanceCents`, `requestedCents` | | Scoped key's spend cap hit | `SpendLimitExceeded` | 402 | `limitCents`, `priorSpendCents`, `requestedCents` | The SDK never throws by default — every method returns `{ data, error }`. Branch on `error` and inspect its `_tag` to react: ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) const { data, error } = await voice.place({ body: { fromNumberId: 'num_...', to: '+15555550123' }, }) if (error) { if (error._tag === 'InsufficientFunds') { // Out of funds (402). Prompt a top-up, then retry. console.error( `Balance too low: ${error.balanceCents}¢ < ${error.requestedCents}¢ — top up to continue.`, ) } else if (error._tag === 'SpendLimitExceeded') { // This key is tapped out until its cap window resets. console.error(`Spend cap hit: cap ${error.limitCents}¢.`) } } ``` See [Errors & idempotency](/docs/guides/errors-and-idempotency) for the full error model. ## Related - [Authentication](/docs/guides/authentication) — the spend cap 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, not prompt instructions. Consent and disclosures are *enforced independently of any LLM* — a misbehaving model (or one you swap out) can never route around them, and outbound voice or SMS to a peer without recorded consent is rejected before it goes out. **10DLC** is the carrier-side registration Saperly files and tracks on your behalf; carriers, not Saperly, police delivery on unregistered numbers. Base URL `https://api.saperly.com`. Authenticate with `Authorization: Bearer sap_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[]` | `read` | | `POST` | `/consent` | `ConsentRecord` (201) | `write` | | `POST` | `/consent/revoke` | `ConsentRecord` | `write` | | `GET` | `/consent/check?numberId=&peerNumber=` | `{ hasConsent, type? }` | `read` | ### 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 configure({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. Check (query params) const { data: checked } = await consent.check({ query: { numberId: 'num_...', peerNumber: '+15555550123' }, }) // 2. Record on opt-in if (checked && !checked.hasConsent) { await consent.record({ body: { numberId: 'num_...', peerNumber: '+15555550123', consentType: 'explicit_outbound', source: 'sms_optin', }, }) } // 3. Now send — see Messaging const { error } = await messaging.send({ body: { fromNumberId: 'num_...', to: '+15555550123', body: 'Thanks for opting in!', }, }) if (error) { // e.g. error._tag === 'RecipientOptedOut' (403) if the peer opted out } ``` Initiating an outbound [call](/docs/guides/voice) or [SMS](/docs/guides/messaging) to a peer who has opted out is a TCPA violation — Saperly blocks it for you and the request fails with the typed error `RecipientOptedOut` (403). Record `explicit_outbound` consent before you initiate contact, or rely on `implied_inbound` consent created when the peer contacts you first. ### 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 configure({ apiKey: process.env.SAPERLY_API_KEY! }) // The disclosure is a field on the connection — set it when you create or update one await connections.create({ body: { 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 application-to-person (A2P) SMS. Registering is what lets your US SMS deliver reliably instead of being filtered, and it happens in three steps on the dashboard's **A2P / 10DLC** page: 1. **Register a brand** — the legal entity behind your messaging, verified by the registry (one-time registration fee, charged from your balance). 2. **Register campaigns** under a verified brand — what you send and how subscribers consent (a monthly fee per campaign, quoted before you submit; the first month is charged at submission and fees continue during carrier review). 3. **Assign numbers** to an approved campaign — one registration per number; a number carries compliant A2P traffic only while assigned. Saperly tracks every registration and advances its status automatically as registry/carrier decisions arrive. ### Brand status | Status | Meaning | | --- | --- | | `draft` | Editable locally; nothing filed, nothing charged. | | `submitted` | Filed with the registry, identity verification in progress. | | `submitting` | Submission in flight — resolves automatically (refresh if it looks stuck). | | `verified` | Identity verified — campaigns can be registered. | | `vetted` | Verified with enhanced vetting (higher carrier throughput). | | `failed` | Verification failed — fix the details and resubmit (no second fee). | ### Campaign status | Status | Meaning | | --- | --- | | `draft` | Editable locally; nothing filed, nothing charged. | | `submitted` / `pending_review` | Filed; registry + carrier review in progress. | | `submitting` | Submission in flight — resolves automatically. | | `approved` | Certified — assign numbers and send A2P SMS. | | `declined` | The review declined the campaign — edit and resubmit (a resubmission is a new registration and is charged again). | | `suspended` | A previously approved campaign was suspended by a carrier. Fees still accrue until you deactivate. | | `deactivated` | You retired the campaign — no further fees. | ### Number assignment status | Status | Meaning | | --- | --- | | `pending` | Assignment filed with the carrier network. | | `assigned` | The number is cleared to send under the campaign. | | `failed` | The assignment failed — the reason is shown; fix and retry. | Reviewers check registrations against a specific rubric — [Getting your A2P registration approved](/docs/guides/a2p-approval-guide) walks every requirement (opt-in evidence, message templates, brand-vetting pitfalls) so your first submission passes. Declined campaign fees are not refunded. Manage brands, campaigns, and number assignments on the dashboard's **A2P / 10DLC** page. Statuses advance automatically as the registry and carriers decide (with a daily reconciliation safety net) — use the page's Refresh actions for an on-demand check. ## 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, an in-network voice, 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 an in-network voice, 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 sap_sk_live_...`; the workspace is resolved from the token. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `GET` | `/connections` | `Connection[]` | `read` | | `GET` | `/connections/:id` | `Connection` | `read` | | `POST` | `/connections` | `Connection` (201) | `write` | | `PATCH` | `/connections/:id` | `Connection` | `write` | | `DELETE` | `/connections/:id` | `{ status: "deleted" }` | `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 backend; currently only 'network' "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", // in-network managed model "model": "openai/gpt-4o" }, "tts": { "voiceId": "..." }, // the in-network voice id; null ⇒ managed default "language": "en", // spoken language (BCP-47); see Languages "mcpServers": [ { "url": "https://mcp.example.com", "auth": { "type": "bearer", "token": "..." }, // or { "type": "none" } "allowedTools": ["lookup_order"] } ], "createdAt": "2026-06-18T..." } ``` `llm`, `tts`, and `mcpServers` are each nullable — omit them and the connection uses managed defaults. A read also returns `recordingEnabled` (per-line call recording, on by default), `callControl` (end-call / DTMF / transfer enablement; `null` ⇒ defaults), and — for `manual` connections — `manualSecret` and `manualWebhookUrl`. ### Key fields | Field | Meaning | | --- | --- | | `backend` | The hosted brain backend. Today the only available value is `network` — an in-network voice assistant. (The `openai_realtime` backend is currently disabled: selecting it on create/update is rejected with `UnsupportedModel` (422).) | | `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.model` | The in-network managed model the hosted assistant runs (e.g. `openai/gpt-4o`). `llm.kind` is always `managed`. | | `tts.voiceId` | The in-network voice the hosted assistant speaks with. Discover ids from `GET /voices`. See [Voices](#voices). | | `language` | The spoken language as a [BCP-47](https://en.wikipedia.org/wiki/IETF_language_tag) tag (e.g. `en`, `es`, `en-US`). Works in both modes (hosted: the transcription hint; manual: the Conversation Relay language). See [Languages](#languages). | | `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; currently only 'network' ('openai_realtime' is disabled) "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; the voice — see Voices "language": "en", // optional; spoken language (BCP-47) — see Languages "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 and a TCPA disclosure. Omit `tts` and the line uses the managed default voice. **Voices:** omit `tts` and the line uses the managed default voice, or set `tts.voiceId` to a voice id discovered from `GET /voices` (see [Voices](#voices) below). `tts.provider` is **optional** — it defaults to the in-network provider — so `{ "tts": { "voiceId": "" } }` is enough. ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) const { data: connection } = await connections.create({ body: { 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.', }, }) console.log(connection!.id) // → conn_... ``` ```python import os import httpx connection = httpx.post( "https://api.saperly.com/connections", headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}, json={ "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.", }, ).json() print(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." }' ``` ## 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 numbers.assignConnection({ path: { id: numberId }, body: { connectionId: connection!.id }, }) ``` ```python httpx.post( f"https://api.saperly.com/numbers/{number_id}/connection", headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}, json={"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). ## Voices A **hosted** connection speaks with an in-network text-to-speech voice. Discover the available voices with `GET /voices`, then pass the chosen `id` as the connection's `tts.voiceId`. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `GET` | `/voices` | `Voice[]` | `read` | Each voice is `{ id, name, gender, language }`. Pass an optional `?language=` query (a case-insensitive prefix, e.g. `?language=en` for every English variant) to narrow the list. The `id` is an opaque, stable voice identifier — pass it straight back as `tts.voiceId`. `tts.provider` is optional and defaults to the in-network provider; you normally only need `voiceId`. ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. Discover voices (optionally filter by language). const { data: list } = await voices.list({ query: { language: 'en' } }) const voiceId = list![0]!.id // 2. Use the chosen voice on a connection — `provider` is optional. await connections.create({ body: { name: 'support line', mode: 'hosted', tts: { voiceId }, }, }) ``` ```python import os import httpx auth = {"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"} # 1. Discover voices (optionally filter by language). voices = httpx.get( "https://api.saperly.com/voices", headers=auth, params={"language": "en"}, ).json() voice_id = voices[0]["id"] # 2. Use the chosen voice on a connection — provider is optional. httpx.post( "https://api.saperly.com/connections", headers=auth, json={"name": "support line", "mode": "hosted", "tts": {"voiceId": voice_id}}, ) ``` ```bash # 1. Discover voices (optionally filter by ?language=en). curl "https://api.saperly.com/voices?language=en" \ -H "Authorization: Bearer $SAPERLY_API_KEY" # 2. Use the chosen voice on a connection — provider is optional. 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", "tts": { "voiceId": "" } }' ``` ## Languages A connection can converse in a language other than English — in **both** hosted and manual mode. Set the connection's `language` field to a [BCP-47](https://en.wikipedia.org/wiki/IETF_language_tag) tag — `en`, `es`, `fr`, or a regional variant like `en-US` / `es-419`. What it drives depends on the mode: - **Hosted** — `language` is the in-network **transcription hint**: it tells the assistant which language the caller speaks. Pair it with a voice that speaks the same language (see [Voices](#voices)), and write your `instructions` so the model replies in that language. - **Manual** — `language` is the Conversation Relay language: it steers the in-network transcription **and** the spoken voice. Your brain decides what to say; this sets the language it's transcribed from and spoken in. | Field | Type | Notes | | --- | --- | --- | | `language` | `string` | BCP-47 tag, e.g. `en`, `es`, `en-US`. Optional — send `""` (or omit) to let the carrier auto-detect. Any well-formed tag is accepted; an unsupported one falls back to a multilingual engine rather than failing. | ### Discover the supported languages `GET /languages` returns the curated set of supported languages, each `{ code, name }`, ordered most-supported first. `code` is the BCP-47 tag to pass as a connection's `language`; every language listed has at least one voice that can speak it. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `GET` | `/languages` | `Language[]` | `read` | ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. See what's supported: [{ code: 'en', name: 'English' }, { code: 'es', name: 'Spanish' }, …] const { data: langs } = await languages.list() // 2. Pick a voice that speaks Spanish (hosted lines pair language + voice). const { data: list } = await voices.list({ query: { language: 'es' } }) // 3. Create a Spanish hosted line. await connections.create({ body: { name: 'línea de soporte', mode: 'hosted', language: 'es', // transcribe the caller in Spanish… tts: { voiceId: list![0]!.id }, // …and speak with a Spanish voice instructions: 'Responde siempre en español.', }, }) ``` ```python import os import httpx auth = {"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"} # 1. See what's supported: [{"code": "en", "name": "English"}, …] languages = httpx.get("https://api.saperly.com/languages", headers=auth).json() # 2. Pick a Spanish voice. voices = httpx.get( "https://api.saperly.com/voices", headers=auth, params={"language": "es"} ).json() # 3. Create a Spanish hosted line. httpx.post( "https://api.saperly.com/connections", headers=auth, json={ "name": "línea de soporte", "mode": "hosted", "language": "es", "tts": {"voiceId": voices[0]["id"]}, "instructions": "Responde siempre en español.", }, ) ``` ```bash # 1. List supported languages. curl "https://api.saperly.com/languages" \ -H "Authorization: Bearer $SAPERLY_API_KEY" # 2. Create a Spanish hosted line (pair the language with a Spanish voice from /voices). curl -X POST https://api.saperly.com/connections \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "linea de soporte", "mode": "hosted", "language": "es", "tts": { "voiceId": "" } }' ``` Both the voice and the language are properties of the **connection**, not of an individual call. `POST /calls` only overrides `instructions` per call — to change the language or voice, `PATCH` the connection (or use a different connection via `connectionId`). ## Calling your own tools (MCP) A **hosted** connection can call **your** tools mid-conversation — to look up an order, check availability, book a slot — by pointing its assistant at one or more [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers you host. You declare them with the `mcpServers` field; the in-network assistant decides when to call a tool and weaves the result into the conversation. Audio never leaves the carrier — the assistant calls your server over plain HTTPS, just like any other backend request. This is **hosted-only**. In manual mode *you* are the brain, so you call your own tools directly — there's no `mcpServers` field to set. ### What your server must be - A **remote, streamable-HTTP MCP server** reachable at a public `https://` URL. This is the modern single-endpoint MCP transport (one URL handles the JSON-RPC POSTs). **Legacy SSE-only servers are not supported** — if your framework offers both, expose the streamable-HTTP endpoint (commonly `/mcp`). - It must implement the standard MCP handshake plus `tools/list` and `tools/call`. Any compliant MCP SDK (TypeScript, Python, etc.) gives you this out of the box. - **Auth (optional):** `auth: { type: "bearer", token: "..." }` sends `Authorization: Bearer ` on every request to your server (the token is stored encrypted and never returned in API reads). Use `{ "type": "none" }` (or omit `auth`) for an open server. - **`allowedTools` (optional):** an allow-list of tool names to expose to the assistant. Omit it to expose every tool your server advertises. ### Add MCP servers via the API Pass `mcpServers` when you create (or `PATCH`) a hosted connection. Each entry is `{ url, auth?, allowedTools? }`: ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) const { data: connection } = await connections.create({ body: { name: 'support line', mode: 'hosted', instructions: 'You are a support agent for Acme. Use the lookup_order tool when a caller asks about an order.', mcpServers: [ { url: 'https://mcp.acme.com/mcp', auth: { type: 'bearer', token: process.env.ACME_MCP_TOKEN! }, allowedTools: ['lookup_order', 'check_availability'], }, ], }, }) console.log(connection!.id) // → conn_... ``` ```python import os import httpx connection = httpx.post( "https://api.saperly.com/connections", headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}, json={ "name": "support line", "mode": "hosted", "instructions": "You are a support agent for Acme. Use the lookup_order tool when a caller asks about an order.", "mcpServers": [ { "url": "https://mcp.acme.com/mcp", "auth": {"type": "bearer", "token": os.environ["ACME_MCP_TOKEN"]}, "allowedTools": ["lookup_order", "check_availability"], } ], }, ).json() print(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 support agent for Acme. Use the lookup_order tool when a caller asks about an order.", "mcpServers": [ { "url": "https://mcp.acme.com/mcp", "auth": { "type": "bearer", "token": "'"$ACME_MCP_TOKEN"'" }, "allowedTools": ["lookup_order", "check_availability"] } ] }' ``` To update the servers on an existing connection, send the **full** `mcpServers` array in a `PATCH /connections/:id` — it replaces the stored list (drop a server by omitting it; clear them all with `[]`). ### Add MCP servers via the dashboard 1. Open **Connections** and click the hosted connection you want to edit. 2. In the agent config, find the **MCP servers** section and click **Add server**. 3. Paste the server **URL** (your streamable-HTTP endpoint, e.g. `https://mcp.acme.com/mcp`) and, if your server requires auth, a **Bearer token**. 4. **Save.** Saving re-syncs the assistant and registers the tools. The assistant only calls a tool when its `instructions` give it a reason to. Mention the tools and when to use them in the system prompt — e.g. "Use `lookup_order` whenever the caller references an order number." Otherwise the model may never reach for them. ### When do the tools take effect? Your servers are registered to the assistant on **sync**, which runs automatically every time you create or update the connection (via the API, the SDK, or the dashboard **Save**). New calls to a number bound to that connection can use the tools immediately; a call already in progress keeps the configuration it started with. ### Troubleshooting - **The agent never calls my tool.** The model decides when to call a tool — make sure your `instructions` describe the tool and when to use it, and that the tool isn't filtered out by `allowedTools`. - **Nothing happens after I add a server.** Confirm the URL is a **streamable-HTTP** MCP endpoint (not SSE-only) reachable over public HTTPS, and that it answers `tools/list`. An unreachable or SSE-only server registers but exposes no tools. - **I changed a token or `allowedTools` but the old behavior persists.** Editing those values **for the same URL** is not pushed in place. Remove the server and re-add it (a new entry, or delete + save then add + save) to apply the change. - **A tool errors at call time.** The assistant surfaces tool failures back into the conversation. Check your server's logs and that the bearer token is valid — Saperly forwards it as `Authorization: Bearer ` on every request. ## 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 sap_sk_live_...`; the workspace is resolved from the token. ## The error shape Every error is a **tagged error** — a JSON body carrying a `_tag` discriminant plus its own fields — returned with the matching HTTP status (e.g. the tag `RateLimited` at `429`). The SDK surfaces it as the `error` half of the `{ data, error }` result, so you branch on `error._tag`: ```jsonc // e.g. a 402 SpendLimitExceeded body { "_tag": "SpendLimitExceeded", "limitCents": 5000, "priorSpendCents": 4800, "requestedCents": 300 } ``` ## Common errors | Tag | Status | Notes | | --- | --- | --- | | `Unauthorized` | 401 | No bearer token on the request (field `message`). | | `AuthorizationDenied` | 403 | A bearer was sent but it's unrecognized, revoked, lacks the required scope, or targets a number outside the key's allow-list (field `reason`). | | `NumberNotFound` / `ConnectionNotFound` / `CallNotFound` | 404 | No such resource (field carries the id). | | `RecipientOptedOut` | 403 | Outbound to a peer who opted out (field `to`). | | `InsufficientFunds` | 402 | Balance too low (fields `balanceCents`, `requestedCents`). | | `SpendLimitExceeded` | 402 | Scoped key's spend cap hit (fields `limitCents`, `priorSpendCents`, `requestedCents`). | | `RateLimited` | 429 | Too many requests (field `bucket` — which limit you hit). | | `IdempotencyConflict` | 409 | A key collision (an in-flight or reused key). | | `IdempotencyKeyMismatch` | 422 | Same key, different body. | | `UpstreamError` | 502 | A carrier/upstream failure (fields `status`, `code`, `message`). | Out-of-funds and spend-cap errors are covered in depth in [Billing](/docs/guides/billing); consent / opt-out 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); a retry while the first request is still in flight gets `IdempotencyConflict` (409). | Situation | Result | | --- | --- | | Same key, same body | Returns the original result — the action runs once. | | Same key, different body | `IdempotencyKeyMismatch` (422). | | Same key, request still in flight (or a fresh replay before it settled) | `IdempotencyConflict` (409) — retry after it settles. | The SDK does **not** invent an `Idempotency-Key` for you — pass your own UUID v4 when you need a write to be safe to retry. Reuse that exact value on every retry of the same logical request (e.g. across a job that may be re-run after a crash), so both attempts share the same key and the action still runs once. ### Sending an idempotency key Pass the standard `Idempotency-Key` header. With the SDK, attach it per call via `headers`: ```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 configure({ apiKey: process.env.SAPERLY_API_KEY! }) const key = randomUUID() // reuse this exact value on every retry const { data, error } = await voice.place({ body: { fromNumberId: 'num_...', to: '+15555550123' }, headers: { 'Idempotency-Key': key }, }) ``` ## Handling typed errors Every method returns `{ data, error }` and does **not throw** by default. Branch on `error` (it is `undefined` on success) and switch on `error._tag` — pause on a spend cap, back off on a rate limit, surface the rest: ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) async function place() { const key = randomUUID() const attempt = async () => voice.place({ body: { fromNumberId: 'num_...', to: '+15555550123' }, headers: { 'Idempotency-Key': key }, // SAME key on every retry }) let { data, error } = await attempt() if (error?._tag === 'SpendLimitExceeded') { // 402 — this key is tapped out until its cap window resets. console.error(`Cap hit: cap ${error.limitCents}¢, requested ${error.requestedCents}¢.`) return null } if (error?._tag === 'RateLimited') { // 429 — back off and retry with the SAME Idempotency-Key. await new Promise((r) => setTimeout(r, 1000)) ;({ data, error } = await attempt()) } if (error) throw new Error(`unexpected: ${error._tag}`) return data } ``` Pass `{ throwOnError: true }` to a method to make it **throw** the error instead of returning it in `error` — handy when you'd rather wrap calls in a `try/catch`. The default (`{ data, error }`, nothing thrown) keeps error handling explicit. 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) — `RecipientOptedOut` and how consent is 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. The `@trysaperly/sdk/agent` framework turns that wire protocol into typed per-event handlers — it owns signature verification, parsing, dispatch, the fail-safe contract, and the HTTP envelope, so you write only the brain. It depends on Web Crypto + plain TS (no heavy deps), so the same handler mounts on any Web-`Request` runtime (Deno, Bun, Next.js route handlers, and edge runtimes): ```typescript export default { fetch: agentBrain({ secret: process.env.SAPERLY_MANUAL_SECRET!, // the connection's manualSecret onInboundCall: ({ event }) => `Hi, you've reached ${event.to}. How can I help?`, onTurn: ({ event }) => `You said: ${event.userText}`, onCallEnded: () => {}, // terminal — the directive is ignored }), } ``` A handler returns a directive, a `string` (coerced to `speak`), or nothing (a safe default keeps the call alive). Point `manualWebhookUrl` at where you mount it. 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": "01923456-789a-7bcd-8ef0-123456789abc", "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 sap_sk_live_...`; the workspace is resolved from the token. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `POST` | `/messages` | `Message` (201) | `write` | | `GET` | `/messages` | `Message[]` | `read` | `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 configure({ apiKey: process.env.SAPERLY_API_KEY! }) const { data: message, error } = await messaging.send({ body: { fromNumberId: 'num_...', to: '+15555550123', body: 'Your code is 123456.', }, }) if (error) { // typed error — e.g. RecipientOptedOut, InsufficientFunds, MessageSendFailed } else { console.log(message.id) } ``` ```python import os import httpx message = httpx.post( "https://api.saperly.com/messages", headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}, json={ "fromNumberId": "num_...", "to": "+15555550123", "body": "Your code is 123456.", }, ).json() print(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 { data: messages } = await messaging.list({ query: { numberId: 'num_...' } }) ``` ```python messages = httpx.get( "https://api.saperly.com/messages", headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}, params={"numberId": "num_..."}, ).json() ``` ```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. ## 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. You provision numbers, list and read them, assign a connection, and release them when you're done. `POST /numbers/:id/webhook` is **not** an event-notification webhook — it sets a legacy per-number *manual-mode brain* URL (a fallback for a connection's `manualWebhookUrl`). To receive `call.received` / `call.completed` / `call.failed` (a call that never connected: `status` is `no_answer` or `failed`, cost is zero) / `message.received` / `call.recording.saved` events, register a **workspace webhook endpoint** (`POST /workspaces/:slug/webhooks`), which returns a signing secret you verify each delivery with. There is no single "line" object. A **number** is the phone number; a **connection** is the brain that answers it. You provision the number, create the connection, then attach one to the other. The same connection can answer many numbers — see [Connections](/docs/guides/connections). ## 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 | `read` | `PhoneNumber[]` | | `GET /numbers/:id` | get | `read` | `PhoneNumber` | | `POST /numbers` | provision | `write` | `PhoneNumber` (201) | | `POST /numbers/:id/release` | release | `write` | `{ status: "released" }` | | `POST /numbers/:id/connection` | assign connection | `write` | `PhoneNumber` | | `POST /numbers/:id/webhook` | set manual-mode brain URL (not events) | `write` | `PhoneNumber` | | `POST /numbers/:id/sms-sender` | set SMS sender id | `write` | `PhoneNumber` | | `POST /numbers/:id/caller-id` | set caller ID name | `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, and later release it. (Step 4 shows the optional, legacy per-number manual-mode brain URL — not event delivery; use a workspace webhook endpoint for events.) ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. Quote the price const { data: quote } = await pricing.quote({ query: { country: 'US', numberType: 'local' }, }) // → { customerMonthlyCents, customerUpfrontCents } // 2. Provision a number (reserves funds + starts metering rent) const { data: number } = await numbers.provision({ body: { country: 'US', numberType: 'local', areaCode: '415' }, }) // 3. Bind a connection (its handler) await numbers.assignConnection({ path: { id: number!.id }, body: { connectionId: 'conn_…' }, }) // 4. (Optional, legacy) Set the per-number manual-mode brain URL — NOT event delivery await numbers.setWebhook({ path: { id: number!.id }, body: { url: 'https://example.com/hooks/saperly' }, }) // 5. Later — release it (sets releasedAt, stops the rent) await numbers.release({ path: { id: number!.id } }) ``` ```python import os import httpx BASE = "https://api.saperly.com" headers = { "Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}", "Content-Type": "application/json", } with httpx.Client(base_url=BASE, headers=headers) as http: # 1. Quote the price quote = http.get( "/pricing/quote", params={"country": "US", "numberType": "local"} ).json() # → { "customerMonthlyCents": ..., "customerUpfrontCents": ... } # 2. Provision a number (reserves funds + starts metering rent) number = http.post( "/numbers", json={"country": "US", "numberType": "local", "areaCode": "415"}, ).json() # 3. Bind a connection (its handler) http.post( f"/numbers/{number['id']}/connection", json={"connectionId": "conn_…"}, ) # 4. (Optional, legacy) Set the per-number manual-mode brain URL — NOT event delivery http.post( f"/numbers/{number['id']}/webhook", json={"url": "https://example.com/hooks/saperly"}, ) # 5. Later — release it (sets releasedAt, stops the rent) http.post(f"/numbers/{number['id']}/release") ``` ```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. (Optional, legacy) Set the per-number manual-mode brain URL — NOT event delivery 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 sap_sk_live_...`; the workspace is resolved from the token. | Method | Path | Returns | Scope | | --- | --- | --- | --- | | `POST` | `/calls` | `Call` (201) | `write` | | `POST` | `/calls/:id/end` | `Call` | `write` | | `POST` | `/calls/:id/transfer` | `Call` | `write` | | `GET` | `/calls/:id` | `Call` | `read` | | `GET` | `/calls` | `Call[]` | `read` | 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 configure({ apiKey: process.env.SAPERLY_API_KEY! }) const { data: call, error } = await voice.place({ body: { fromNumberId: 'num_...', to: '+15555550123', instructions: 'You are calling to confirm a delivery window. Keep it under a minute.', }, }) if (error) { // typed error — e.g. CallStartFailed, InsufficientFunds, RecipientOptedOut } else { console.log(call.id) } ``` ```python import os import httpx call = httpx.post( "https://api.saperly.com/calls", headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}, json={ "fromNumberId": "num_...", "to": "+15555550123", "instructions": "You are calling to confirm a delivery window. Keep it under a minute.", }, ).json() print(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 `write` 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` ends a live call — it issues the carrier hangup. It takes **no body**: billing is settled authoritatively from the carrier's hangup event (which carries the **real** duration), never a value you supply. It returns the call (still live); the carrier event finalizes its status and cost moments later. ```bash curl -X POST https://api.saperly.com/calls/$CALL_ID/end \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ## Get and list calls ```typescript const { data: call } = await voice.get({ path: { id: callId } }) const { data: calls } = await voice.list() ``` ```python auth = {"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"} call = httpx.get(f"https://api.saperly.com/calls/{call_id}", headers=auth).json() calls = httpx.get("https://api.saperly.com/calls", headers=auth).json() ``` ```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" ``` A call's `status` is `initiated` (placed or ringing), `completed` (connected, then ended — the only status that bills), `no_answer` (nobody picked up), `failed` (it could not be connected, or was declined before answer), or `rejected` (refused up front, e.g. insufficient balance). Every call reaches a terminal status even if the network never reports it back — Saperly finalizes such a call itself (`failed`, zero cost) within hours, a day at most. ## 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 **carrier-reported** duration and releases the remainder. A call that never connects (`no_answer` / `failed`) settles at zero, so the whole reservation is released. 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). ## 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 `@trysaperly/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 `@trysaperly/voice-openclaw`): ```json5 { plugins: { enabled: true, allow: ["saperly-voice"], entries: { "saperly-voice": { enabled: true, config: { baseUrl: "https://api.saperly.com", connectionId: "01923456-789a-7bcd-8ef0-123456789abc", // 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. `@trysaperly/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 sap_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' }), }) ``` A workspace-level **default webhook** (applied to numbers without a per-number override) plus delivery inspection, stats, and test sends are managed in the dashboard under **Settings → Webhooks**. ## Events Every delivery body is `{ deliveryId, eventType, payload }`. The call events: | Event | When | Payload | | --- | --- | --- | | `call.received` | An inbound call reached one of your numbers | `{ callId, connectionId, from, to }` | | `call.completed` | A call **connected** and then ended | `{ callId, status: "completed", durationSec, costCents, from, to, hangupCause? }` | | `call.failed` | A call **never connected** — the callee didn't answer, the carrier couldn't place it, or it was declined before answer | `{ callId, status: "no_answer" \| "failed", durationSec: 0, costCents: 0, from, to, hangupCause? }` | | `call.recording.saved` | A call's recording is ready to fetch | `{ callId, … }` | | `message.received` | An inbound SMS reached one of your numbers | `{ messageId, numberId, to, from, body }` | Exactly **one** terminal event (`call.completed` or `call.failed`) is delivered per call, by whichever path finalizes it first — the carrier's hangup, or Saperly's own safety net for calls the carrier never reported back (those arrive hours after placement, a day at most). Only `completed` calls bill; `costCents` on a `call.failed` is always `0`. A call refused before it was even attempted (insufficient balance) produces no terminal event — the API returned the refusal synchronously. 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}.${rawBody}` ``` where `timestamp` is the `x-saperly-timestamp` header value 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 outside the tolerance window (a replay), and dedup on `x-saperly-delivery-id` to drop any re-delivery. ### Verifying with the SDK The Node SDK ships `verifyWebhook(rawBody, secret, headers, options?)`. It is **async** (it uses Web Crypto) and resolves `{ valid: boolean, reason?: string, deliveryId?: string, eventType?: string }` — `deliveryId` and `eventType` are present on success, so you can dedup straight off the result. 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' }), async (req, res) => { const rawBody = req.body.toString('utf8') // a Buffer of the exact bytes const result = await verifyWebhook( rawBody, process.env.SAPERLY_WEBHOOK_SECRET!, req.headers, ) if (!result.valid) { // reason explains why: signature_mismatch, stale_timestamp, … return res.status(400).send(`invalid webhook: ${result.reason}`) } // Dedup on result.deliveryId (== the x-saperly-delivery-id header) for at // least the tolerance window before trusting the event. const event = JSON.parse(rawBody) // … handle the verified event … res.sendStatus(200) }, ) ``` On a Web-`Request` runtime (Workers, Deno, Bun, Next.js route handlers), read the raw body with `await req.text()` and pass `req.headers` straight through: ```typescript const raw = await req.text() // the EXACT bytes, not a re-serialized object const result = await verifyWebhook(raw, process.env.SAPERLY_WEBHOOK_SECRET!, req.headers) if (!result.valid) return new Response(`invalid: ${result.reason}`, { status: 400 }) // dedup result.deliveryId, then JSON.parse(raw) and handle … ``` 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 (JSON-RPC 2.0) and authenticates with either a bearer `sk_` key or an MCP **OAuth** access token. Any MCP-capable client (Claude, Cursor, or your own) can connect. ## Endpoint | | | | --- | --- | | **URL** | `https://api.saperly.com/mcp` | | **Transport** | Streamable HTTP (JSON-RPC 2.0) | | **Auth** | `Authorization: Bearer ` (or an MCP OAuth access token) | The key behaves exactly as it does for the REST API: scopes, the number 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. ## 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 sap_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 v2 client (`@trysaperly/sdk`). - [Python](/docs/sdks/python) — call the v2 REST API directly with `httpx`. - [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 `@trysaperly/sdk` is the official TypeScript client for the Saperly **v2** API. It is **typed end-to-end**, autogenerated from the OpenAPI contract, and runs on **Node.js 18+** (anywhere `fetch` is global), plus Bun, Deno, and edge runtimes. `configure({ apiKey })` once and lowercase resource handles — `numbers`, `connections`, `messaging`, `voice`, and friends — give your agent a phone number, SMS, outbound calling, consent + compliance, and signed webhook verification. The SDK targets the camelCase **v2 API** directly. For the raw HTTP surface, see the [API reference](/docs/api-reference). ## Install ```bash npm install @trysaperly/sdk ``` The package ships ESM + types. Get an API key from the [dashboard](https://saperly.com/sign-in) → **Keys** (`sap_sk_live_…`). ## Quickstart The SDK is **not a class**. Call `configure()` once to point a shared client at your workspace key, then call the resource handles. Stand up a hosted line: create a connection (the brain), provision a number, and attach the two. ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. The connection is the handler — a hosted, in-network voice assistant. const { data: connection, error: connErr } = await connections.create({ body: { name: 'support', mode: 'hosted', instructions: 'You are a helpful assistant answering calls for Acme Inc.', }, }) if (connErr) throw new Error(connErr._tag) // 2. Provision a number (here, in the 415 area code). const { data: number } = await numbers.provision({ body: { areaCode: '415' } }) console.log(number!.phoneNumber) // → +1... // 3. Bind the connection to the number. await numbers.assignConnection({ path: { id: number!.id }, body: { connectionId: connection!.id }, }) ``` Every method returns `{ data, error, request, response }` — `data` on success, `error` (the typed error body) on a non-2xx status. **Nothing throws by default**; see [Error handling](#error-handling). New to the platform? Walk through the [Quickstart](/docs/quickstart) first. ## Authentication `configure()` sets a shared client that sends `Authorization: Bearer ` on every request: ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) ``` `configure(config)` accepts: | Option | Type | Notes | | --- | --- | --- | | `apiKey` | `string` | Bearer credential (`sap_sk_live_…`). Required. | | `baseUrl` | `string` | Override the API origin (e.g. for staging). Defaults to the production host. | | `retries` | `number` | Retries for **idempotent** requests (GET/HEAD/OPTIONS/DELETE) on `5xx` + network errors. Default `1`; set `0` to disable. **POST/PATCH are never retried.** | Saperly uses one tier of scoped `sk_` API keys. 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. See [Authentication](/docs/guides/authentication) for the full model, scopes, and spend caps. ### Multiple keys `configure()` sets one shared client. To talk to more than one workspace from a single process, create isolated clients with `createSaperlyClient()` and pass one per call via `{ client }`: ```typescript const client = createSaperlyClient({ apiKey: 'sap_sk_live_...' }) const { data } = await numbers.list({ client }) ``` ### Method call shape Every method takes a single options object — `{ body?, path?, query?, client?, throwOnError? }` — and returns `{ data, error }`. ```typescript await numbers.list() // no args await numbers.get({ path: { id } }) // path params await numbers.provision({ body: { areaCode: '415' } }) // request body await consent.check({ query: { numberId, peerNumber } }) // query params ``` ## Resources Import each handle by name. The methods most agents reach for: | Resource | Methods | | --- | --- | | `numbers` | `list`, `provision`, `get`, `release`, `assignConnection`, `setWebhook`, `setCallerIdName`, `setSmsSenderId` | | `connections` | `list`, `create`, `get`, `update`, `delete` | | `messaging` | `send`, `list` | | `voice` | `place`, `list`, `get`, `end`, `transfer`, `recording`, `transcript` | | `voices` | `list` | | `consent` | `check`, `list`, `record`, `revoke` | | `pricing` | `quote` | | `usage` | `summary` | | `keys` | `list`, `mint`, `revoke` | | `assistant` | `answer` | | `health` | `check` | Request/response and typed-error types are exported from the package root (e.g. `import type { NumbersProvisionData } from '@trysaperly/sdk'`). ### Connections A **connection** is the handler bound to a number. `mode` is `'hosted'` (an in-network voice assistant answers each call) or `'manual'` (your own brain drives the call — see [Agent brain](#agent-brain-manual-mode)). ```typescript const { data: connection } = await connections.create({ body: { name: 'support', mode: 'hosted', instructions: 'You are Acme support.', llm: { kind: 'managed', model: 'gpt-4o' }, }, }) await connections.list() await connections.get({ path: { id: connection!.id } }) await connections.update({ path: { id: connection!.id }, body: { instructions: 'Updated prompt.' } }) await connections.delete({ path: { id: connection!.id } }) ``` `connections.create` also accepts `backend` (`'network'` | `'openai_realtime'`), `mcpServers` (customer MCP servers exposed to the assistant), `complianceEnabled`, and `disclosure` (the forced compliance opener). See [Connections](/docs/guides/connections) for the full body. ### Numbers ```typescript const { data: number } = await numbers.provision({ body: { areaCode: '415' }, // or { country: 'US', numberType: 'local' } }) await numbers.list() await numbers.get({ path: { id: number!.id } }) await numbers.assignConnection({ path: { id: number!.id }, body: { connectionId } }) await numbers.setCallerIdName({ path: { id: number!.id }, body: { callerIdName: 'ACME INC' } }) await numbers.release({ path: { id: number!.id } }) ``` `provision` is cost-plus and idempotent; pass `expectedMonthlyPriceCents` / `expectedUpfrontPriceCents` and the call rejects with `PriceChanged` if the live price drifted (override with `approveHigherPrice: true`). ### Messaging `body` is the SMS text. ```typescript const { data: message } = await messaging.send({ body: { fromNumberId: number!.id, to: '+15555550123', body: 'Hi from my agent 👋', }, }) await messaging.list() ``` ### Voice Place an outbound call. `connectionId` picks the brain; `instructions` overrides the prompt for this one call. ```typescript const { data: call } = await voice.place({ body: { fromNumberId: number!.id, to: '+14155551234', connectionId: connection!.id, instructions: 'Confirm the 3pm appointment.', }, }) await voice.list() await voice.get({ path: { id: call!.id } }) await voice.transfer({ path: { id: call!.id }, body: { to: '+14155559999' } }) await voice.end({ path: { id: call!.id } }) // issues the carrier hangup; billing settles from the carrier event // Artifacts once the call completes. await voice.recording({ path: { id: call!.id } }) await voice.transcript({ path: { id: call!.id } }) ``` ### Consent ```typescript await consent.record({ body: { numberId: number!.id, peerNumber: '+15555550123', consentType: 'explicit_outbound', source: 'web-form', }, }) const { data } = await consent.check({ query: { numberId: number!.id, peerNumber: '+15555550123' }, }) data!.hasConsent // → boolean await consent.list() await consent.revoke({ body: { numberId: number!.id, peerNumber: '+15555550123' } }) ``` ### Keys Mint scoped child keys. `mint` returns the plaintext `token` exactly once. ```typescript const { data: key } = await keys.mint({ body: { name: 'voice-agent-prod', scopes: ['write'], numberScope: [number!.id], // optional allow-list spendLimitCents: 5000, // optional cap spendLimitResetPeriod: 'monthly', }, }) console.log(key!.token) // save it now — never re-emitted await keys.list() await keys.revoke({ path: { id: key!.id } }) ``` ### Pricing & usage ```typescript const { data: quote } = await pricing.quote({ query: { country: 'US', numberType: 'local' } }) const { data: usage } = await usage.summary() ``` ## Webhook verification Saperly signs every outbound delivery. Verify the raw body before trusting it. `verifyWebhook` checks `x-saperly-signature: v1=` (HMAC-SHA256 over `${timestamp}.${rawBody}`) constant-time, then the `x-saperly-timestamp` freshness (default 5-minute window). **It is `async`.** ```typescript // Pass the RAW body string — the exact bytes, never a re-serialized object. const rawBody = await req.text() const result = await verifyWebhook(rawBody, process.env.SAPERLY_WEBHOOK_SECRET!, req.headers) if (!result.valid) { return new Response(`Invalid: ${result.reason}`, { status: 400 }) } // result.deliveryId — dedup on this to defeat replays. ``` `verifyWebhook` is stateless. To defeat replays, **cache each `result.deliveryId`** 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. ## Agent brain (manual mode) In **manual mode** a connection's "brain" drives a live phone call: Saperly POSTs a signed `{ event }` to the connection's webhook per call event and reads back exactly ONE directive. `@trysaperly/sdk/agent` turns that wire protocol into a typed handler surface — you write per-event handlers and the framework owns signature verification, parsing, dispatch, and the fail-safe HTTP envelope. ```typescript // Mount on any Web-`Request` runtime (Workers, Deno, Bun.serve, Next.js, Hono…). export default { fetch: agentBrain({ secret: process.env.SAPERLY_MANUAL_SECRET!, // the connection's manualSecret onInboundCall: ({ event }) => `Hi, you've reached ${event.to}. How can I help?`, onTurn: ({ event }) => `You said: ${event.userText}`, // a string → speak(...) onCallEnded: () => {}, // terminal — the directive is ignored }), } ``` The `/agent` entry is **dependency-light** — Web Crypto + plain TS only, with no generated REST client and no `effect` — so it imports cleanly on Node 18+, Bun, Deno (`npm:@trysaperly/sdk/agent`), and edge runtimes. A handler returns a `Directive` (`speak`, `reject`, `transfer`, …, built with the exported builders), a `string` (shorthand for `speak`), or nothing (a safe default). See [Manual mode](/docs/guides/manual-mode) for the event/directive catalog. ## Error handling **Nothing throws by default.** Each method resolves to `{ data, error }`: `data` on a 2xx, `error` on a non-2xx. The `error` is a discriminated union — branch on its `_tag`, never `try/catch`. ```typescript const { data, error } = await messaging.send({ body: { fromNumberId, to, body: 'Hello' }, }) if (error) { switch (error._tag) { case 'RecipientOptedOut': // recipient opted out — capture fresh consent first (error.to) break case 'InsufficientFunds': // top up the balance — error.balanceCents / error.requestedCents break case 'SpendLimitExceeded': // this key's spend cap is spent — error.limitCents / error.priorSpendCents break case 'RateLimited': // back off — error.bucket break default: console.error(error._tag) } } else { console.log(data!.id) } ``` Each typed error carries a `_tag` plus error-specific fields (e.g. `InsufficientFunds` has `balanceCents` + `requestedCents`; `PriceChanged` has the expected vs. actual prices; `AuthorizationDenied` has `reason`). Every method's error union is exported as a type (e.g. `MessagingSendError`). ### Opting into exceptions Pass `{ throwOnError: true }` to make a single call throw on a non-2xx instead of returning `error`: ```typescript const { data } = await numbers.provision({ body: { areaCode: '415' }, throwOnError: true, // throws the typed error body on a non-2xx }) ``` See [Errors & idempotency](/docs/guides/errors-and-idempotency) for the full error catalog and the idempotency model. ## Next steps - [Python SDK](/docs/sdks/python) — the same surface in `snake_case` (v2 in progress; the [REST API](/docs/api-reference) and [MCP](/docs/sdks/mcp) cover Python today). - [MCP](/docs/sdks/mcp) — expose Saperly as tools to any agent framework. - [Manual mode](/docs/guides/manual-mode) — drive a live call with your own brain. - [Authentication](/docs/guides/authentication) — scopes, spend caps, and child keys. - [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 `@trysaperly/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 `@trysaperly/voice-openclaw`): ```bash openclaw plugins install npm:@trysaperly/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: "01923456-789a-7bcd-8ef0-123456789abc", // 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. `@trysaperly/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 The official Python SDK is [**`saperly`**](https://pypi.org/project/saperly/) — a typed client generated from the Saperly v2 OpenAPI contract. It covers the whole camelCase data plane (numbers, connections, messaging, voice, usage, consent, pricing, keys), with sync **and** async calls, built-in retries, and webhook signature verification. ```bash pip install saperly # or: uv add saperly ``` Requires Python 3.9+. Get an API key from the [dashboard](https://saperly.com/sign-in) → **Keys**. Prefer raw HTTP or tools over a client library? Every endpoint is plain JSON over HTTPS — call it directly (see [Calling the REST API directly](#calling-the-rest-api-directly)), or use the [MCP server](/docs/sdks/mcp) to expose Saperly as tools to any agent framework. ## A tiny client `create_client(api_key=...)` returns a configured client you pass to each operation. The key rides on `Authorization: Bearer ` for every request; the workspace is read from the key, never from client input. ```python import os from saperly import create_client client = create_client(api_key=os.environ["SAPERLY_API_KEY"]) ``` Each operation is a small module with four entry points: `sync(...)` (returns the parsed body), `sync_detailed(...)` (returns a `Response` with `status_code` / `headers` / `parsed`), and the `asyncio(...)` / `asyncio_detailed(...)` async equivalents. Request bodies are typed models with **snake_case** fields (the SDK maps them to the API's camelCase on the wire). ## The canonical flow Provision a number, give it a brain (a **connection**), attach the two, then send an SMS and place a call. ### 1. Provision a number ```python from saperly.api.numbers import numbers_provision from saperly.models import NumbersProvisionBody number = numbers_provision.sync(client=client, body=NumbersProvisionBody(area_code="415")) print(number.id, number.phone_number) # → 019…(uuid) +1415… ``` Pass `country=...` instead of `area_code` for numbers outside the US/Canada. ### 2. Create a connection A **connection** is the handler a number routes to. `mode` is `"hosted"` (an in-network voice assistant answers, configured by `instructions`) or `"manual"` (your own agent decides each turn — see [Manual mode](/docs/guides/manual-mode)). ```python from saperly.api.connections import connections_create from saperly.models import ConnectionsCreateBody connection = connections_create.sync( client=client, body=ConnectionsCreateBody( name="support", mode="hosted", instructions="You are a helpful assistant answering calls for Acme Inc.", ), ) print(connection.id) ``` ### 3. Attach the connection to the number ```python from saperly.api.numbers import numbers_assign_connection from saperly.models import NumbersAssignConnectionBody numbers_assign_connection.sync( client=client, id=number.id, body=NumbersAssignConnectionBody(connection_id=connection.id), ) ``` ### 4. Send an SMS ```python from saperly.api.messaging import messaging_send from saperly.models import MessagingSendBody message = messaging_send.sync( client=client, body=MessagingSendBody(from_number_id=number.id, to="+15555550123", body="Hi from my agent 👋"), ) print(message.id, message.status) ``` ### 5. Place a call `voice_place` uses the number's attached connection by default; pass a `connection_id` to override, or `instructions` for a one-off hosted prompt. ```python from saperly.api.voice import voice_place from saperly.models import VoicePlaceBody call = voice_place.sync( client=client, body=VoicePlaceBody( from_number_id=number.id, to="+14155551234", # instructions="Confirm the 3pm appointment, then hang up.", ), ) print(call.id, call.status) ``` New to the platform? Walk through the [Quickstart](/docs/quickstart) first. ## Error handling `sync(...)` returns the parsed body — the success model on 2xx, or the **typed error model** on a handled non-2xx. Use `sync_detailed(...)` when you need the status code and headers (for example to honor `429` `Retry-After`): ```python res = messaging_send.sync_detailed( client=client, body=MessagingSendBody(from_number_id=number.id, to=to, body=body), ) if res.status_code == 200: print(res.parsed.id, res.parsed.status) else: err = res.parsed # typed error model (e.g. RecipientOptedOut, RateLimited) if res.status_code == 429: ... # honor res.headers["Retry-After"], then retry ``` Idempotent requests (GET/HEAD/OPTIONS/DELETE) are retried once on 5xx + connection errors; `POST`/`PATCH` are never retried. Tune with `create_client(api_key=..., retries=3)` (or `retries=0`). ## Async Every operation has `asyncio(...)` / `asyncio_detailed(...)`. The client is an async context manager: ```python import asyncio, os from saperly import Saperly from saperly.api.numbers import numbers_list async def main(): with Saperly(api_key=os.environ["SAPERLY_API_KEY"]) as saperly: numbers = await numbers_list.asyncio(client=saperly.client) print(numbers) asyncio.run(main()) ``` ## Webhooks Verify the signature on inbound Saperly webhooks with `verify_webhook`: ```python from saperly import verify_webhook result = verify_webhook(raw_body, secret=os.environ["SAPERLY_WEBHOOK_SECRET"], headers=request.headers) if not result.valid: return 401 ``` ## Calling the REST API directly You don't need the SDK — the v2 API is plain JSON over HTTPS. Wrap the base URL and bearer header once with [`httpx`](https://www.python-httpx.org/) (fields are **camelCase** on the wire — `fromNumberId`, `areaCode`, `connectionId`): ```python import os, httpx saperly = httpx.Client( base_url="https://api.saperly.com", headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}, timeout=30.0, ) res = saperly.post("/numbers", json={"areaCode": "415"}) res.raise_for_status() print(res.json()["id"]) ``` Non-2xx responses carry a typed JSON error body with a `_tag` discriminator (`Unauthorized`, `AuthorizationDenied`, `RecipientOptedOut`, `InsufficientFunds`, `RateLimited`, …). Branch on `_tag` and honor `429` `Retry-After`. ## Next steps - [Node / TypeScript SDK](/docs/sdks/node) — the typed v2 client (`@trysaperly/sdk`). - [MCP](/docs/sdks/mcp) — expose Saperly as tools to any agent framework. - [Authentication](/docs/guides/authentication) — scopes, spend caps, and scoped `sap_sk` keys. - [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)). ## Stand up a hosted line A **hosted** connection is 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 `instructions` are your agent's prompt; the line answers with a sensible default voice you can change anytime from the dashboard's voice picker. Create the connection, provision a number, then attach one to the other. ```typescript configure({ apiKey: process.env.SAPERLY_API_KEY! }) // 1. The brain. const { data: connection } = await connections.create({ body: { name: 'support agent', mode: 'hosted', instructions: 'You are a friendly support agent for Acme Inc. Greet the caller, answer ' + 'their question, and keep replies short.', }, }) // 2. A real number. const { data: number } = await numbers.provision({ body: { areaCode: '415' } }) // 3. Bind them. await numbers.assignConnection({ path: { id: number!.id }, body: { connectionId: connection!.id }, }) console.log(number!.phoneNumber) // → the number to call ``` ```python import os from saperly import create_client from saperly.api.connections import connections_create from saperly.api.numbers import numbers_provision, numbers_assign_connection from saperly.models import ( ConnectionsCreateBody, NumbersProvisionBody, NumbersAssignConnectionBody, ) client = create_client(api_key=os.environ["SAPERLY_API_KEY"]) # 1. The brain. connection = connections_create.sync( client=client, body=ConnectionsCreateBody( name="support agent", mode="hosted", instructions=( "You are a friendly support agent for Acme Inc. Greet the caller, " "answer their question, and keep replies short." ), ), ) # 2. A real number. number = numbers_provision.sync(client=client, body=NumbersProvisionBody(area_code="415")) # 3. Bind them. numbers_assign_connection.sync( client=client, id=number.id, body=NumbersAssignConnectionBody(connection_id=connection.id), ) print(number.phone_number) # → the number to call ``` ```bash # 1. The brain. CONNECTION_ID=$(curl -s -X POST https://api.saperly.com/connections \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "support agent", "mode": "hosted", "instructions": "You are a friendly support agent for Acme Inc. Greet the caller, answer their question, and keep replies short." }' | jq -r .id) # 2. A real number. NUMBER=$(curl -s -X POST https://api.saperly.com/numbers \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "areaCode": "415" }') NUMBER_ID=$(echo "$NUMBER" | jq -r .id) # 3. Bind them. curl -X POST "https://api.saperly.com/numbers/$NUMBER_ID/connection" \ -H "Authorization: Bearer $SAPERLY_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"connectionId\": \"$CONNECTION_ID\" }" ``` The result is a real, certified number ready to ring — answered by the hosted assistant with a default voice you can swap anytime from the dashboard. ## Call it Dial the `phoneNumber` from any phone. The hosted assistant picks up, speaks its greeting, and holds a real conversation driven by your `instructions`. 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/calls" \ -H "Authorization: Bearer $SAPERLY_API_KEY" ``` ```typescript const { data: calls } = await voice.list() console.log(calls![0]) // status, direction, durationSec, from, to ``` Each call carries its `status`, `direction`, `durationSec`, and the numbers involved (`from` / `to`). The full **turn-by-turn transcript** — what the caller said and what your agent replied — is at `GET /calls/{id}/transcript` (`voice.transcript({ path: { id } })`). Audio stays in the network; you get the record. ## What just happened **You stood up a hosted line.** A connection (the brain) plus a certified number, bound together — and an in-network voice agent answering, with no media infrastructure of your own. **You talked to your agent.** The hosted assistant ran the whole conversation in-network from your `instructions`. **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.