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:
{ "detail": "Insufficient credits" }
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.
{
  "detail": {
    "error": "rate_limit_exceeded",
    "limit": 60,
    "observed": 65,
    "retry_after_seconds": 45
  }
}

HTTP status codes

StatusMeaningWhere it shows up
200OK. The body is the resource you asked for.All success responses.
400Bad 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.
401Authentication failed. Includes WWW-Authenticate: Bearer.Missing, malformed, expired, or revoked API key. See authentication errors.
402Payment 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.
403Forbidden. The organization has no active subscription.Any write on /api/v1/uploads for an unsubscribed org.
404Not 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.
413Payload too large.CSV over 100 MB, phone_numbers/contacts over 250,000 entries.
415Unsupported Content-Type.POST /api/v1/uploads with anything other than multipart/form-data or application/json.
422Unprocessable entity — the request was valid but the business constraint failed.Fewer than 100 valid phone numbers after server-side validation.
429Rate limit exceeded. Includes Retry-After header.Either the per-key or per-organization minute bucket.
500Server error. We log these and they page on-call.Database failures, unexpected exceptions. Safe to retry with backoff.
503Service unavailable.Brief, intentional unavailability. Safe to retry.

Notable error shapes

422 — too few valid phone numbers

{
  "detail": {
    "error": "min_contacts_required",
    "min": 100,
    "got": 73,
    "message": "At least 100 valid phone numbers are required per upload."
  }
}
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:
{
  "detail": {
    "error": "ping_failed",
    "message": "Endpoint did not respond 2xx to the ping challenge. Verify signature, then retry.",
    "status_code": 500,
    "underlying_error": null
  }
}
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

{
  "detail": {
    "error": "rate_limit_exceeded",
    "limit": 60,
    "observed": 65,
    "retry_after_seconds": 45
  }
}
  • limit is the bucket size that was breached (60 for per-key, 600 for per-org).
  • observed is the count we saw in the current window after counting your request.
  • retry_after_seconds is the seconds remaining until the current minute window resets — also surfaced in the Retry-After HTTP header.

Rate limits

Two minute-bucket counters apply to every authenticated write call on /api/v1/uploads and /api/v1/webhooks:
LimitBucket sizeWindow
Per API key60 requests1 minute (clock-aligned)
Per organization (sum across all keys)600 requests1 minute (clock-aligned)
Windows are aligned to the wall clock: at the top of every minute the counter resets to zero. This means your “60 requests per minute” budget can be 60 in the first second if you happen to start at :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. 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.
async function callWithRateLimit(req: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await req();
    if (res.status !== 429) return res;
    const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
    await new Promise(r => setTimeout(r, retryAfter * 1000));
  }
  throw new Error("Rate limit retries exhausted");
}
If you’re consistently hitting per-org limits (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=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.