saperly
SDKs & connectors

Python

Call the Saperly v2 REST API from Python today with httpx — provision numbers, attach connections, send SMS, and place calls with a bearer sk_ key. A native v2 Python SDK is in progress.

A native v2 Python SDK is in progress. Until it ships, call the Saperly v2 REST API directly — it's a small, predictable JSON surface (camelCase fields, a bearer sap_sk key). This page shows the canonical flow with httpx. Prefer tools over HTTP? The MCP server exposes the same capabilities to any agent framework.

The Saperly v2 API is plain JSON over HTTPS. Every request carries Authorization: Bearer <sk_key>, sends and receives camelCase fields, and the workspace is read from the key — never from client input. Base URL:

https://api.saperly.com

Get an API key from the dashboardKeys.

A tiny client

httpx (or requests) is all you need. Wrap the base URL and bearer header once and reuse the client.

import os
import httpx

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

Fields are camelCase (fromNumberId, areaCode, connectionId) — the TypeScript style, not Python's snake_case. The env var here is SAPERLY_API_KEY. Use scoped sap_sk keys (sap_sk_live_…); see Authentication for scopes, number allow-lists, and spend caps.

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

POST /numbers searches and orders a number. Pass an areaCode (US/Canada) or a country; the response includes the new number's id and E.164 phoneNumber.

res = saperly.post("/numbers", json={"areaCode": "415"})
res.raise_for_status()
number = res.json()

print(number["id"], number["phoneNumber"])  # → num_…  +1415…

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

res = saperly.post(
    "/connections",
    json={
        "name": "support",
        "mode": "hosted",
        "instructions": "You are a helpful assistant answering calls for Acme Inc.",
    },
)
res.raise_for_status()
connection = res.json()

print(connection["id"])  # → conn_…

3. Attach the connection to the number

POST /numbers/{id}/connection binds the handler to the line.

saperly.post(
    f"/numbers/{number['id']}/connection",
    json={"connectionId": connection["id"]},
).raise_for_status()

4. Send an SMS

POST /messages sends from one of your numbers (by fromNumberId) to any destination.

res = saperly.post(
    "/messages",
    json={
        "fromNumberId": number["id"],
        "to": "+15555550123",
        "body": "Hi from my agent 👋",
    },
)
res.raise_for_status()
message = res.json()

print(message["id"], message["status"])

5. Place a call

POST /calls places an outbound call. It uses the number's attached connection by default; pass a connectionId to override, or instructions for a one-off hosted prompt.

res = saperly.post(
    "/calls",
    json={
        "fromNumberId": number["id"],
        "to": "+14155551234",
        # "instructions": "Confirm the 3pm appointment, then hang up.",
    },
)
res.raise_for_status()
call = res.json()

print(call["id"], call["status"])

New to the platform? Walk through the Quickstart first.

Async

The same calls work with httpx.AsyncClient. Construct it inside async with so the underlying connection pool closes cleanly.

import asyncio
import os
import httpx

async def main():
    async with httpx.AsyncClient(
        base_url="https://api.saperly.com",
        headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"},
        timeout=30.0,
    ) as saperly:
        res = await saperly.get("/numbers")
        res.raise_for_status()
        print(res.json())

asyncio.run(main())

Error handling

Non-2xx responses return a typed JSON error body with a _tag discriminator (for example Unauthorized, AuthorizationDenied, ConsentRequired, InsufficientBalance, RateLimited). Branch on _tag and honor 429 Retry-After.

res = saperly.post(
    "/messages",
    json={"fromNumberId": number["id"], "to": to, "body": body},
)

if res.status_code >= 400:
    err = res.json()
    tag = err.get("_tag")
    if tag == "ConsentRequired":
        ...  # recipient hasn't consented — capture consent first
    elif tag == "RateLimited":
        ...  # honor Retry-After, then retry
    else:
        res.raise_for_status()

The full set of endpoints, request/response shapes, and typed error tags lives in the API reference with a try-it playground.

Next steps

On this page