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: API v2 is poll-based; it has no webhooks. Async calls return an id; poll the matching status endpoint until the status is terminal. Chain operations with the task_id cohort handle returned by searches and list reads. Status polls cost no credits. (If you need push delivery, the legacy v1 API offers webhooks on its enrich/bulk endpoint.)
Polling & Async Results
Several API v2 operations run in the background. This page explains how to get their results.
API v2 has no webhook or callback endpoint and no webhook_url parameter; the supported pattern is polling. If you need push delivery, the separate legacy v1 API accepts a webhook_url on its enrich/bulk endpoint and keeps a delivery log.
Which operations are async?
| Operation | Returns | Poll | Terminal statuses |
|---|---|---|---|
POST /enrichment/person | workflow_id | GET /enrichment/status/{workflow_id} | completed, completed_with_errors, failed, canceled, terminated |
POST /enrichment/bulk | workflow_id | GET /enrichment/status/{workflow_id} | completed, completed_with_errors, failed |
POST /enrichment/by-task (person) | workflow_id | GET /enrichment/status/{workflow_id} | same as bulk |
POST /lead-lists/{id}/csv-import (dispatching) | workflow_id + enrichment_status_url | GET /enrichment/status/{workflow_id} | same as bulk |
POST /smart-agents/run | smart_agent_task_id | GET /smart-agents/{smart_agent_task_id} | completed, failed |
POST /people/find-at-companies | task_id + poll_url | GET /people/find-at-companies/status/{task_id} | completed, failed |
POST /lead-lists/{id}/find-people | workflow_id + poll_url | GET /lead-lists/{id}/find-people/status/{workflow_id} | completed, failed, cancelled |
Synchronous operations (company enrichment, by-task for companies, CRM and sequencer sync and exports) return their results directly and need no polling. Sync responses include a workflow_id for consistency, but the counts are already final.
Spelling differs by job: enrichment uses canceled (one l), find-people uses cancelled (two). Compare against the exact values in the table.
Polling enrichment status
GET /enrichment/status/{workflow_id}
Scope: enrich:read · Cost: free
Poll until status is terminal: completed, completed_with_errors or failed. Single-record runs can also end canceled or terminated. A bulk run with any failed rows settles as completed_with_errors, not completed, so a loop that only checks for completed never exits; always treat completed_with_errors as terminal. The non-terminal states are pending and processing. The handle can be a workflow_id (enrich-..., bulk-enrich-...) or a cl-task_... cohort handle.
import time, requests
H = {"Authorization": "Bearer clapi_your_api_key"}
B = "https://api.cleanlist.ai/api/v2"
TERMINAL = ("completed", "completed_with_errors", "failed", "canceled", "terminated")
def wait_for(workflow_id, interval=5, timeout=900):
deadline = time.time() + timeout
while time.time() < deadline:
s = requests.get(f"{B}/enrichment/status/{workflow_id}", headers=H).json()
if s["status"] in TERMINAL:
return s
time.sleep(interval)
raise TimeoutError(workflow_id)const B = "https://api.cleanlist.ai/api/v2";
const H = { Authorization: "Bearer clapi_your_api_key" };
const TERMINAL = ["completed", "completed_with_errors", "failed", "canceled", "terminated"];
async function waitFor(workflowId, interval = 5000, timeout = 900000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const s = await (await fetch(`${B}/enrichment/status/${workflowId}`, { headers: H })).json();
if (TERMINAL.includes(s.status)) return s;
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(`timeout: ${workflowId}`);
}See Enrichment: Status for the full response, including the inline result for single-person runs and the settled credits_charged and credits_refunded.
Polling tips
- Free, but within rate limits. Status polls cost no credits but count toward the 60 requests per minute per organization (and 30 per key). Poll every few seconds; back off for long bulk jobs.
- Read the counters.
total,processed,completedandfailedshow live progress on bulk runs. - Watch
refund_status. A terminal failure on a prepaid run setsrefund_status: "pending_review"with a message to contact support; show it to the user. - Bulk results aren't inline. For bulk and cohort runs, read the enriched leads from the target list with
GET /lead-lists/{id}/leadsonce the workflow completes. - Smart-agent results grow.
GET /smart-agents/{id}returns the per-leadresultsthat are finished so far, so a running job returns a partial set.
Chaining with task_id
Many responses carry a task_id in the envelope, a short-lived cohort handle. It's the glue for multi-step workflows:
- Search:
POST /people/findreturns rows and atask_idfor the result cohort. - Save or enrich the cohort: pass that
task_idtoPOST /lead-lists/{id}/leads(free), or toPOST /enrichment/by-taskwith a quote. By-task returns aworkflow_id. - Poll: poll the
workflow_id(or thecl-task_...handle) withGET /enrichment/statusuntil terminal.
The same works for GET /lead-lists/{id}/leads, which returns a task_id for the page so you can enrich a page of an existing list the same way.
Cohort handles belong to your organization. They last about an hour, refreshed each time you use them, with a hard 24-hour limit. If a task_id has expired (404 task_not_found), run the original search again to get a fresh one.