New: API Reference docs are live — integrate Cleanlist enrichment into your apps. View API docs →
API Reference (v2)
Errors & Rate Limits
🔌

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
  }
}
FieldAlways presentMeaning
codeyesMachine-readable error code — branch on this
problemyesHuman-readable description
causenoWhat triggered it
fixnoSuggested remediation
retryableyesWhether retrying could succeed
retry_after_msnoBackoff hint in milliseconds (also mirrored to the Retry-After header)
docs_urlyesDeep link to the error's docs page
request_idyesCorrelation id — include it in support requests
estimated_cost_credits / available_credits / shortfall_creditsnoPresent on credit errors
suggested_actionsnoFollow-up actions an agent can present or call
detailsnoExtra 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

CodeTypical HTTPRetryableMeaning
validation_error400noRequest body/params failed validation
quote_required400noA signed quote_id is required for this operation but was not supplied
insufficient_credits400noBalance can't cover the operation (see credit fields)
spend_cap_exceeded400noActual cost would exceed the quote's max — re-estimate
approval_required400noOperation needs approved: true or a smaller scope
quote_mismatch400noQuote bound to a different request shape
estimate_token_mismatch400noLegacy alias of quote_mismatch — treat identically
quote_expired400noQuote older than its ~5-min TTL — re-estimate
quote_invalid400noQuote signature/format invalid
quote_already_redeemed400noQuote already used — re-estimate
invalid_filter_id400noUnknown saved-filter id
field_mapping_required400noSync needs a field mapping
invalid_cursor400noPagination cursor is malformed
provider_not_connected400noNo active CRM/sequencer connection for the org
invalid_token401noMissing, malformed, expired, or revoked credential
insufficient_scope403noCredential lacks the endpoint's required scope
search_quota_exhausted403noOrganization's people-search quota is exhausted for this period
list_protected403noList is protected against rename/delete — unprotect it in the app first
not_found / list_not_found / task_not_found / folder_not_found404noResource not found or not accessible in this workspace
feature_not_available501noTool/agent type not enabled on this deployment
provider_error502yesUpstream data provider returned an error — retry with backoff
filters_unavailable503yesFilter source temporarily unavailable — retry with backoff
not_enabled503noFeature disabled by an environment kill switch (e.g. CSV import)
rate_limited429yesRate limit hit — back off using Retry-After
workflow_timeout504yesA workflow didn't finish in time
version_deprecated410noAPI version retired — migrate to the current version
internal_error500/502maybeUnexpected 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:

LimitWindowScope
60 requestsper minuteper organization (all keys + members combined)
30 requestsper minuteper 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));
  }
}

Learn more