> ## Documentation Index
> Fetch the complete documentation index at: https://docs.optimaldial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and rate limits

> Standardised error envelope, status codes, and the rate limits that protect your account.

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:

```json theme={null}
{ "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.

```json theme={null}
{
  "detail": {
    "error": "rate_limit_exceeded",
    "scope": "per_key",
    "limit": 600,
    "retry_after_seconds": 2
  }
}
```

## 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](/authentication#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` and `X-RateLimit-*` headers.                             | The per-key or per-organization bucket — see `scope`.                                                                                       |
| `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

```json theme={null}
{
  "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:

```json theme={null}
{
  "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

```json theme={null}
{
  "detail": {
    "error": "rate_limit_exceeded",
    "scope": "per_key",
    "limit": 600,
    "retry_after_seconds": 2
  }
}
```

* `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:

| Limit            | Bucket size                 | Scope                                                                               |
| ---------------- | --------------------------- | ----------------------------------------------------------------------------------- |
| Per API key      | **600** requests / minute   | A single key. The main pacing knob — point your integration's rate setting at this. |
| Per organization | **1,500** requests / minute | Summed across all of your org's keys.                                               |

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 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.

<Tabs>
  <Tab title="Node.js">
    ```ts theme={null}
    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");
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import time, requests

    def call_with_rate_limit(make_request, max_attempts=5):
        for _ in range(max_attempts):
            resp = make_request()
            if resp.status_code != 429:
                return resp
            retry_after = int(resp.headers.get("Retry-After", "1"))
            time.sleep(retry_after)
        raise RuntimeError("Rate limit retries exhausted")
    ```
  </Tab>
</Tabs>

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.
