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

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

Error code reference

CodeTypical HTTPRetryableMeaning
validation_error400noRequest body/params failed validation
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
estimate_token_mismatch400noQuote doesn't bind to this request shape
quote_expired400noQuote older than its ~5-min TTL — re-estimate
quote_invalid400noQuote signature/format invalid
quote_mismatch400noQuote bound to a different request shape
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
feature_not_available501noTool/agent type not enabled on this deployment
not_enabled503noFeature disabled by an environment kill switch (e.g. CSV import)
filters_unavailable502maybeFilter source temporarily unavailable
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
not_found / list_not_found / task_not_found / folder_not_found404noResource not found or not accessible in this workspace
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