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 bearer credentials, prefixed sap_sk_live_…. The workspace, scopes,
number allow-list, and spend cap all ride on the key itself — the workspace is
always resolved from the token, never from client input.
Scopes
Each key carries a set of scopes. A request is allowed only if the key holds the scope the action requires. On the wire a key holds one or more of three coarse scopes:
| Scope | Grants |
|---|---|
read | list and read resources (numbers, connections, messages, calls, usage, consent) |
write | mutating actions — place calls, send SMS, provision/release numbers, record consent, write connections |
admin | everything write allows, plus minting, listing, and revoking child keys |
The grant
A key's grant is more than scopes. It also bounds which numbers the key can
touch and how much it can spend. On the wire (the POST /api-tokens body)
the grant is flat:
{
name: string // 1–100 chars
scopes: ('read' | 'write' | 'admin')[] // at least one
numberScope?: string[] | null // allow-list for number-scoped actions
spendLimitCents?: number | null // hard spend cap, in cents
spendLimitResetPeriod?: 'monthly' | null // cap window
}scopes— the minimum is one scope; the key can do nothing outside them.numberScope— an optional allow-list. When present, number-scoped actions are restricted to exactly those numbers.spendLimitCents— a hard cap enforced at reserve time by the Ledger, so it holds even for in-flight spend.spendLimitResetPeriod: 'monthly'resets the counter at the UTC month boundary;null(or omitted) means a single lifetime cap.
Because the spend limit is checked when funds are reserved, a key can never
overrun its cap mid-call — the reservation simply fails with
SpendLimitExceeded.
Child keys: delegation with a ceiling
A key with the admin scope (the key-administration capability, keys:admin)
can mint child keys — one key per agent, each with a narrower grant. This is
how you hand out keys at fleet scale without minting a separate admin credential
per agent.
The child is bounded by the parent's ceiling: its scopes, number allow-list,
and spend cap may not exceed the minting key's own grant. This is enforced
server-side, so an admin key can only ever narrow what it already holds —
never escalate. Hand your admin key to your provisioning system, hand the
scoped child keys it mints to individual agents.
| Action | Endpoint | SDK |
|---|---|---|
| Mint a child key | POST /api-tokens | keys.mint(...) |
| List keys | GET /api-tokens | keys.list() |
| Revoke a key | POST /api-tokens/:id/revoke | keys.revoke(...) |
POST /api-tokens returns a fresh key bound to the grant you pass. The plaintext
token is in the response — store it immediately.
curl -X POST https://api.saperly.com/api-tokens \
-H "Authorization: Bearer $SAPERLY_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "agent-42",
"scopes": ["read", "write"],
"numberScope": ["num_01H..."],
"spendLimitCents": 5000,
"spendLimitResetPeriod": "monthly"
}'import { configure, keys } from '@trysaperly/sdk'
configure({ apiKey: process.env.SAPERLY_ADMIN_KEY! })
const { data, error } = await keys.mint({
body: {
name: 'agent-42',
scopes: ['read', 'write'],
numberScope: ['num_01H...'],
spendLimitCents: 5000,
spendLimitResetPeriod: 'monthly',
},
})
if (error) {
// typed error body — e.g. AuthorizationDenied when the grant exceeds the ceiling
console.error(error)
} else {
const token = data.token // sap_sk_live_… — present in the response, store it now
}The child inherits the minting key's workspace — you never pass an org or workspace id, which is precisely what closes the cross-tenant hole. If the grant you request exceeds the parent's ceiling (scopes ⊄ parent, a number outside the parent's allow-list, or a higher spend cap), the mint is rejected.
Where keys come from
A key is created either by an org admin in the dashboard (Settings → Keys)
or programmatically by an admin-scoped key via POST /api-tokens. Use the
dashboard for a handful of keys; mint child keys when you're issuing one per
agent at fleet scale.
Role presets (human members)
Human members get their abilities from their org role, not from a key grant:
| Role | Allows |
|---|---|
owner / admin | everything — read, write, and key administration |
member | read + the everyday write actions (place calls, send SMS, read numbers/connections, record consent), but not key administration |
A member deliberately cannot administer keys; key administration is reserved
for owner / admin (or a keys:admin-equivalent admin-scoped key).
Next steps
- Numbers — 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.
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.