New: meet Clu, and send email, LinkedIn and call steps with Sequences. Watch the videos →
Legacy API (v1)
Errors & Rate Limits
🔌

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..."
  }
}
FieldAlways presentMeaning
codeYesUPPERCASE code derived from the status. Branch on this, not on message
messageYesWhat went wrong
detailsNoExtra structured context
trace_idNoInclude it when you contact support
Statuscode
400BAD_REQUEST
401AUTHENTICATION_INVALID
402INSUFFICIENT_CREDITS
403PERMISSION_DENIED
404RESOURCE_NOT_FOUND
429RATE_LIMITED
500INTERNAL_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

StatusWhen you'll see it on v1
200Success
201Folder created (POST /folders)
400No contacts, more than 250 contacts, every contact already in Extension Leads, both or neither of workflow_id and task_id, invalid parent_id
401Missing, invalid, expired or revoked API key
402Balance below the worst-case cost of a bulk run
403Plan doesn't include API access, or the run belongs to another workspace ("Access denied")
404Task, run or parent folder not found
422Body failed validation
429Rate limit exceeded
500Unexpected server error

Common errors and fixes

400 Bad Request

messageFix
"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" } }
CauseFix
Missing Authorization header (message "Not authenticated")Add Authorization: Bearer clapi_...
Wrong scheme, such as X-API-Key or Basic authUse Bearer
Key revoked or expiredCreate 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

messageFix
"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

messageFix
"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, or
  • first_name + last_name + (company_domain or company_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-After header 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()

Related