Errors & idempotency
Saperly returns typed errors with stable codes and HTTP statuses, and every mutating endpoint accepts an Idempotency-Key so a retried request never duplicates an effect.
Saperly returns typed errors with stable codes and HTTP statuses, and makes every mutating call safe to retry with an Idempotency-Key. Same key plus same body returns the same result; same key plus a different body is rejected.
Base URL https://api.saperly.com. Authenticate with Authorization: Bearer sap_sk_live_...; the workspace is resolved from the token.
The error shape
Every error is a tagged error — a JSON body carrying a _tag discriminant
plus its own fields — returned with the matching HTTP status (e.g. the tag
RateLimited at 429). The SDK surfaces it as the error half of the
{ data, error } result, so you branch on error._tag:
// e.g. a 402 SpendLimitExceeded body
{
"_tag": "SpendLimitExceeded",
"limitCents": 5000,
"priorSpendCents": 4800,
"requestedCents": 300
}Common errors
| Tag | Status | Notes |
|---|---|---|
Unauthorized | 401 | No bearer token on the request (field message). |
AuthorizationDenied | 403 | A bearer was sent but it's unrecognized, revoked, lacks the required scope, or targets a number outside the key's allow-list (field reason). |
NumberNotFound / ConnectionNotFound / CallNotFound | 404 | No such resource (field carries the id). |
RecipientOptedOut | 403 | Outbound to a peer who opted out (field to). |
InsufficientFunds | 402 | Balance too low (fields balanceCents, requestedCents). |
SpendLimitExceeded | 402 | Scoped key's spend cap hit (fields limitCents, priorSpendCents, requestedCents). |
RateLimited | 429 | Too many requests (field bucket — which limit you hit). |
IdempotencyConflict | 409 | A key collision (an in-flight or reused key). |
IdempotencyKeyMismatch | 422 | Same key, different body. |
UpstreamError | 502 | A carrier/upstream failure (fields status, code, message). |
Out-of-funds and spend-cap errors are covered in depth in Billing; consent / opt-out errors in Compliance.
Idempotency
Every mutating endpoint accepts the standard IETF Idempotency-Key header — a UUID v4. Saperly stores the first response under that key, so a retry with the same key and same body returns the same result instead of repeating the effect. A retry with the same key but a different body is rejected with IdempotencyKeyMismatch (422); a retry while the first request is still in flight gets IdempotencyConflict (409).
| Situation | Result |
|---|---|
| Same key, same body | Returns the original result — the action runs once. |
| Same key, different body | IdempotencyKeyMismatch (422). |
| Same key, request still in flight (or a fresh replay before it settled) | IdempotencyConflict (409) — retry after it settles. |
You generate the key
The SDK does not invent an Idempotency-Key for you — pass your own UUID v4 when you need a write to be safe to retry. Reuse that exact value on every retry of the same logical request (e.g. across a job that may be re-run after a crash), so both attempts share the same key and the action still runs once.
Sending an idempotency key
Pass the standard Idempotency-Key header. With the SDK, attach it per call via headers:
curl -X POST https://api.saperly.com/calls \
-H "Authorization: Bearer $SAPERLY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f1d6c5e-2b7a-4f0e-9c2d-8a1b6e4f0c11" \
-d '{ "fromNumberId": "num_...", "to": "+15555550123" }'import { randomUUID } from 'node:crypto'
import { configure, voice } from '@trysaperly/sdk'
configure({ apiKey: process.env.SAPERLY_API_KEY! })
const key = randomUUID() // reuse this exact value on every retry
const { data, error } = await voice.place({
body: { fromNumberId: 'num_...', to: '+15555550123' },
headers: { 'Idempotency-Key': key },
})Handling typed errors
Every method returns { data, error } and does not throw by default. Branch
on error (it is undefined on success) and switch on error._tag — pause on a
spend cap, back off on a rate limit, surface the rest:
import { randomUUID } from 'node:crypto'
import { configure, voice } from '@trysaperly/sdk'
configure({ apiKey: process.env.SAPERLY_API_KEY! })
async function place() {
const key = randomUUID()
const attempt = async () =>
voice.place({
body: { fromNumberId: 'num_...', to: '+15555550123' },
headers: { 'Idempotency-Key': key }, // SAME key on every retry
})
let { data, error } = await attempt()
if (error?._tag === 'SpendLimitExceeded') {
// 402 — this key is tapped out until its cap window resets.
console.error(`Cap hit: cap ${error.limitCents}¢, requested ${error.requestedCents}¢.`)
return null
}
if (error?._tag === 'RateLimited') {
// 429 — back off and retry with the SAME Idempotency-Key.
await new Promise((r) => setTimeout(r, 1000))
;({ data, error } = await attempt())
}
if (error) throw new Error(`unexpected: ${error._tag}`)
return data
}Prefer throwing? Opt in per call
Pass { throwOnError: true } to a method to make it throw the error instead of returning it in error — handy when you'd rather wrap calls in a try/catch. The default ({ data, error }, nothing thrown) keeps error handling explicit.
Retry with the same key
When you retry a mutating request after a 429 (or a network failure), reuse the same Idempotency-Key so the action still runs exactly once. A new key on retry defeats the protection and can duplicate the effect.
Related
- Billing —
InsufficientFunds/SpendLimitExceededand the spend-cap fields. - Compliance —
RecipientOptedOutand how consent is enforced. - Authentication — scopes, grants, and the errors a key can hit.
- Webhooks — using the delivery id to make your handler idempotent.
- API reference — every endpoint and its error responses.
Webhooks
Saperly delivers events — inbound SMS, call lifecycle, 10DLC status, delivery receipts — to your endpoint, signed with HMAC-SHA256; verify the signature on the raw body and dedup the delivery id before you trust a payload.
API Reference
The full Saperly REST API, generated from the live OpenAPI contract.