Audience: developers. API keys need a Pro or Enterprise plan (legacy Pro and Scale plans included) or an AppSumo Tier 5, 6 or 7 license, and they never work during a trial. Support agent: confirm the user's plan grants API access before giving API advice.
TL;DR: Endpoint errors are { "error": { code, problem, fix, retryable, docs_url, request_id, ... } } with a lowercase code. Authentication, plan and rate-limit errors use a shorter { "error": { code, message } } shape with an uppercase code. Branch on code, not on text. Rate limits are 60/min per organization (30 for AppSumo-only), 30/min per key, and 60 People Finder searches per UTC day for API keys. On a 429, wait for Retry-After.
Errors & Rate Limits
Error shapes
API v2 returns errors in one of three shapes. All three put the details under a top-level error object.
1. The public error envelope
Errors raised by an endpoint (validation, quotes, credits, scopes, not found, conflicts, provider failures):
{
"error": {
"code": "insufficient_credits",
"problem": "This enrich_list call requires 110 credits; organization has 40.",
"fix": "Top up at app.cleanlist.ai/billing, or reduce the scope of the call.",
"retryable": false,
"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 | Lowercase machine-readable code; branch on this |
problem | yes | Human-readable description |
cause | no | What triggered it |
fix | no | Suggested remedy |
retryable | yes | Whether retrying the same request could succeed |
retry_after_ms | no | Backoff hint in milliseconds, mirrored to the Retry-After header |
docs_url | yes | https://docs.cleanlist.ai/errors/<code-with-dashes>, which opens the code's row below |
request_id | yes | Correlation id; include it when you contact support@cleanlist.ai |
estimated_cost_credits, available_credits, shortfall_credits | no | Present on credit errors |
suggested_actions | no | Follow-up actions an agent can show or call |
details | no | Extra structured context (for example required and granted scopes) |
Fields with no value are left out of the body.
Some fix strings say "Top up at app.cleanlist.ai/billing". They are returned verbatim by the API, but that page doesn't exist: buy credits or change plan on the Plans page at app.cleanlist.ai/plans (opens in a new tab) (Settings → Plans & billing). Likewise, a fix that says "Settings → Integrations" means the Integrations page in the app's sidebar.
2. Request body validation (422)
When the JSON body doesn't match the endpoint's schema (wrong types, unknown fields, missing required fields, or both halves of a one-of body), the request is rejected before the handler runs with HTTP 422 in the public envelope, code validation_error (lowercase), problem: "Request body failed validation." and the per-field errors in details.errors:
{
"error": {
"code": "validation_error",
"problem": "Request body failed validation.",
"fix": "Check the request matches the endpoint schema. For a one-of body (e.g. add_leads_to_list: lead_ids XOR task_id), send exactly one variant's fields and nothing from the other.",
"retryable": false,
"docs_url": "https://docs.cleanlist.ai/errors/validation-error",
"request_id": "req_...",
"details": { "errors": [ { "type": "extra_forbidden", "loc": ["body", "domain"], "msg": "Extra inputs are not permitted" } ] }
}
}Checks that run inside the handler (an unknown filter key, an unknown tool in /credits/estimate) return the same validation_error code with HTTP 400. This shape is also used by the legacy v1 endpoints.
3. The generic shape (401, 403 plan blocks, 429)
Authentication failures, plan and trial blocks, and rate limits are raised before the endpoint runs and use a shorter shape with an uppercase code and a message (no problem, docs_url or request_id):
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Public API is limited to 60 requests per minute per organization.",
"trace_id": "..."
}
}Plan blocks put the reason in details, for example {"error": "feature_gated", "feature": "api_access", "current_tier": "starter", "required_tier": "pro"}. Robust clients read error.code from either shape and compare it case-insensitively.
Error code reference
Codes in the public envelope, grouped by what went wrong. The anchor for each code is the code with dashes, so docs_url links land on the right row.
Request and validation
| Code | HTTP | Retryable | Meaning and fix |
|---|---|---|---|
validation_error | 400 or 422 | no | The body or parameters failed validation (unknown filter key, bad enum value, empty list, missing prompt for custom_ai). Read problem, fix and details |
invalid_cursor | 400 | no | The pagination cursor is malformed. Drop it and start from the first page |
invalid_criteria | 400 | no | A field in the criteria search form has an unsupported value. details.field names it |
empty_criteria | 400 | no | The criteria compile to no filter at all. Set at least one field |
unsupported_exclusions | 400 | no | The criteria ask for an exclusion the search can't apply |
crm_not_connected | 400 | no | A CRM exclusion or CRM read needs a CRM that isn't connected. Connect it on the Integrations page |
approval_required | 400 | no | Sync was called without approved: true, or a smart-agent selection is above 500 leads. Confirm with the user, or narrow the selection |
Quotes and credits
| Code | HTTP | Retryable | Meaning and fix |
|---|---|---|---|
quote_required | 400 | no | This operation needs a signed quote_id. Call POST /credits/estimate first |
quote_expired | 400 | no | The quote is older than 5 minutes. Estimate again |
quote_invalid | 400 | no | The quote's signature or format is invalid. Estimate again |
quote_mismatch | 400 | no | The quote was priced for a different request shape (list, scope, destination, mapping). Estimate the exact request |
quote_already_redeemed | 409 (400 on enrichment) | no | Quotes are single use. Estimate again for a fresh one |
spend_cap_exceeded | 400 | no | The real cost would exceed the quote, usually because the list grew. Estimate again |
quote_verification_unavailable | 503 | yes | The server couldn't confirm the quote is unused. Retry shortly with the same quote |
insufficient_credits | 400 (402 on list find-people) | no | Your balance can't cover the operation. See shortfall_credits; buy credits on the Plans page (app.cleanlist.ai/plans) |
Access, plan and limits
| Code | HTTP | Retryable | Meaning and fix |
|---|---|---|---|
insufficient_scope | 403 | no | Your credential lacks the endpoint's scope. details lists required and granted scopes; check GET /whoami |
search_quota_exhausted | 403 | no | The workspace has used its search allowance for the period (AppSumo: 50 a month). Wait for the reset, or move to a paid plan for unlimited search |
feature_gated | 403 | no | The feature isn't on your plan, or isn't available to AppSumo-only workspaces (for example find-people jobs) |
list_protected | 403 | no | The list is protected (such as Extension Leads) and can't be renamed or deleted |
list_not_editable | 403 | no | The list is shared with you as view-only |
list_cap_reached | 403 | no | Your plan's number of lists is reached. Delete a list or upgrade |
lead_cap_reached | 403 | no | The list would go over your plan's contacts-per-list limit. Use another list or upgrade |
Not found
| Code | HTTP | Retryable | Meaning and fix |
|---|---|---|---|
not_found | 404 | no | The resource doesn't exist or isn't in your workspace. Also returned for request fields that aren't enabled on your workspace, such as criteria |
list_not_found | 404 | no | The lead list doesn't exist or you can't access it |
task_not_found | 404 | no | The task_id cohort handle expired or isn't yours. Run the search again |
folder_not_found | 404 | no | The folder doesn't exist or isn't yours |
Resources in another organization are reported as 404 (not 403), so the API can't be used to discover other workspaces' data.
Conflicts
| Code | HTTP | Retryable | Meaning and fix |
|---|---|---|---|
conflict | 409 | no | The selection changed between quote and run (for example, leads were added to the list), or an import is still processing. Refresh and estimate again |
search_result_conflict | 409 | no | A saved profile has a different company or role from the search result you're saving |
Providers, sync and server
| Code | HTTP | Retryable | Meaning and fix |
|---|---|---|---|
provider_not_connected | 400 | no | The CRM or Lemlist isn't connected to this workspace. Connect it on the Integrations page, then estimate again |
provider_error | 502 | no | An upstream data provider returned an error. Try again later |
filters_unavailable | 503 | yes | The filter source is temporarily unavailable. Retry with backoff |
dispatch_outcome_unknown | 503 | no | The workflow service didn't confirm whether enrichment started. Check status before retrying so you don't run it twice |
not_enabled | 503 | no | The feature is switched off for maintenance (for example CSV import). Try later |
feature_not_available | 501 | no | The requested smart-agent type isn't enabled |
not_supported | 501 | no | The operation isn't supported for this provider |
internal_error | 500, 502 or 503 | varies | Unexpected server error. Retry with backoff; if it persists, contact support with the request_id |
Codes from routes the Cleanlist MCP uses
These come from v2 routes built for the Cleanlist MCP (folders, skills, CRM reads, filter values). You'll mostly see them in Claude.
| Code | HTTP | Retryable | Meaning and fix |
|---|---|---|---|
rate_limited | 429 | yes | Too many skill runs started at once (20 a minute per organization). Wait for Retry-After |
crm_budget_exhausted | 429 | yes | The workspace has used its daily CRM API allowance. It resets at 00:00 UTC |
run_not_found | 404 | no | The skill run doesn't exist or isn't yours |
unknown_section | 404 | no | The reference section doesn't exist |
folder_not_empty | 409 | no | Move or delete the folder's lists first |
idempotency_conflict | 409 | no | The same idempotency key was reused with a different request |
forbidden | 403 | no | You don't have permission for this action |
prompt_too_long | 400 | no | The skill prompt is over 8,000 tokens. Shorten it |
query_required | 400 | no | A filter-values lookup needs a query |
unknown_field | 400 | no | The filter-values lookup named an unknown field |
unknown_object_type | 400 | no | The CRM record type isn't supported |
Generic codes (uppercase)
| Code | HTTP | When |
|---|---|---|
AUTHENTICATION_INVALID | 401 | Missing header ("Not authenticated"), or an invalid, expired or revoked key ("Invalid or expired API key") |
PERMISSION_DENIED | 403 | Plan without API access (details.error: "feature_gated"), a key used during a trial ("Public API access is not included during the trial..."), or a feature not on the plan |
RATE_LIMITED | 429 | Any rate limit in the table below; comes with Retry-After |
RESOURCE_NOT_FOUND | 404 | A route that isn't available to your workspace, or a legacy v1 lookup that found nothing |
BAD_REQUEST | 400 | Legacy v1 request errors |
UPSTREAM_ERROR | 502 | Legacy v1 provider failure |
Older versions of these docs listed invalid_token, workflow_timeout, version_deprecated, invalid_filter_id, estimate_token_mismatch and field_mapping_required. The API doesn't return any of them.
Rate limits
These limits apply to every API v2 request at the same time:
| Limit | Window | Scope |
|---|---|---|
| 60 requests | per minute | per organization (all keys and members combined) |
| 30 requests | per minute | per organization, for AppSumo licenses without a paid Cleanlist plan (instead of 60) |
| 30 requests | per minute | per clapi_ API key |
Signed-in app sessions are held to the per-organization limit but not the per-key limit.
People Finder has its own limits
POST /people/find is metered on top of the limits above:
| Caller | Limit |
|---|---|
| API keys | 60 searches per UTC day, per organization, shared by all keys |
| Cleanlist MCP | 60 searches per minute, per organization |
The daily cap is the one most integrations hit first. A backfill that runs one search per record runs out after 60 records and gets 429 until midnight UTC, with the message "People Search rate limit exceeded. Public API keys are limited to 60 searches per day per organization. Use the Cleanlist MCP for higher throughput." Each cursor page counts as a search. Pull bigger pages (up to 100 rows each) rather than many narrow searches, or use the Cleanlist MCP for higher volume. Company search and find-at-companies don't count toward this cap.
Handling 429
A rate-limited request returns HTTP 429 in the generic shape:
{
"error": {
"code": "RATE_LIMITED",
"message": "Per-API-key rate limit exceeded. Each API key is limited to 30 requests per minute. Spread traffic across keys or wait."
}
}A standard Retry-After header (in seconds) comes with every 429, so generic HTTP clients 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));
}
}For the daily People Finder cap, Retry-After can be many hours; stop and resume after midnight UTC instead of sleeping in a loop.