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 comes back as { "error": { "code", "message", ... } } JSON with a stable UPPERCASE code. The most important statuses to handle: 401 (bad key), 402 (out of credits), 422 (validation), 429 (rate limit).
Errors
The Cleanlist Public API uses standard HTTP status codes, and every error response is wrapped in a single JSON envelope.
Error format
All errors share one shape:
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Lead list not found",
"details": { },
"trace_id": "f7a1b3c2..."
}
}| Field | Always present | Meaning |
|---|---|---|
code | yes | Stable, machine-readable UPPERCASE code — branch on this, not on message text |
message | yes | Human-readable description |
details | no | Structured context (e.g. the per-field list on validation errors) |
trace_id | no | Request trace id — include it in support requests when present |
The code is derived from the error: BAD_REQUEST (400), AUTHENTICATION_REQUIRED / AUTHENTICATION_INVALID (401), INSUFFICIENT_CREDITS (402), PERMISSION_DENIED / FEATURE_NOT_ENABLED (403), RESOURCE_NOT_FOUND (404), RESOURCE_CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500), UPSTREAM_ERROR (502), NOT_IMPLEMENTED (501). The one exception is 422, which on the public API (/api/v1/public/*) emits the lowercase public envelope (code: "validation_error", problem, fix, docs_url, request_id) — see below.
422 validation errors carry a structured array under details.errors describing each invalid field:
{
"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.",
"docs_url": "https://docs.cleanlist.ai/errors/validation-error",
"request_id": "req_a1b2c3d4e5f6a7b8",
"details": {
"errors": [
{
"type": "value_error",
"loc": ["body", "contacts", 0],
"msg": "Each contact must include linkedin_url OR first_name + last_name + (company_domain or company_name)."
}
]
}
}
}The newer MCP API endpoints use a richer lowercase-code envelope (problem, fix, retryable, docs_url, ...) — see MCP API Errors. The legacy endpoints documented in this section use the UPPERCASE shape above for every status except 422, which already emits the lowercase public envelope shown above (code: "validation_error", problem, fix, docs_url, request_id).
Status code reference
| Code | Meaning | When you'll see it |
|---|---|---|
200 | OK | Successful GET / POST |
400 | Bad Request | Empty contacts, more than 250 contacts, supplying both workflow_id and task_id |
401 | Unauthorized | Missing, malformed, expired, or revoked API key |
402 | Payment Required | Insufficient credits |
403 | Forbidden | Your plan does not include API access |
404 | Not Found | Workflow id, task id, or webhook id does not exist |
422 | Unprocessable Entity | Pydantic validation error (bad field types, missing required combos) |
429 | Too Many Requests | Public API rate limit exceeded |
500 | Internal Server Error | Unexpected backend failure — please report it |
503 | Service Unavailable | Temporary backend outage; retry with backoff |
Common errors and how to fix them
401 Unauthorized
{ "error": { "code": "AUTHENTICATION_INVALID", "message": "Not authenticated" } }| Cause | Fix |
|---|---|
Missing Authorization header | Add Authorization: Bearer clapi_... |
Wrong scheme (e.g., X-API-Key or basic auth) | Use Bearer |
| Key was revoked | Generate a new one in the portal |
| Key has expired | Generate a new one with a longer / no expiration |
Confirm with GET /api/v1/public/auth/validate-key.
402 Payment Required
{ "error": { "code": "INSUFFICIENT_CREDITS", "message": "Insufficient credits" } }| Cause | Fix |
|---|---|
| Organization credit balance is below the cost of the request | Top up in the portal (opens in a new tab) under Settings → Billing |
Failed enrichments cost zero credits, so retries after a top-up are safe.
400 Bad Request
The code is BAD_REQUEST; the message tells you what to fix:
message | Cause |
|---|---|
"At least one contact is required." | You sent an empty contacts array |
"Bulk enrichment supports up to 250 contacts per request." | You sent more than 250 contacts |
"Provide exactly one of workflow_id or task_id." | You hit /enrich/status with both, or with neither |
Split large batches client-side; the 250-contact cap is hard.
422 Unprocessable Entity
The most common validation failure is a contact missing the required field combo. Each contact must include either:
linkedin_url, orfirst_name+last_name+ (company_domainorcompany_name)
Inspect error.details.errors[].loc to find which contact index is offending:
{
"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.",
"docs_url": "https://docs.cleanlist.ai/errors/validation-error",
"request_id": "req_a1b2c3d4e5f6a7b8",
"details": {
"errors": [
{
"type": "value_error",
"loc": ["body", "contacts", 7],
"msg": "Each contact must include linkedin_url OR first_name + last_name + (company_domain or company_name)."
}
]
}
}
}In this example, contacts[7] is the bad row.
429 Too Many Requests
{ "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded" } }The legacy public endpoints documented here (/enrich/bulk, /enrich/status, /auth/validate-key, /webhooks/deliveries, /folders) are bounded by a single rate limit:
- 60 requests/minute per organization — all keys and members combined
A separate 30 requests/minute per API key cap (each individual clapi_ key) applies to the newer v2 / MCP endpoints, not these legacy single-file endpoints.
Exceeding the limit returns 429. Size batch loops to stay under it and back off on 429.
Recommended retry strategy
import time
import random
import requests
def call_with_backoff(method, url, **kwargs):
for attempt in range(6): # 1 try + 5 retries
r = requests.request(method, url, **kwargs)
if r.status_code != 429:
return r
sleep_for = (2 ** attempt) + random.uniform(0, 0.5)
time.sleep(sleep_for)
r.raise_for_status()Use exponential backoff with jitter. Avoid tight loops on the same endpoint.
500 Internal Server Error
Something on Cleanlist's side broke. The response body usually includes a short reason. Treat it like a transient failure: retry with backoff. If it persists, email support with the request id (visible in your portal API request log) and we'll investigate.
Webhook delivery errors
Webhook deliveries are tracked separately from the API call that submitted them. A successful POST /enrich/bulk returning 200 does not guarantee that the webhook was delivered — those are separate events.
To inspect delivery results, query GET /api/v1/public/webhooks/deliveries?workflow_id=.... Each row's status is either delivered or failed, with the response code and error message attached. See Webhooks for the full schema.
Defensive coding patterns
def safe_enrich(payload, api_key):
r = requests.post(
"https://api.cleanlist.ai/api/v1/public/enrich/bulk",
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
)
if r.status_code == 401:
raise RuntimeError("Cleanlist API key is invalid or revoked")
if r.status_code == 402:
raise RuntimeError("Out of credits — top up at portal.cleanlist.ai")
if r.status_code == 422:
# Surface field-level errors so the caller can fix the input
raise ValueError(r.json()["error"]["details"]["errors"])
if r.status_code == 429:
# Retry with backoff
raise TransientError("Rate limited")
r.raise_for_status()
return r.json()Related
- Authentication — managing keys
- Credits — balance and pricing
- Enrichment — bulk endpoint contract
- Webhooks — webhook delivery semantics