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.
| Method | Path | Returns | Scope |
|---|---|---|---|
POST | /calls | Call (201) | write |
POST | /calls/:id/end | Call | write |
POST | /calls/:id/transfer | Call | write |
GET | /calls/:id | Call | read |
GET | /calls | Call[] | 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:
| Field | Type | Notes |
|---|---|---|
fromNumberId | string | The Saperly number to call from. |
to | string | Destination in E.164 format (e.g. +15555550123). |
connectionId | string | Optional. Overrides the number's bound connection for this call. |
instructions | string | Optional. 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.
Related
- 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.
Messaging
Send and list SMS from your Saperly numbers over the v2 API — consent and disclosures are enforced automatically.
Manual mode
Bring your own LLM as the brain of a phone call — Saperly sends it text turns and executes the directives it returns, while speech-to-text, text-to-speech, and the audio all stay in-network.