saperly
SDKs & connectors

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/sdk

The package ships ESM + types and pulls in nothing else. Get an API key from the dashboardKeys.

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:

OptionTypeNotes
apiKeystringBearer credential. Required unless defaultHeaders supplies an alternate credential.
baseUrlstringOverride the API host (e.g. for staging). Defaults to the production host.
defaultHeadersRecord<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:

ResourceMethods
linescreate, list, get, update, delete, sendSms
callscreate, list, get, hangup, conversation, speak, waitForResponse, pressNumbers
messagessend
conversationslist, messages
consentgrant, check, revoke
complianceaudit
keyscreate, list, get, update, delete, rotate
billingbalance, transactions
usagedaily, monthly
auditlist
webhooksdeliveries, stats, test
voiceslist
settingsget, 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 instantly

Billing, 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.

On this page