Skip to main content
OptimalDial uses standard HTTP status codes and a single error envelope across every endpoint. This page covers what to expect when something goes wrong, and how to keep your client well-behaved under load.

Error envelope

The default error response is a JSON object with a single detail field:
Some errors return a richer object instead of a plain string. The shape is always nested under 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

  • scope is which limit was hit: per_key or per_org.
  • limit is the per-minute size of that bucket.
  • retry_after_seconds is how long to wait before retrying — also surfaced in the Retry-After HTTP header (alongside X-RateLimit-Limit, X-RateLimit-Remaining, and X-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. When you receive 429, 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.
If you’re consistently hitting the per-org limit (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=5 before retrying — match on original_filename and created_at to detect a successful submission you didn’t see the response for.
  • Or lean on webhooks: register upload.created and treat that as the canonical “we have your list” confirmation, regardless of whether your POST returned 200 or timed out.
A request idempotency-key header is on our roadmap; until then, the upload-list-check pattern is the recommended workaround.