TL;DR: Every error is { "error": { code, problem, fix, retryable, docs_url, request_id, ... } }. Branch on code, not on text. Rate limits are 60/min per org and 30/min per key; on a 429 read Retry-After and back off. Insufficient credits is a 400, not a 402.
Errors & Rate Limits
The error envelope
Every error response from /api/v1/public/* has the same shape:
{
"error": {
"code": "insufficient_credits",
"problem": "This enrich_list call requires 110 credits; organization has 40.",
"cause": null,
"fix": "Top up at app.cleanlist.ai/billing, or reduce the scope of the call.",
"retryable": false,
"retry_after_ms": null,
"docs_url": "https://docs.cleanlist.ai/errors/insufficient-credits",
"request_id": "req_4f1c2a9b3d6e7f80",
"estimated_cost_credits": 110,
"available_credits": 40,
"shortfall_credits": 70
}
}| Field | Always present | Meaning |
|---|---|---|
code | yes | Machine-readable error code — branch on this |
problem | yes | Human-readable description |
cause | no | What triggered it |
fix | no | Suggested remediation |
retryable | yes | Whether retrying could succeed |
retry_after_ms | no | Backoff hint in milliseconds (also mirrored to the Retry-After header) |
docs_url | yes | Deep link to the error's docs page |
request_id | yes | Correlation id — include it in support requests |
estimated_cost_credits / available_credits / shortfall_credits | no | Present on credit errors |
suggested_actions | no | Follow-up actions an agent can present or call |
details | no | Extra structured context (e.g. required vs granted scopes) |
Always branch on code, never on problem text — the prose can change, the code is stable. The docs_url is https://docs.cleanlist.ai/errors/<code-with-dashes>.
Error code reference
| Code | Typical HTTP | Retryable | Meaning |
|---|---|---|---|
validation_error | 400 | no | Request body/params failed validation |
insufficient_credits | 400 | no | Balance can't cover the operation (see credit fields) |
spend_cap_exceeded | 400 | no | Actual cost would exceed the quote's max — re-estimate |
approval_required | 400 | no | Operation needs approved: true or a smaller scope |
estimate_token_mismatch | 400 | no | Quote doesn't bind to this request shape |
quote_expired | 400 | no | Quote older than its ~5-min TTL — re-estimate |
quote_invalid | 400 | no | Quote signature/format invalid |
quote_mismatch | 400 | no | Quote bound to a different request shape |
quote_already_redeemed | 400 | no | Quote already used — re-estimate |
invalid_filter_id | 400 | no | Unknown saved-filter id |
field_mapping_required | 400 | no | Sync needs a field mapping |
invalid_cursor | 400 | no | Pagination cursor is malformed |
feature_not_available | 501 | no | Tool/agent type not enabled on this deployment |
not_enabled | 503 | no | Feature disabled by an environment kill switch (e.g. CSV import) |
filters_unavailable | 502 | maybe | Filter source temporarily unavailable |
provider_not_connected | 400 | no | No active CRM/sequencer connection for the org |
invalid_token | 401 | no | Missing, malformed, expired, or revoked credential |
insufficient_scope | 403 | no | Credential lacks the endpoint's required scope |
not_found / list_not_found / task_not_found / folder_not_found | 404 | no | Resource not found or not accessible in this workspace |
rate_limited | 429 | yes | Rate limit hit — back off using Retry-After |
workflow_timeout | 504 | yes | A workflow didn't finish in time |
version_deprecated | 410 | no | API version retired — migrate to the current version |
internal_error | 500/502 | maybe | Unexpected server error — retry with backoff; contact support with request_id |
Cross-organization access is deliberately reported as 404 not_found (not 403) so the API can't be used to enumerate resources owned by other tenants.
Rate limits
Two independent limits apply to every Public API request:
| Limit | Window | Scope |
|---|---|---|
| 60 requests | per minute | per organization (all keys + members combined) |
| 30 requests | per minute | per individual clapi_ API key |
JWT-session callers (the portal) are subject to the per-org limit but not the per-key limit.
Handling 429
When you hit a limit you get HTTP 429 with a rate_limited body:
{
"error": {
"code": "rate_limited",
"problem": "Rate limit exceeded.",
"retryable": true,
"retry_after_ms": 2000,
"docs_url": "https://docs.cleanlist.ai/errors/rate-limited",
"request_id": "req_..."
}
}A standard Retry-After HTTP header (in seconds, rounded up) accompanies the response, so generic HTTP clients and gateways can back off without parsing the body. Honor it, then retry:
import time, requests
def call_with_backoff(method, url, **kw):
while True:
r = requests.request(method, url, **kw)
if r.status_code != 429:
return r
wait = int(r.headers.get("Retry-After", "2"))
time.sleep(wait)async function callWithBackoff(url, opts) {
while (true) {
const r = await fetch(url, opts);
if (r.status !== 429) return r;
const wait = parseInt(r.headers.get("Retry-After") || "2", 10);
await new Promise((res) => setTimeout(res, wait * 1000));
}
}