Error envelope
The default error response is a JSON object with a singledetail field:
detail — your client can branch on typeof detail === "object" to switch parsers.
HTTP status codes
Notable error shapes
422 — too few valid phone numbers
got is the count after server-side validation, so don’t be surprised if you submitted 110 and got 73 back — that means 37 of yours failed parsing or were outside the US/CA region.
400 — webhook ping failed
When you POST /api/v1/webhooks (or PATCH with a new URL), we send a synchronous ping and require a 2xx. If your endpoint returns anything else, the call fails with:
status_code is the HTTP status your receiver returned, or null if the request never completed (DNS failure, TLS error, timeout). underlying_error carries the network-level error message in that case.
429 — rate limited
scopeis which limit was hit:per_keyorper_org.limitis the per-minute size of that bucket.retry_after_secondsis how long to wait before retrying — also surfaced in theRetry-AfterHTTP header (alongsideX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset).
Rate limits
Authenticated write calls on/api/v1/contacts, /api/v1/uploads, and /api/v1/webhooks are protected by token-bucket rate limits:
Each bucket refills continuously (roughly
limit ÷ 60 tokens per second), so a fresh bucket lets you burst up to its size and then settles into the steady rate — there’s no clock-aligned reset to game. When a bucket is empty you get a 429; honor Retry-After and you’ll drain your whole job without errors.
High-volume integrations (e.g. partner platforms that fan out many customers’ traffic) can have their per-organization limit raised — contact us.
Read endpoints (GET /api/v1/uploads, GET /api/v1/uploads/{id}, GET /api/v1/uploads/{id}/download/*, GET /api/v1/webhooks, GET /api/v1/contacts, etc.) currently bypass the rate limiter. We may add limits there in the future; build your client to handle 429 on any endpoint just in case.
Recommended client pattern
When you receive429, sleep for Retry-After seconds and retry the same request. Don’t compound your own backoff with Retry-After — the value we send is already the delay until the bucket resets.
- Node.js
- Python
scope: "per_org"), batch more numbers into fewer uploads — one upload of 50,000 numbers costs the same in rate-limit budget as one upload of 100, and processing throughput is the same either way. (The per-row POST /api/v1/contacts endpoint is the exception by design — there each contact is its own request, so pace it under your per-key limit.)
Server errors and idempotency
5xx responses are safe to retry — use exponential backoff capped at a minute or so. Note that POST /api/v1/uploads is not currently idempotent; if your retry of an apparent 5xx actually committed on our side, you’ll end up with two uploads. Mitigate by:
- Reading your most recent uploads via
GET /api/v1/uploads?limit=5before retrying — match onoriginal_filenameandcreated_atto detect a successful submission you didn’t see the response for. - Or lean on webhooks: register
upload.createdand treat that as the canonical “we have your list” confirmation, regardless of whether yourPOSTreturned 200 or timed out.