Node / TypeScript
@saperly/sdk is the typed, zero-dependency TypeScript client for Saperly — provision numbers, send SMS, place calls, and verify webhooks with full IntelliSense.
@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.
Install
npm install @saperly/sdkThe package ships ESM + types and pulls in nothing else. Get an API key from the dashboard → Keys.
Quickstart
Provision a hosted line and send your first SMS.
import { Saperly } from '@saperly/sdk'
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 first.
Authentication
The client authenticates with Authorization: Bearer <key>. Pass your key to
the constructor:
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
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<string, string> | 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 |
Removed against v2
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 and 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'.
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.
// 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.
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 instantlyBilling, usage, and audit
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:
import { SUPPORTED_LANGUAGES, LANGUAGE_LABELS, isSupportedLanguage } from '@saperly/sdk'
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:
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.
import { verifyWebhook } from '@saperly/sdk'
// 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 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).
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 — the same surface in
snake_case. - MCP — expose Saperly as tools to any agent framework.
- Authentication — scopes, spend caps, and child-key delegation.
- API reference — every endpoint with a try-it playground.