saperly
SDKs & connectors

Python

The official Saperly v2 Python SDK (saperly on PyPI) — provision numbers, attach connections, send SMS, and place calls with a bearer sk_ key. Typed models, sync + async, built-in retries and webhook verification.

The official Python SDK is saperly — a typed client generated from the Saperly v2 OpenAPI contract. It covers the whole camelCase data plane (numbers, connections, messaging, voice, usage, consent, pricing, keys), with sync and async calls, built-in retries, and webhook signature verification.

pip install saperly        # or: uv add saperly

Requires Python 3.9+. Get an API key from the dashboardKeys.

Prefer raw HTTP or tools over a client library? Every endpoint is plain JSON over HTTPS — call it directly (see Calling the REST API directly), or use the MCP server to expose Saperly as tools to any agent framework.

A tiny client

create_client(api_key=...) returns a configured client you pass to each operation. The key rides on Authorization: Bearer <key> for every request; the workspace is read from the key, never from client input.

import os
from saperly import create_client

client = create_client(api_key=os.environ["SAPERLY_API_KEY"])

Each operation is a small module with four entry points: sync(...) (returns the parsed body), sync_detailed(...) (returns a Response with status_code / headers / parsed), and the asyncio(...) / asyncio_detailed(...) async equivalents. Request bodies are typed models with snake_case fields (the SDK maps them to the API's camelCase on the wire).

The canonical flow

Provision a number, give it a brain (a connection), attach the two, then send an SMS and place a call.

1. Provision a number

from saperly.api.numbers import numbers_provision
from saperly.models import NumbersProvisionBody

number = numbers_provision.sync(client=client, body=NumbersProvisionBody(area_code="415"))
print(number.id, number.phone_number)  # → 019…(uuid)  +1415…

Pass country=... instead of area_code for numbers outside the US/Canada.

2. Create a connection

A connection is the handler a number routes to. mode is "hosted" (an in-network voice assistant answers, configured by instructions) or "manual" (your own agent decides each turn — see Manual mode).

from saperly.api.connections import connections_create
from saperly.models import ConnectionsCreateBody

connection = connections_create.sync(
    client=client,
    body=ConnectionsCreateBody(
        name="support",
        mode="hosted",
        instructions="You are a helpful assistant answering calls for Acme Inc.",
    ),
)
print(connection.id)

3. Attach the connection to the number

from saperly.api.numbers import numbers_assign_connection
from saperly.models import NumbersAssignConnectionBody

numbers_assign_connection.sync(
    client=client,
    id=number.id,
    body=NumbersAssignConnectionBody(connection_id=connection.id),
)

4. Send an SMS

from saperly.api.messaging import messaging_send
from saperly.models import MessagingSendBody

message = messaging_send.sync(
    client=client,
    body=MessagingSendBody(from_number_id=number.id, to="+15555550123", body="Hi from my agent 👋"),
)
print(message.id, message.status)

5. Place a call

voice_place uses the number's attached connection by default; pass a connection_id to override, or instructions for a one-off hosted prompt.

from saperly.api.voice import voice_place
from saperly.models import VoicePlaceBody

call = voice_place.sync(
    client=client,
    body=VoicePlaceBody(
        from_number_id=number.id,
        to="+14155551234",
        # instructions="Confirm the 3pm appointment, then hang up.",
    ),
)
print(call.id, call.status)

New to the platform? Walk through the Quickstart first.

Error handling

sync(...) returns the parsed body — the success model on 2xx, or the typed error model on a handled non-2xx. Use sync_detailed(...) when you need the status code and headers (for example to honor 429 Retry-After):

res = messaging_send.sync_detailed(
    client=client,
    body=MessagingSendBody(from_number_id=number.id, to=to, body=body),
)

if res.status_code == 200:
    print(res.parsed.id, res.parsed.status)
else:
    err = res.parsed          # typed error model (e.g. RecipientOptedOut, RateLimited)
    if res.status_code == 429:
        ...                   # honor res.headers["Retry-After"], then retry

Idempotent requests (GET/HEAD/OPTIONS/DELETE) are retried once on 5xx + connection errors; POST/PATCH are never retried. Tune with create_client(api_key=..., retries=3) (or retries=0).

Async

Every operation has asyncio(...) / asyncio_detailed(...). The client is an async context manager:

import asyncio, os
from saperly import Saperly
from saperly.api.numbers import numbers_list

async def main():
    with Saperly(api_key=os.environ["SAPERLY_API_KEY"]) as saperly:
        numbers = await numbers_list.asyncio(client=saperly.client)
        print(numbers)

asyncio.run(main())

Webhooks

Verify the signature on inbound Saperly webhooks with verify_webhook:

from saperly import verify_webhook

result = verify_webhook(raw_body, secret=os.environ["SAPERLY_WEBHOOK_SECRET"], headers=request.headers)
if not result.valid:
    return 401

Calling the REST API directly

You don't need the SDK — the v2 API is plain JSON over HTTPS. Wrap the base URL and bearer header once with httpx (fields are camelCase on the wire — fromNumberId, areaCode, connectionId):

import os, httpx

saperly = httpx.Client(
    base_url="https://api.saperly.com",
    headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"},
    timeout=30.0,
)

res = saperly.post("/numbers", json={"areaCode": "415"})
res.raise_for_status()
print(res.json()["id"])

Non-2xx responses carry a typed JSON error body with a _tag discriminator (Unauthorized, AuthorizationDenied, RecipientOptedOut, InsufficientFunds, RateLimited, …). Branch on _tag and honor 429 Retry-After.

Next steps

On this page