TL;DR: Errors come back as { "detail": "..." } JSON. The most important codes 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 FastAPI's default JSON error format.
Error format
Most errors look like this:
{
"detail": "Human-readable error message"
}422 validation errors include a structured array describing each invalid field:
{
"detail": [
{
"type": "value_error",
"loc": ["body", "contacts", 0],
"msg": "Each contact must include linkedin_url OR first_name + last_name + (company_domain or company_name).",
"input": {"first_name": "John"}
}
]
}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
{ "detail": "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
{ "detail": "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
| Body | 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 detail[].loc to find which contact index is offending:
{
"detail": [
{
"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
{ "detail": "Rate limit exceeded" }The Public API enforces per-organization, per-endpoint rate limits to keep the platform fair. Limits are designed for normal automation patterns and are not published — back off on 429 and you should never hit them in practice.
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()["detail"])
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