saperly
SDKs & connectors

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

The package ships ESM + types. Get an API key from the dashboardKeys (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:

OptionTypeNotes
apiKeystringBearer credential (sap_sk_live_…). Required.
baseUrlstringOverride the API origin (e.g. for staging). Defaults to the production host.
retriesnumberRetries 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 params

Resources

Import each handle by name. The methods most agents reach for:

ResourceMethods
numberslist, provision, get, release, assignConnection, setWebhook, setCallerIdName, setSmsSenderId
connectionslist, create, get, update, delete
messagingsend, list
voiceplace, list, get, end, transfer, recording, transcript
voiceslist
consentcheck, list, record, revoke
pricingquote
usagesummary
keyslist, mint, revoke
assistantanswer
healthcheck

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 } })
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.

On this page