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