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

TagStatusNotes
Unauthorized401No bearer token on the request (field message).
AuthorizationDenied403A 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 / CallNotFound404No such resource (field carries the id).
RecipientOptedOut403Outbound to a peer who opted out (field to).
InsufficientFunds402Balance too low (fields balanceCents, requestedCents).
SpendLimitExceeded402Scoped key's spend cap hit (fields limitCents, priorSpendCents, requestedCents).
RateLimited429Too many requests (field bucket — which limit you hit).
IdempotencyConflict409A key collision (an in-flight or reused key).
IdempotencyKeyMismatch422Same key, different body.
UpstreamError502A 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).

SituationResult
Same key, same bodyReturns the original result — the action runs once.
Same key, different bodyIdempotencyKeyMismatch (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.

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

On this page