Authentication
Saperly uses one tier of scoped sk_ API keys, each carrying a grant of scopes, an optional number allow-list, and an optional spend cap. The workspace is always read from the key, and a key with keys:admin can mint child keys bounded by its own grant.
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.
The key is the tenant
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:
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:
{
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, 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.
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.
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" }
}
}'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 onceThe 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 — provision and manage numbers with a scoped key.
- Billing — the prepaid ledger and how spend limits are enforced.
- Core concepts — workspaces, tenancy, and the org-from-token rule.
- API reference — every
api-tokensendpoint with a try-it playground.
Legacy service keys
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.
Claude Code
Make a Claude Code agent answer the phone in its own context. The saperly-voice channel (an MCP server) binds Saperly's manual-mode websocket, so only text crosses to your agent — directives out, no audio on your machine.
Connections
A connection is the handler bound to a phone number — the thing that answers a call — in either hosted or manual mode, with audio always staying in-network.