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 sk_live_...; the workspace is resolved from the token.
Error envelopes
The two API surfaces shape errors differently, but the meaning is identical:
- v2 API — a tagged error with an HTTP status (e.g. the tag
RateLimitedat429). - v1 SDK surface — a nested
{ error: { code, message, details? } }envelope, and the SDK throws a typed error class you cancatch.
// v1 SDK error envelope
{
"error": {
"code": "agent_cap_exceeded",
"message": "Monthly spend cap reached for this key.",
"details": { "spent_cents": 5000, "cap_cents": 5000, "cycle_reset_at": "2026-07-01T00:00:00Z" }
}
}Common error codes
| v1 code | SDK class | Status | Notes |
|---|---|---|---|
validation_error | ValidationError | 422 | Malformed or invalid request body. |
unauthorized / invalid_api_key | AuthenticationError | 401 | Missing or bad key. |
forbidden | ForbiddenError | 403 | Authenticated but not allowed. |
not_found | NotFoundError | 404 | No such resource. |
consent_required | ConsentRequiredError | 403 | Outbound contact without recorded consent. |
insufficient_credits / payment_method_required | — | 402 | Balance too low. |
agent_scope_error | AgentScopeError | 403 | Key used a number outside its allow-list (field line_id). |
agent_cap_exceeded | AgentCapExceededError | 402 | Spend cap hit (fields spent_cents, cap_cents, cycle_reset_at). |
agent_permission_denied | AgentPermissionDeniedError | 403 | Action outside the key's permission (fields tier, verb). |
rate_limited | RateLimitedError | 429 | Too many requests. |
idempotency_key_reused | — | 409 | Same key reused with a different body. |
idempotency_in_progress | — | 409 | A request with this key is still in flight. |
missing_idempotency_key | — | 400 | An endpoint that requires a key got none. |
v2 names of note
A few v2 tagged errors are worth knowing by name:
| v2 tag | Status | Payload |
|---|---|---|
RateLimited | 429 | { bucket } — which limit you hit. |
UpstreamError | 502 | { status, code, message } — a carrier/upstream failure. |
IdempotencyConflict | 409 | A key collision (an in-flight or reused key). |
IdempotencyKeyMismatch | 422 | Same key, different body. |
Out-of-funds and spend-cap errors are covered in depth in Billing; consent 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) / idempotency_key_reused (409).
| Situation | Result |
|---|---|
| Same key, same body | Returns the original result — the action runs once. |
| Same key, different body | IdempotencyKeyMismatch (422) / idempotency_key_reused (409). |
| Same key, request still in flight | idempotency_in_progress (409) — retry after it settles. |
The SDKs auto-generate a key for key operations
For key creation and rotation, the SDKs auto-generate an Idempotency-Key (UUID v4) if you don't pass one — so a retried mint never produces a duplicate key. Pass your own idempotencyKey when you need to retry safely across process boundaries (e.g. a job that may be re-run after a crash), so both attempts share the same key.
Sending an idempotency key
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'
const key = randomUUID() // reuse this exact value on every retry
const res = await fetch('https://api.saperly.com/calls', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SAPERLY_API_KEY!}`,
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({ fromNumberId: 'num_...', to: '+15555550123' }),
})Handling typed errors
Catch the specific classes you care about and react — pause on a spend cap, back off on a rate limit, rethrow the rest:
import {
Saperly,
AgentCapExceededError,
RateLimitedError,
} from '@saperly/sdk'
const saperly = new Saperly({ apiKey: process.env.SAPERLY_API_KEY! })
async function place() {
try {
return await saperly.calls.place({
fromNumberId: 'num_...',
to: '+15555550123',
})
} catch (err) {
if (err instanceof AgentCapExceededError) {
// 402 — this key is tapped out until its cycle resets.
console.error(
`Cap hit: ${err.details.spent_cents}/${err.details.cap_cents} cents,` +
` resets ${err.details.cycle_reset_at}`,
)
return null
}
if (err instanceof RateLimitedError) {
// 429 — back off and retry with the SAME Idempotency-Key.
await new Promise((r) => setTimeout(r, 1000))
return place()
}
throw err // anything else is unexpected — let it surface
}
}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 —
consent_requiredand how it's 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.