Numbers
A number is a real phone number provisioned under your workspace and bound to one connection — provision, list, assign a handler, and release, all over scoped sk_ keys.
A number is a real phone number provisioned under your workspace. Each number is bound to exactly one connection — its handler, the thing that answers. You provision numbers, list and read them, assign a connection, and release them when you're done.
Looking to receive call/SMS events?
POST /numbers/:id/webhook is not an event-notification webhook — it sets a
legacy per-number manual-mode brain URL (a fallback for a connection's
manualWebhookUrl). To receive call.received / call.completed /
message.received / call.recording.saved events, register a workspace webhook
endpoint (POST /workspaces/:slug/webhooks), which returns a signing secret you
verify each delivery with.
Numbers and connections are separate
There is no single "line" object. A number is the phone number; a connection is the brain that answers it. You provision the number, create the connection, then attach one to the other. The same connection can answer many numbers — see Connections.
Endpoints
All v2 endpoints are camelCase, authenticate with a scoped sk_ bearer key, and
read the workspace from the key. Base URL: https://api.saperly.com.
| Method & path | Action | Scope | Returns |
|---|---|---|---|
GET /numbers | list | read | PhoneNumber[] |
GET /numbers/:id | get | read | PhoneNumber |
POST /numbers | provision | write | PhoneNumber (201) |
POST /numbers/:id/release | release | write | { status: "released" } |
POST /numbers/:id/connection | assign connection | write | PhoneNumber |
POST /numbers/:id/webhook | set manual-mode brain URL (not events) | write | PhoneNumber |
POST /numbers/:id/sms-sender | set SMS sender id | write | PhoneNumber |
POST /numbers/:id/caller-id | set caller ID name | write | PhoneNumber |
Provision payload
{ "country": "US", "numberType": "local", "areaCode": "415" }| Field | Default | Notes |
|---|---|---|
country | "US" | ISO country to provision in |
numberType | "local" | e.g. local |
areaCode | — | optional preferred area code |
The PhoneNumber shape
{
id: string
phoneNumber: string
connectionId: string | null
webhookUrl: string | null
country: string | null
numberType: string | null
monthlyPriceCents: number | null // what you pay per month
currency: string | null
nextChargeAt: string | null // ISO — next monthly sweep
releasedAt: string | null // ISO — soft-delete marker
createdAt: string // ISO
}Sender identity
Two per-number settings control how a number presents itself on outbound traffic.
Both are optional, both clear by sending null, and both fall back to the bare
phone number when unset.
SMS sender id
For international SMS, you can send from an alphanumeric sender id instead of
the phone number — a short brand name like Acme. It must be 1–11 alphanumeric
characters. Send null to clear it, and SMS goes back to sending from the phone
number.
POST /numbers/:id/sms-sender
{ "smsSenderId": "Acme" }Alphanumeric sender ids are display-only (one-way) in some countries: recipients see the brand name but cannot reply to it. Where two-way SMS matters, send from the phone number.
Caller ID name (CNAM)
Set the outbound caller-ID name shown on the called party's handset. It must be
1–15 letters, digits, or spaces, and is registered as the line's CNAM listing.
Send null to clear it, and the bare number shows.
POST /numbers/:id/caller-id
{ "callerIdName": "Acme Inc" }# Set an SMS sender id for international SMS
curl -X POST "$BASE/numbers/$NUMBER_ID/sms-sender" \
-H "Authorization: Bearer $SAPERLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "smsSenderId": "Acme" }'
# Set the outbound caller-ID name (CNAM)
curl -X POST "$BASE/numbers/$NUMBER_ID/caller-id" \
-H "Authorization: Bearer $SAPERLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "callerIdName": "Acme Inc" }'
# Clear either one (the bare number shows / SMS sends from the number)
curl -X POST "$BASE/numbers/$NUMBER_ID/caller-id" \
-H "Authorization: Bearer $SAPERLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "callerIdName": null }'Pricing
Get a quote before provisioning so you know the price up front:
GET /pricing/quote?country=US&numberType=localReturns a NumberQuote with your price:
{
customerMonthlyCents: number // your monthly price
customerUpfrontCents: number // your one-time provisioning price
}See Billing for how the prepaid balance is funded and metered.
Provisioning, billing, and release
Provisioning reserves funds for the number's first charge and starts metering its monthly rent against your prepaid balance. The rent sweep is idempotent per billing period, so a number is never double-charged for the same month.
nextChargeAt records when the next monthly sweep is due. Each sweep settles
the recurring cost against your balance.
Releasing a number sets releasedAt (a soft-delete marker) and stops the
rent. The record stays for your history; no further charges accrue.
Worked example
Quote a price, provision the number, bind a connection so it can answer, and later release it. (Step 4 shows the optional, legacy per-number manual-mode brain URL — not event delivery; use a workspace webhook endpoint for events.)
import { configure, numbers, pricing } from '@trysaperly/sdk'
configure({ apiKey: process.env.SAPERLY_API_KEY! })
// 1. Quote the price
const { data: quote } = await pricing.quote({
query: { country: 'US', numberType: 'local' },
})
// → { customerMonthlyCents, customerUpfrontCents }
// 2. Provision a number (reserves funds + starts metering rent)
const { data: number } = await numbers.provision({
body: { country: 'US', numberType: 'local', areaCode: '415' },
})
// 3. Bind a connection (its handler)
await numbers.assignConnection({
path: { id: number!.id },
body: { connectionId: 'conn_…' },
})
// 4. (Optional, legacy) Set the per-number manual-mode brain URL — NOT event delivery
await numbers.setWebhook({
path: { id: number!.id },
body: { url: 'https://example.com/hooks/saperly' },
})
// 5. Later — release it (sets releasedAt, stops the rent)
await numbers.release({ path: { id: number!.id } })import os
import httpx
BASE = "https://api.saperly.com"
headers = {
"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}",
"Content-Type": "application/json",
}
with httpx.Client(base_url=BASE, headers=headers) as http:
# 1. Quote the price
quote = http.get(
"/pricing/quote", params={"country": "US", "numberType": "local"}
).json()
# → { "customerMonthlyCents": ..., "customerUpfrontCents": ... }
# 2. Provision a number (reserves funds + starts metering rent)
number = http.post(
"/numbers",
json={"country": "US", "numberType": "local", "areaCode": "415"},
).json()
# 3. Bind a connection (its handler)
http.post(
f"/numbers/{number['id']}/connection",
json={"connectionId": "conn_…"},
)
# 4. (Optional, legacy) Set the per-number manual-mode brain URL — NOT event delivery
http.post(
f"/numbers/{number['id']}/webhook",
json={"url": "https://example.com/hooks/saperly"},
)
# 5. Later — release it (sets releasedAt, stops the rent)
http.post(f"/numbers/{number['id']}/release")export BASE=https://api.saperly.com
# 1. Quote the price
curl "$BASE/pricing/quote?country=US&numberType=local" \
-H "Authorization: Bearer $SAPERLY_API_KEY"
# 2. Provision a number (reserves funds + starts metering rent)
curl -X POST "$BASE/numbers" \
-H "Authorization: Bearer $SAPERLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "country": "US", "numberType": "local", "areaCode": "415" }'
# 3. Bind a connection (its handler)
curl -X POST "$BASE/numbers/$NUMBER_ID/connection" \
-H "Authorization: Bearer $SAPERLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "connectionId": "conn_…" }'
# 4. (Optional, legacy) Set the per-number manual-mode brain URL — NOT event delivery
curl -X POST "$BASE/numbers/$NUMBER_ID/webhook" \
-H "Authorization: Bearer $SAPERLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/saperly" }'
# 5. Later — release it (sets releasedAt, stops the rent)
curl -X POST "$BASE/numbers/$NUMBER_ID/release" \
-H "Authorization: Bearer $SAPERLY_API_KEY"Errors
| Error | Status | Detail |
|---|---|---|
NumberQuotaExceeded | 409 | { limit, current } |
NoNumbersAvailable | 404 | { country, numberType } |
InsufficientFunds | 402 | balance (or a key's spend cap) too low to reserve |
NumberNotFound | 404 | no such number in this workspace |
See Errors & idempotency for the shared error envelope and safe retries.
Next steps
- Connections — the handler you bind to a number.
- Billing — the prepaid ledger, reservations, and rent.
- Authentication — the scopes and spend caps that gate these endpoints.
- API reference — every
numbersandpricingendpoint with a try-it playground.
Billing
Saperly is prepaid — you top up a balance and usage meters against it through a reserve → settle → release ledger that can never overspend a balance or a scoped key's spend cap.
Messaging
Send and list SMS from your Saperly numbers over the v2 API — consent and disclosures are enforced automatically.