saperly
Guides

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 RateLimited at 429).
  • v1 SDK surface — a nested { error: { code, message, details? } } envelope, and the SDK throws a typed error class you can catch.
// 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 codeSDK classStatusNotes
validation_errorValidationError422Malformed or invalid request body.
unauthorized / invalid_api_keyAuthenticationError401Missing or bad key.
forbiddenForbiddenError403Authenticated but not allowed.
not_foundNotFoundError404No such resource.
consent_requiredConsentRequiredError403Outbound contact without recorded consent.
insufficient_credits / payment_method_required402Balance too low.
agent_scope_errorAgentScopeError403Key used a number outside its allow-list (field line_id).
agent_cap_exceededAgentCapExceededError402Spend cap hit (fields spent_cents, cap_cents, cycle_reset_at).
agent_permission_deniedAgentPermissionDeniedError403Action outside the key's permission (fields tier, verb).
rate_limitedRateLimitedError429Too many requests.
idempotency_key_reused409Same key reused with a different body.
idempotency_in_progress409A request with this key is still in flight.
missing_idempotency_key400An endpoint that requires a key got none.

v2 names of note

A few v2 tagged errors are worth knowing by name:

v2 tagStatusPayload
RateLimited429{ bucket } — which limit you hit.
UpstreamError502{ status, code, message } — a carrier/upstream failure.
IdempotencyConflict409A key collision (an in-flight or reused key).
IdempotencyKeyMismatch422Same 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).

SituationResult
Same key, same bodyReturns the original result — the action runs once.
Same key, different bodyIdempotencyKeyMismatch (422) / idempotency_key_reused (409).
Same key, request still in flightidempotency_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.

  • BillingInsufficientFunds / SpendLimitExceeded and the spend-cap fields.
  • Complianceconsent_required and 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.

On this page