Errors and Testing

If you only test the happy path, telephony will punish you.

Common error families

Expect these kinds of failures:

  • invalid or revoked API key
  • validation errors on line or call creation
  • insufficient credits
  • consent missing for outbound calls
  • webhook destination failures
  • missing resources
  • rate limiting

Error response shape

Every error response follows the same envelope. The code field is the stable, machine-readable identifier you should branch on. The message field is human-readable and may change over time.

1{
2 "error": {
3 "code": "validation_error",
4 "message": "Request validation failed.",
5 "details": [
6 {
7 "field": "name",
8 "message": "Required"
9 }
10 ]
11 }
12}

Error codes

Every error response includes a stable code field. Branch on the code, not the message.

CodeHTTPWhen it happens
validation_error422Missing or invalid request fields
email_taken409Email already registered (signup flows)
invalid_credentials401Wrong email or password
invalid_api_key401Key is malformed, expired, or revoked
unauthorized401No auth header provided
not_found404Resource does not exist
forbidden403Blocked by an internal auth or policy check
call_not_found404Call ID does not match any call
consent_required403Outbound call to a number without consent
number_opted_out403Destination number has opted out
call_in_progress409Line already has an active call
insufficient_credits402Balance below the one-minute voice reserve (e.g. 13¢ Zone A webhook, 26¢ Zone A hosted; multiplied by zone for international — see voice-zones guide)
payment_method_required402POST /v1/lines in live env when the user has already provisioned a line and has no default payment method on file (first line is free + 30-day grace)
line_no_webhook422Line has no webhook_url configured
rate_limited429Per-endpoint rate limit exceeded
conversation_window_expired403No inbound SMS from the recipient in the last 24h AND no active explicit_outbound consent on file for that (line, recipient) pair. Record consent via POST /v1/consent to bypass the 24h window.
missing_idempotency_key400Idempotency-Key header missing on POST /v1/keys or POST /v1/keys/{id}/rotate
idempotency_key_reused409Same Idempotency-Key reused with a different request body (same method+path) within the 12-hour cache window
idempotency_in_progress409A prior request with the same Idempotency-Key is still in-flight. Retry after ~1s (response includes Retry-After: 1).
agent_scope_error403Line-scoped key tried to read another key’s audit; cross-scope access denied
agent_permission_denied403Permission tier insufficient for the requested verb
agent_cap_exceeded402Per-key monthly_cap_cents reached for the current cycle. Details include spent_cents, cap_cents, cycle_reset_at.
m3_fraud_block403M3 fraud heuristic blocked the request (IRSF reconnaissance + no payment method on file)
internal_error500Unexpected server error
Treat 4xx errors as permanent for the current request and 5xx errors as transient. Retry 5xx with backoff, but do not retry 4xx without fixing the request first.

Error response examples

Concrete shapes for the codes you are most likely to hit in production.

insufficient_credits
1{
2 "error": {
3 "code": "insufficient_credits",
4 "message": "Insufficient balance to place call."
5 }
6}
payment_method_required
1{
2 "error": {
3 "code": "payment_method_required",
4 "message": "Add a payment method to provision additional numbers."
5 }
6}

After adding a payment method in the portal, retry POST /v1/lines with a new Idempotency-Key. The original 402 is sticky-cached for ~12h on the original key — reusing it returns the same 402.

consent_required
1{
2 "error": {
3 "code": "consent_required",
4 "message": "Outbound calls require consent for this destination."
5 }
6}
conversation_window_expired
1{
2 "error": {
3 "code": "conversation_window_expired",
4 "message": "Outbound SMS requires either an inbound message from this number within the last 24 hours, or an active explicit_outbound consent record on file for this (line, recipient) pair (record one via POST /v1/consent)."
5 }
6}
call_in_progress
1{
2 "error": {
3 "code": "call_in_progress",
4 "message": "Line already has an active call."
5 }
6}
rate_limited
1{
2 "error": {
3 "code": "rate_limited",
4 "message": "Too many requests. Retry after the window resets."
5 }
6}

Rate limits

Rate limits vary per endpoint. Most use a 1 hour rolling window; SMS is the only endpoint with a 1 minute window.

EndpointLimitWindow
POST /api/v1/lines101 hour
PATCH /api/v1/lines/{id}601 hour
DELETE /api/v1/lines/{id}101 hour
POST /api/v1/calls601 hour
POST /api/v1/calls/conversation601 hour
POST /api/v1/messages101 minute
POST /api/v1/consent1001 hour
POST /api/v1/disclosures201 hour
POST /api/v1/webhooks/test101 hour
Read endpoints (conversations, settings, usage)601 hour

When you exceed a limit, the API returns 429 Too Many Requests with the rate_limited error code.

SDK error handling

Both SDKs surface a typed error class so you can branch on code without parsing response bodies.

1import { Saperly, SaperlyError } from '@saperly/sdk';
2
3try {
4 const call = await saperly.calls.create({ lineId: 'LINE_ID', toNumber: '+1...' });
5} catch (err) {
6 if (err instanceof SaperlyError) {
7 console.error(err.code, err.message); // e.g. "insufficient_credits"
8 }
9}

What to test before shipping

Inbound call

  • call connects
  • caller hears the expected first response
  • transcript is readable
  • call record is queryable after completion

Outbound call

  • blocked without consent
  • succeeds with consent
  • failure state is visible if destination is unreachable

SMS

  • inbound event arrives
  • reply succeeds
  • conversation history looks correct

Webhooks

  • your endpoint is idempotent (Saperly delivers each webhook once; network-level duplicates are still possible)
  • non-200 responses are visible in webhook delivery logs
  • test endpoint can hit your webhook before production traffic does

Load testing

Use sk_test_ keys for load testing. Test keys hit the same infrastructure but are isolated from production billing. Rate limits still apply — if you hit 429 rate_limited, back off and retry.

Recommended approach:

  1. Create 2-3 test lines
  2. Simulate inbound webhook events against your handler
  3. Place outbound calls to your own test numbers
  4. Monitor webhook delivery success rate and latency
  5. Check billing transactions to verify correct charging

Useful debugging endpoints

  • GET /api/v1/webhooks/deliveries
  • GET /api/v1/webhooks/stats
  • POST /api/v1/webhooks/test
  • GET /api/v1/calls
  • GET /api/v1/compliance/audit

Advice

Break your webhook on purpose once in staging.

Then look at delivery logs and the failure-mode user experience. Better to learn that path deliberately than from your first customer.