saperly
Guides

Voice

Place and control outbound calls and fetch call records over the v2 API — audio always stays in-network, funds are reserved on start and settled on end.

Place and control outbound calls, and list and fetch call records. Call audio stays in-network and never reaches your servers. The handler that actually talks during a call is the connection bound to the number (or the connectionId you pass).

Endpoints

Base URL https://api.saperly.com. Authenticate with Authorization: Bearer sap_sk_live_...; the workspace is resolved from the token.

MethodPathReturnsScope
POST/callsCall (201)write
POST/calls/:id/endCallwrite
POST/calls/:id/transferCallwrite
GET/calls/:idCallread
GET/callsCall[]read

A GET or /end against an unknown id returns CallNotFound (404).

Outbound voice requires consent

Calling a peer requires recorded consent first. Record explicit_outbound consent before placing a call to a number you initiated contact with — Saperly enforces this automatically. See Compliance.

Place a call

POST /calls takes the following payload:

FieldTypeNotes
fromNumberIdstringThe Saperly number to call from.
tostringDestination in E.164 format (e.g. +15555550123).
connectionIdstringOptional. Overrides the number's bound connection for this call.
instructionsstringOptional. A per-call system prompt that overrides the line's saved instructions for this call only.
import { configure, voice } from '@trysaperly/sdk'

configure({ apiKey: process.env.SAPERLY_API_KEY! })

const { data: call, error } = await voice.place({
  body: {
    fromNumberId: 'num_...',
    to: '+15555550123',
    instructions: 'You are calling to confirm a delivery window. Keep it under a minute.',
  },
})

if (error) {
  // typed error — e.g. CallStartFailed, InsufficientFunds, RecipientOptedOut
} else {
  console.log(call.id)
}
import os
import httpx

call = httpx.post(
    "https://api.saperly.com/calls",
    headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"},
    json={
        "fromNumberId": "num_...",
        "to": "+15555550123",
        "instructions": "You are calling to confirm a delivery window. Keep it under a minute.",
    },
).json()

print(call["id"])
curl -X POST https://api.saperly.com/calls \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fromNumberId": "num_...",
    "to": "+15555550123",
    "instructions": "You are calling to confirm a delivery window. Keep it under a minute."
  }'

If the carrier can't start the call, it fails with CallStartFailed (502).

Per-call instructions

The optional instructions string is a per-call system prompt. It overrides the line's saved instructions for this call only — the connection's stored prompt is left untouched. Use it to give one call a narrow goal (confirm an appointment, read back an order) without editing the connection.

Transfer a live call

POST /calls/:id/transfer does a blind transfer of the live leg to a new destination. This is signaling only — it hands the call off at the carrier and works across every backend. It requires the write scope.

The payload takes a single to, either an E.164 number or a sip: URI:

{ "to": "+15551230000" }   // or { "to": "sip:agent@example.com" }
curl -X POST https://api.saperly.com/calls/$CALL_ID/transfer \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+15551230000" }'

End a call

POST /calls/:id/end ends a live call — it issues the carrier hangup. It takes no body: billing is settled authoritatively from the carrier's hangup event (which carries the real duration), never a value you supply. It returns the call (still live); the carrier event finalizes its status and cost moments later.

curl -X POST https://api.saperly.com/calls/$CALL_ID/end \
  -H "Authorization: Bearer $SAPERLY_API_KEY"

Get and list calls

const { data: call } = await voice.get({ path: { id: callId } })
const { data: calls } = await voice.list()
auth = {"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}

call = httpx.get(f"https://api.saperly.com/calls/{call_id}", headers=auth).json()
calls = httpx.get("https://api.saperly.com/calls", headers=auth).json()
curl "https://api.saperly.com/calls/$CALL_ID" \
  -H "Authorization: Bearer $SAPERLY_API_KEY"

curl "https://api.saperly.com/calls" \
  -H "Authorization: Bearer $SAPERLY_API_KEY"

Billing: reserve → settle

Funds are reserved when a call starts and settled when it ends — Saperly's prepaid ledger holds an estimate at start, then settles against the carrier-reported duration and releases the remainder. See Billing for the reserve → settle → release flow and Concepts for the model.

Who talks during the call

The handler that speaks is the connection bound to the number, or the connectionId you pass on POST /calls. For an agent that is the brain of the live call — receiving turns and issuing directives — use a manual-mode connection and see Manual mode and Voice channels.

  • Connections — the handler that answers and talks.
  • Manual mode — your own LLM as the brain of a live call.
  • Voice channels — call your own coding agent and talk to it in its own context.
  • Billing — the prepaid ledger and reserve → settle → release.
  • Compliance — consent required for outbound voice.

On this page