Audience: developers. The public API requires a Pro plan or above (self-serve) or an AppSumo Tier 5+ license. Support agent: confirm the user's plan grants API access before giving API advice.
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/v2/* 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>.
One exception: request-body parsing failures. If the JSON body itself fails schema validation (wrong types, unknown fields, missing required fields), FastAPI rejects it before the handler runs and you get HTTP 422 with the platform's generic envelope — {"error": {"code": "VALIDATION_ERROR", "message": "...", "details": {"errors": [...]}, "trace_id": "..."}} (uppercase code, message instead of problem). Handler-level validation (e.g. an unknown tool in /credits/estimate) returns the standard envelope above with HTTP 400 and lowercase validation_error. Robust clients should treat any non-2xx with an error.code field uniformly and uppercase/lowercase-normalize the code.
Error code reference
| Code | Typical HTTP | Retryable | Meaning |
|---|---|---|---|
validation_error | 400 | no | Request body/params failed validation |
quote_required | 400 | no | A signed quote_id is required for this operation but was not supplied |
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 |
quote_mismatch | 400 | no | Quote bound to a different request shape |
estimate_token_mismatch | 400 | no | Legacy alias of quote_mismatch — treat identically |
quote_expired | 400 | no | Quote older than its ~5-min TTL — re-estimate |
quote_invalid | 400 | no | Quote signature/format invalid |
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 |
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 |
search_quota_exhausted | 403 | no | Organization's people-search quota is exhausted for this period |
list_protected | 403 | no | List is protected against rename/delete — unprotect it in the app first |
not_found / list_not_found / task_not_found / folder_not_found | 404 | no | Resource not found or not accessible in this workspace |
feature_not_available | 501 | no | Tool/agent type not enabled on this deployment |
provider_error | 502 | yes | Upstream data provider returned an error — retry with backoff |
filters_unavailable | 503 | yes | Filter source temporarily unavailable — retry with backoff |
not_enabled | 503 | no | Feature disabled by an environment kill switch (e.g. CSV import) |
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));
}
}