Node / TypeScript
@trysaperly/sdk is the typed TypeScript client for the Saperly v2 API — provision numbers, send SMS, place calls, and verify webhooks with full IntelliSense.
@trysaperly/sdk is the official TypeScript client for the Saperly v2 API.
It is typed end-to-end, autogenerated from the OpenAPI contract, and runs on
Node.js 18+ (anywhere fetch is global), plus Bun, Deno, and edge
runtimes. configure({ apiKey }) once and lowercase resource handles —
numbers, connections, messaging, voice, and friends — give your agent a
phone number, SMS, outbound calling, consent + compliance, and signed webhook
verification.
The SDK targets the camelCase v2 API directly. For the raw HTTP surface, see the API reference.
Install
npm install @trysaperly/sdkThe package ships ESM + types. Get an API key from the
dashboard → Keys (sap_sk_live_…).
Quickstart
The SDK is not a class. Call configure() once to point a shared client at
your workspace key, then call the resource handles. Stand up a hosted line:
create a connection (the brain), provision a number, and attach the two.
import { configure, connections, numbers } from '@trysaperly/sdk'
configure({ apiKey: process.env.SAPERLY_API_KEY! })
// 1. The connection is the handler — a hosted, in-network voice assistant.
const { data: connection, error: connErr } = await connections.create({
body: {
name: 'support',
mode: 'hosted',
instructions: 'You are a helpful assistant answering calls for Acme Inc.',
},
})
if (connErr) throw new Error(connErr._tag)
// 2. Provision a number (here, in the 415 area code).
const { data: number } = await numbers.provision({ body: { areaCode: '415' } })
console.log(number!.phoneNumber) // → +1...
// 3. Bind the connection to the number.
await numbers.assignConnection({
path: { id: number!.id },
body: { connectionId: connection!.id },
})Every method returns { data, error, request, response } — data on success,
error (the typed error body) on a non-2xx status. Nothing throws by
default; see Error handling.
New to the platform? Walk through the Quickstart first.
Authentication
configure() sets a shared client that sends Authorization: Bearer <key> on
every request:
import { configure } from '@trysaperly/sdk'
configure({ apiKey: process.env.SAPERLY_API_KEY! })configure(config) accepts:
| Option | Type | Notes |
|---|---|---|
apiKey | string | Bearer credential (sap_sk_live_…). Required. |
baseUrl | string | Override the API origin (e.g. for staging). Defaults to the production host. |
retries | number | Retries for idempotent requests (GET/HEAD/OPTIONS/DELETE) on 5xx + network errors. Default 1; set 0 to disable. POST/PATCH are never retried. |
Saperly uses one tier of scoped sk_ API keys. Each key carries a grant —
scopes, an optional number allow-list, and an optional spend cap — and the
workspace is always read from the key, never from client input. See
Authentication for the full model, scopes, and
spend caps.
Multiple keys
configure() sets one shared client. To talk to more than one workspace from a
single process, create isolated clients with createSaperlyClient() and pass
one per call via { client }:
import { createSaperlyClient, numbers } from '@trysaperly/sdk'
const client = createSaperlyClient({ apiKey: 'sap_sk_live_...' })
const { data } = await numbers.list({ client })Method call shape
Every method takes a single options object — { body?, path?, query?, client?, throwOnError? } — and returns { data, error }.
await numbers.list() // no args
await numbers.get({ path: { id } }) // path params
await numbers.provision({ body: { areaCode: '415' } }) // request body
await consent.check({ query: { numberId, peerNumber } }) // query paramsResources
Import each handle by name. The methods most agents reach for:
| Resource | Methods |
|---|---|
numbers | list, provision, get, release, assignConnection, setWebhook, setCallerIdName, setSmsSenderId |
connections | list, create, get, update, delete |
messaging | send, list |
voice | place, list, get, end, transfer, recording, transcript |
voices | list |
consent | check, list, record, revoke |
pricing | quote |
usage | summary |
keys | list, mint, revoke |
assistant | answer |
health | check |
Request/response and typed-error types are exported from the package root (e.g.
import type { NumbersProvisionData } from '@trysaperly/sdk').
Connections
A connection is the handler bound to a number. mode is 'hosted' (an
in-network voice assistant answers each call) or 'manual' (your own brain
drives the call — see Agent brain).
const { data: connection } = await connections.create({
body: {
name: 'support',
mode: 'hosted',
instructions: 'You are Acme support.',
llm: { kind: 'managed', model: 'gpt-4o' },
},
})
await connections.list()
await connections.get({ path: { id: connection!.id } })
await connections.update({ path: { id: connection!.id }, body: { instructions: 'Updated prompt.' } })
await connections.delete({ path: { id: connection!.id } })connections.create also accepts backend ('network' | 'openai_realtime'),
mcpServers (customer MCP servers exposed to the assistant), complianceEnabled,
and disclosure (the forced compliance opener). See
Connections for the full body.
Numbers
const { data: number } = await numbers.provision({
body: { areaCode: '415' }, // or { country: 'US', numberType: 'local' }
})
await numbers.list()
await numbers.get({ path: { id: number!.id } })
await numbers.assignConnection({ path: { id: number!.id }, body: { connectionId } })
await numbers.setCallerIdName({ path: { id: number!.id }, body: { callerIdName: 'ACME INC' } })
await numbers.release({ path: { id: number!.id } })provision is cost-plus and idempotent; pass expectedMonthlyPriceCents /
expectedUpfrontPriceCents and the call rejects with PriceChanged if the live
price drifted (override with approveHigherPrice: true).
Messaging
body is the SMS text.
const { data: message } = await messaging.send({
body: {
fromNumberId: number!.id,
to: '+15555550123',
body: 'Hi from my agent 👋',
},
})
await messaging.list()Voice
Place an outbound call. connectionId picks the brain; instructions overrides
the prompt for this one call.
const { data: call } = await voice.place({
body: {
fromNumberId: number!.id,
to: '+14155551234',
connectionId: connection!.id,
instructions: 'Confirm the 3pm appointment.',
},
})
await voice.list()
await voice.get({ path: { id: call!.id } })
await voice.transfer({ path: { id: call!.id }, body: { to: '+14155559999' } })
await voice.end({ path: { id: call!.id } }) // issues the carrier hangup; billing settles from the carrier event
// Artifacts once the call completes.
await voice.recording({ path: { id: call!.id } })
await voice.transcript({ path: { id: call!.id } })Consent
await consent.record({
body: {
numberId: number!.id,
peerNumber: '+15555550123',
consentType: 'explicit_outbound',
source: 'web-form',
},
})
const { data } = await consent.check({
query: { numberId: number!.id, peerNumber: '+15555550123' },
})
data!.hasConsent // → boolean
await consent.list()
await consent.revoke({ body: { numberId: number!.id, peerNumber: '+15555550123' } })Keys
Mint scoped child keys. mint returns the plaintext token exactly once.
const { data: key } = await keys.mint({
body: {
name: 'voice-agent-prod',
scopes: ['write'],
numberScope: [number!.id], // optional allow-list
spendLimitCents: 5000, // optional cap
spendLimitResetPeriod: 'monthly',
},
})
console.log(key!.token) // save it now — never re-emitted
await keys.list()
await keys.revoke({ path: { id: key!.id } })Pricing & usage
const { data: quote } = await pricing.quote({ query: { country: 'US', numberType: 'local' } })
const { data: usage } = await usage.summary()Webhook verification
Saperly signs every outbound delivery. Verify the raw body before trusting it.
verifyWebhook checks x-saperly-signature: v1=<hex> (HMAC-SHA256 over
${timestamp}.${rawBody}) constant-time, then the x-saperly-timestamp
freshness (default 5-minute window). It is async.
import { verifyWebhook } from '@trysaperly/sdk'
// Pass the RAW body string — the exact bytes, never a re-serialized object.
const rawBody = await req.text()
const result = await verifyWebhook(rawBody, process.env.SAPERLY_WEBHOOK_SECRET!, req.headers)
if (!result.valid) {
return new Response(`Invalid: ${result.reason}`, { status: 400 })
}
// result.deliveryId — dedup on this to defeat replays.verifyWebhook is stateless. To defeat replays, cache each result.deliveryId
for at least the clock-tolerance window (default 5 minutes) and reject duplicates
yourself.
See Webhooks for the full event catalog and delivery guarantees.
Agent brain (manual mode)
In manual mode a connection's "brain" drives a live phone call: Saperly POSTs
a signed { event } to the connection's webhook per call event and reads back
exactly ONE directive. @trysaperly/sdk/agent turns that wire protocol into a
typed handler surface — you write per-event handlers and the framework owns
signature verification, parsing, dispatch, and the fail-safe HTTP envelope.
import { agentBrain } from '@trysaperly/sdk/agent'
// Mount on any Web-`Request` runtime (Workers, Deno, Bun.serve, Next.js, Hono…).
export default {
fetch: agentBrain({
secret: process.env.SAPERLY_MANUAL_SECRET!, // the connection's manualSecret
onInboundCall: ({ event }) => `Hi, you've reached ${event.to}. How can I help?`,
onTurn: ({ event }) => `You said: ${event.userText}`, // a string → speak(...)
onCallEnded: () => {}, // terminal — the directive is ignored
}),
}The /agent entry is dependency-light — Web Crypto + plain TS only, with no
generated REST client and no effect — so it imports cleanly on Node 18+, Bun,
Deno (npm:@trysaperly/sdk/agent), and edge runtimes.
A handler returns a Directive (speak, reject, transfer, …, built with the
exported builders), a string (shorthand for speak), or nothing (a safe
default). See Manual mode for the event/directive
catalog.
Error handling
Nothing throws by default. Each method resolves to { data, error }: data
on a 2xx, error on a non-2xx. The error is a discriminated union — branch on
its _tag, never try/catch.
import { messaging } from '@trysaperly/sdk'
const { data, error } = await messaging.send({
body: { fromNumberId, to, body: 'Hello' },
})
if (error) {
switch (error._tag) {
case 'RecipientOptedOut':
// recipient opted out — capture fresh consent first (error.to)
break
case 'InsufficientFunds':
// top up the balance — error.balanceCents / error.requestedCents
break
case 'SpendLimitExceeded':
// this key's spend cap is spent — error.limitCents / error.priorSpendCents
break
case 'RateLimited':
// back off — error.bucket
break
default:
console.error(error._tag)
}
} else {
console.log(data!.id)
}Each typed error carries a _tag plus error-specific fields (e.g.
InsufficientFunds has balanceCents + requestedCents; PriceChanged has the
expected vs. actual prices; AuthorizationDenied has reason). Every method's
error union is exported as a type (e.g. MessagingSendError).
Opting into exceptions
Pass { throwOnError: true } to make a single call throw on a non-2xx instead
of returning error:
const { data } = await numbers.provision({
body: { areaCode: '415' },
throwOnError: true, // throws the typed error body on a non-2xx
})See Errors & idempotency for the full error catalog and the idempotency model.
Next steps
- Python SDK — the same surface in
snake_case(v2 in progress; the REST API and MCP cover Python today). - MCP — expose Saperly as tools to any agent framework.
- Manual mode — drive a live call with your own brain.
- Authentication — scopes, spend caps, and child keys.
- API reference — every endpoint with a try-it playground.
Core concepts
The Saperly model — numbers, connections, your prepaid balance, workspaces, and the compliance layer that wraps them.
Python
The official Saperly v2 Python SDK (saperly on PyPI) — provision numbers, attach connections, send SMS, and place calls with a bearer sk_ key. Typed models, sync + async, built-in retries and webhook verification.