Audience: developers. API keys and the REST API are included on Pro and Enterprise (and legacy Pro and Scale plans) and on AppSumo Tiers 5 to 7. They are not available on Free, Starter, AppSumo Tiers 1 to 4 or during the Pro trial. Support agent: confirm the user's plan includes API access before giving API advice.
TL;DR: Most v1 errors return {"error": {"code", "message"}} with an UPPERCASE code. Body validation (422) returns the lowercase public envelope with code: "validation_error". Handle 401 (bad key), 402 (not enough credits for the run), 422 (bad body) and 429 (60 requests a minute per workspace, with Retry-After).
Errors
The v1 API uses standard HTTP status codes and wraps every error in JSON.
Error formats
Standard envelope
Every error except 422 uses this shape:
{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Insufficient credits. Required: 33.0 for 3 prospects, Available: 12. Please purchase more credits to continue.",
"trace_id": "f7a1b3c2..."
}
}| Field | Always present | Meaning |
|---|---|---|
code | Yes | UPPERCASE code derived from the status. Branch on this, not on message |
message | Yes | What went wrong |
details | No | Extra structured context |
trace_id | No | Include it when you contact support |
| Status | code |
|---|---|
400 | BAD_REQUEST |
401 | AUTHENTICATION_INVALID |
402 | INSUFFICIENT_CREDITS |
403 | PERMISSION_DENIED |
404 | RESOURCE_NOT_FOUND |
429 | RATE_LIMITED |
500 | INTERNAL_ERROR |
Validation envelope (422)
When the request body fails validation, the response uses the lowercase public envelope shared with the v2 API:
{
"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_a1b2c3d4e5f6a7b8",
"details": {
"errors": [
{
"type": "value_error",
"loc": ["body", "contacts", 7],
"msg": "Value error, Each contact must include linkedin_url OR first_name + last_name + (company_domain or company_name). To discover people at a company from a domain alone, call POST /api/v2/people/find first, then pass the resulting rows back to /enrich/bulk."
}
]
}
}
}details.errors[].loc tells you which field failed: here contacts[7]. The v2 API uses this envelope for all its errors; see v2 errors.
Status codes
| Status | When you'll see it on v1 |
|---|---|
200 | Success |
201 | Folder created (POST /folders) |
400 | No contacts, more than 250 contacts, every contact already in Extension Leads, both or neither of workflow_id and task_id, invalid parent_id |
401 | Missing, invalid, expired or revoked API key |
402 | Balance below the worst-case cost of a bulk run |
403 | Plan doesn't include API access, or the run belongs to another workspace ("Access denied") |
404 | Task, run or parent folder not found |
422 | Body failed validation |
429 | Rate limit exceeded |
500 | Unexpected server error |
Common errors and fixes
400 Bad Request
message | Fix |
|---|---|
| "At least one contact is required." | Send at least one contact |
| "Bulk enrichment supports up to 250 contacts per request." | Split the batch into requests of 250 or fewer |
| "All N prospects are duplicates already in this list." | Every contact is already in your Extension Leads list. Nothing new to enrich |
| "Provide exactly one of workflow_id or task_id." | Send one query parameter to /enrich/status, not both |
| "Invalid parent_id." | Send a folder UUID, or leave parent_id out |
401 Unauthorized
{ "error": { "code": "AUTHENTICATION_INVALID", "message": "Invalid or expired API key" } }| Cause | Fix |
|---|---|
Missing Authorization header (message "Not authenticated") | Add Authorization: Bearer clapi_... |
Wrong scheme, such as X-API-Key or Basic auth | Use Bearer |
| Key revoked or expired | Create a new key in Settings → API keys |
Test a key with GET /api/v1/public/auth/validate-key.
402 Payment Required
The balance doesn't cover contacts times the full price of the enrichment_type (1, 10 or 11). Nothing is queued or charged. Buy credits in Settings → Plans & billing → Buy more credits, or send fewer contacts or a cheaper type, then resend. See Credits.
403 Forbidden
message | Fix |
|---|---|
| "Public API access is not included during the trial. Use the signed-in app or choose a paid plan." | Choose a plan with API access in Settings → Plans & billing |
| "Public API access is not included in the current plan. Choose an eligible plan to use this key." | Move to Pro or Enterprise |
| "Access denied" | You're polling a run or task from another workspace. Use a key from the workspace that started it |
404 Not Found
message | Fix |
|---|---|
| "Task not found" | The task_id is wrong, or older than 24 hours. Read the contact from your Extension Leads list instead |
| "Folder not found." | parent_id doesn't exist or isn't a folder you created |
422 Unprocessable Entity
The most common cause is a contact without enough identifiers. Each contact needs either:
linkedin_url, orfirst_name+last_name+ (company_domainorcompany_name)
Blank strings count as missing. An enrichment_type other than partial, phone_only or full, or a webhook_url that isn't a valid URL, also returns 422.
429 Too Many Requests
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Public API is limited to 60 requests per minute per organization."
}
}- 60 requests a minute per workspace, counted across every key, member and the Claude connector.
- 30 a minute for workspaces on an AppSumo deal without a paid Cleanlist plan. Adding a paid plan restores 60.
- The limit resets at the start of each clock minute. The
Retry-Afterheader gives the seconds to wait. - v2 endpoints also have a limit of 30 requests a minute per API key; v1 endpoints count only toward the workspace limit.
Retry strategy
import random
import time
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
wait = int(r.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait + random.uniform(0, 0.5))
r.raise_for_status()500 Internal Server Error
Something failed on Cleanlist's side. Retry with backoff. If it keeps happening, email support@cleanlist.ai with the trace_id and the time of the request.
Webhook delivery errors
A 200 from POST /enrich/bulk doesn't mean the webhook was delivered later; delivery is tracked separately. Check GET /api/v1/public/webhooks/deliveries?workflow_id=...: each attempt is delivered or failed, with your endpoint's status code and an error message. See Webhooks.
Defensive coding
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, revoked or expired")
if r.status_code == 402:
raise RuntimeError(r.json()["error"]["message"])
if r.status_code == 422:
raise ValueError(r.json()["error"]["details"]["errors"])
if r.status_code == 429:
raise TransientError(r.headers.get("Retry-After"))
r.raise_for_status()
return r.json()