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
| Status | Meaning | Where it shows up |
|---|---|---|
200 | OK. The body is the resource you asked for. | All success responses. |
400 | Bad request — your input was malformed or violated a constraint. | Missing phone_column, both/neither of phone_numbers and contacts, invalid event type on a webhook, ping verification failure. |
401 | Authentication failed. Includes WWW-Authenticate: Bearer. | Missing, malformed, expired, or revoked API key. See authentication errors. |
402 | Payment required. The organization has a subscription but not enough credits to process this upload. | POST /api/v1/uploads when valid rows exceed the credit balance. |
403 | Forbidden. The organization has no active subscription. | Any write on /api/v1/uploads for an unsubscribed org. |
404 | Not found, or not visible to your API key’s organization. | Upload or webhook ID that doesn’t exist, or that belongs to a different org. We don’t distinguish — both return 404 to avoid leaking IDs. |
413 | Payload too large. | CSV over 100 MB, phone_numbers/contacts over 250,000 entries. |
415 | Unsupported Content-Type. | POST /api/v1/uploads with anything other than multipart/form-data or application/json. |
422 | Unprocessable entity — the request was valid but the business constraint failed. | Fewer than 100 valid phone numbers after server-side validation. |
429 | Rate limit exceeded. Includes Retry-After header. | Either the per-key or per-organization minute bucket. |
500 | Server error. We log these and they page on-call. | Database failures, unexpected exceptions. Safe to retry with backoff. |
503 | Service unavailable. | Brief, intentional unavailability. Safe to retry. |
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
limitis the bucket size that was breached (60for per-key,600for per-org).observedis the count we saw in the current window after counting your request.retry_after_secondsis the seconds remaining until the current minute window resets — also surfaced in theRetry-AfterHTTP header.
Rate limits
Two minute-bucket counters apply to every authenticated write call on/api/v1/uploads and /api/v1/webhooks:
| Limit | Bucket size | Window |
|---|---|---|
| Per API key | 60 requests | 1 minute (clock-aligned) |
| Per organization (sum across all keys) | 600 requests | 1 minute (clock-aligned) |
:00. Plan your client’s pacing accordingly — don’t burst right at the top of a minute.
Read endpoints (GET /api/v1/uploads, GET /api/v1/uploads/{id}, GET /api/v1/uploads/{id}/download/*, GET /api/v1/webhooks, 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
limit: 600), 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.
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.