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: Full lead-list CRUD plus folders. Create a list, add leads for free (by cohort task_id to save search results, or by lead_ids for existing leads), read leads with phone and full_name, update name, description or folder, delete a list, remove leads, import a CSV with optional bulk enrichment, or find people at the list’s companies.
Lead Lists
Lead lists are how you organize contacts in CluGTM, the same Lead Lists you see in the app. API v2 exposes full CRUD plus CSV import.
| Endpoint | Method | Scope |
|---|---|---|
| Create | POST /lead-lists | lists:write |
| List | GET /lead-lists | lists:read |
| Get one | GET /lead-lists/{list_id} | lists:read |
| Update | PATCH /lead-lists/{list_id} | lists:write |
| Delete | DELETE /lead-lists/{list_id} | lists:write + lists:delete (opt-in) |
| List leads | GET /lead-lists/{list_id}/leads | lists:read |
| Add leads | POST /lead-lists/{list_id}/leads | lists:write |
| Remove leads | DELETE /lead-lists/{list_id}/leads | lists:write + lists:delete (opt-in) |
| CSV import | POST /lead-lists/{list_id}/csv-import | lists:write (+ enrich:write if dispatching) |
| Find people | POST /lead-lists/{list_id}/find-people | lists:write |
| List folders | GET /folders | lists:read |
| Create folder | POST /folders | lists:write |
| Rename folder | PATCH /folders/{folder_id} | lists:write |
| Delete folder | DELETE /folders/{folder_id} | lists:write |
Create
POST /lead-lists
Creates a private list in your workspace. Returns 201.
Create is idempotent on name (case-insensitive) for the same owner: if you already own a list with that name, no new list is created and the endpoint returns 200 with the existing list and reused: true.
Request body
| Field | Type | Notes |
|---|---|---|
name | string | Required. 1 to 200 characters |
description | string | Optional |
folder_id | string | Optional. A folder you created (owned: true in GET /folders); anything else returns 404 folder_not_found |
curl -X POST https://api.cleanlist.ai/api/v2/lead-lists \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Q3 Outbound: Fintech VPs"}'{
"list_id": "9c1f...",
"name": "Q3 Outbound: Fintech VPs",
"description": null,
"lead_count": 0,
"enriched_count": 0,
"folder_id": null,
"created_at": "2026-06-03T10:00:00Z",
"timestamp_ms": 1717430400000
}If your plan’s list limit is reached, create returns 403 list_cap_reached; delete a list or upgrade, then retry.
List
GET /lead-lists
Pages through the lists you can access: owned, organization-wide and shared.
| Query param | Default | Range |
|---|---|---|
limit | 50 | 1 to 100 |
cursor | none | opaque cursor from the previous page |
folder_id | none | filter to one folder |
{
"lists": [
{
"list_id": "9c1f...",
"name": "Q3 Outbound: Fintech VPs",
"lead_count": 128,
"folder_id": null,
"created_at": "2026-06-03T10:00:00Z",
"owner_name": "Your Name",
"visibility": "private"
}
],
"total": 14,
"cursor": "50",
"timestamp_ms": 1717430400000
}visibility is one of private, organization or shared. owner_name is the list creator’s display name (may be null).
Get one
GET /lead-lists/{list_id}
{
"list_id": "9c1f...",
"name": "Q3 Outbound: Fintech VPs",
"description": null,
"lead_count": 128,
"enriched_count": 96,
"folder_id": null,
"created_at": "2026-06-03T10:00:00Z",
"timestamp_ms": 1717430400000
}A list that doesn’t exist, or that belongs to another workspace, returns 404 list_not_found.
Update
PATCH /lead-lists/{list_id}
Scope: lists:write
Rename a list, update its description, or move it to a different folder. Only the fields you send change. Returns the updated list object.
Request body
| Field | Type | Notes |
|---|---|---|
name | string | Optional. 1 to 200 characters |
description | string | Optional |
folder_id | string | Optional. A folder you created; null to remove it from any folder |
curl -X PATCH https://api.cleanlist.ai/api/v2/lead-lists/9c1f... \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Q3 Outbound: Fintech VPs (revised)", "folder_id": "4b0e2c9a-..."}'{
"list_id": "9c1f...",
"name": "Q3 Outbound: Fintech VPs (revised)",
"description": null,
"lead_count": 128,
"enriched_count": 96,
"folder_id": "4b0e2c9a-...",
"created_at": "2026-06-03T10:00:00Z",
"timestamp_ms": 1717430400000
}A list shared with you as view-only returns 403 list_not_editable on any write.
Delete
DELETE /lead-lists/{list_id}
Scope: lists:write, and the key must have opted into lists:delete
Permanently deletes a list and removes all its lead memberships. Lead records that also belong to other lists stay in those lists. Returns HTTP 200 with a small confirmation body:
curl -X DELETE https://api.cleanlist.ai/api/v2/lead-lists/9c1f... \
-H "Authorization: Bearer clapi_your_api_key"{ "list_id": "9c1f...", "deleted": true, "timestamp_ms": 1717430400000 }Deletion is permanent. Protected system lists (such as “Extension Leads”) can’t be renamed or deleted and return 403 list_protected.
Deleting a list or removing its leads needs the lists:delete opt-in scope on top of lists:write. A key that hasn’t opted in gets 403 opt_in_scope_required with details.required_scope: "lists:delete", and nothing is deleted. Tick Delete lead lists and leads on the key under Settings → API keys → Permissions, or delete the list in CluGTM. The CluGTM MCP deletes only after the person approves in the host app’s own approval prompt; without it the call returns 403 approval_channel_required.
List leads
GET /lead-lists/{list_id}/leads
Pages through the leads in a list, newest first.
| Query param | Default | Range |
|---|---|---|
limit | 100 | 1 to 500 |
cursor | none | opaque cursor from the previous page |
snapshot_id | none | The previous page’s snapshot_id; use with offset |
offset | none | Position to start at. Wins over the position in cursor |
cursor and snapshot_id are opaque. Each walk reads one frozen order, so leads added while you page cause no duplicates or skips. Either keep passing cursor (it carries the snapshot), or pass snapshot_id plus an offset. If that snapshot has expired you get 409 snapshot_expired: start again without snapshot_id, offset or cursor. A lead deleted after the snapshot simply drops off its page.
Lead row schema
| Field | Type | Notes |
|---|---|---|
lead_id | string | Stable lead id |
first_name | string | |
last_name | string | |
full_name | string | Pre-composed display name |
email | string | Primary email (null until enriched) |
phone | string | Primary verified phone (null until phone-enriched) |
title | string | Job title |
company | string | Company name |
company_id | string | CluGTM id of the lead’s current company (null when unmatched). Pass it in company_ids to find people |
domain | string | Company website domain from the saved row |
location | string | Composed City, State, Country (null when unknown) |
linkedin_url | string | LinkedIn profile URL; use it to confirm enrichment matched the right person |
provider | string | Enrichment provider that found the contact data (null until enriched) |
{
"list_id": "9c1f...",
"leads": [
{
"lead_id": "lead_abc",
"first_name": "Jane",
"last_name": "Doe",
"full_name": "Jane Doe",
"email": "jane@acme.com",
"phone": "+14155550123",
"title": "VP Sales",
"company": "Acme",
"company_id": "3f2b6c1d-8a4e-4b7f-9c2d-1e5a6b7c8d9e",
"domain": "acme.com",
"location": "San Francisco, California, United States",
"linkedin_url": "https://www.linkedin.com/in/jane-doe-9a1b",
"provider": "leadmagic"
}
],
"total": 128,
"cursor": "100:view:{9c1f...}:...",
"snapshot_id": "view:{9c1f...}:...",
"pending_imports": [],
"pending_import_total": 0,
"pending_import_cursor": null,
"task_id": "cl-task_def...",
"timestamp_ms": 1717430400000
}email and phone follow your workspace’s reveal rules: a contact value your workspace hasn’t paid to reveal comes back null.
Pending imports
Rows from an import that haven’t become leads yet are returned separately, in pending_imports, never in leads. Each row has a raw_person_id (not a lead_id, so it can’t be used with add or remove), a status of pending, processing, failed or lookup_failed, the parsed identity fields (first_name, last_name, full_name, email, phone, title, company, company_domain, linkedin_url) and source_row_index. pending_import_total counts them and pending_import_cursor pages them; pass it as cursor with the same limit. Whole-list enrichment includes pending rows; an enrichment of selected lead_ids does not.
The response carries a task_id, a cohort handle for these leads. Pass it to POST /enrichment/by-task to enrich the page without listing every lead_id. For more columns (including smart-agent results), use GET /export/json.
Add leads
POST /lead-lists/{list_id}/leads
The body takes exactly one of two forms (sending both, or neither, returns 422 validation_error). Adding by filter isn’t supported; run a search first and save its cohort.
| Form | Body | Use it for |
|---|---|---|
| By cohort | { "task_id": "cl-task_...", "entity_ids": [...] } | Saving search results. task_id is the cohort handle from a POST /people/find (or list-leads) response. entity_ids is an optional subset of that cohort (up to 2000); omit it to save the whole cohort |
| By lead id | { "lead_ids": ["...", "..."] } | Adding leads that already exist in your workspace, for example from another list. 1 to 1000 ids |
Saving a search result? Use the cohort form (task_id). Search rows carry search-scoped ids, not lead ids, so they can’t be passed as lead_ids (that returns 404). Pass the search’s task_id, optionally narrowed by entity_ids, and CluGTM creates the matching leads in the list.
Both forms take an optional idempotency_key (up to 128 characters). Re-sending the same add with the same key inserts once, so a retry after a timeout is safe. Omit it and the server makes a fresh key for each request.
Adding leads is free: there is no per-lead import charge, and credits_charged is always 0. Enrichment is billed separately.
# Save a search result: pass the cohort handle (task_id), not lead_ids
curl -X POST https://api.cleanlist.ai/api/v2/lead-lists/9c1f.../leads \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{"task_id": "cl-task_abc123", "entity_ids": ["4482910", "4482911"], "idempotency_key": "save-2026-11-10-d4e5f6"}'# Add existing leads by id (for example, from another list)
curl -X POST https://api.cleanlist.ai/api/v2/lead-lists/9c1f.../leads \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{"lead_ids": ["lead_abc", "lead_def"]}'{ "list_id": "9c1f...", "added": 2, "skipped_duplicates": 0, "credits_charged": 0 }| Field | Meaning |
|---|---|
added | Leads newly added to the list |
skipped_duplicates | Leads already in the list, skipped |
created | Cohort saves only: new lead records created (versus reusing an existing lead for the same person) |
credits_charged | Always 0 |
A cohort handle expires an hour after the search; an expired or unknown task_id returns 404. To check what a handle holds before saving it, read it with GET /tasks/{task_id}.
If the save would push the list past your plan’s per-list contact limit, you get 403 lead_cap_reached. If a saved profile conflicts with the search result (different company or role), you get 409 search_result_conflict.
Remove leads
DELETE /lead-lists/{list_id}/leads
Scope: lists:write, and the key must have opted into lists:delete (see Delete)
Removes leads from a list by lead_ids (up to 100). This removes membership only: the lead stays in your workspace and in any other lists. Unknown ids are ignored.
curl -X DELETE https://api.cleanlist.ai/api/v2/lead-lists/9c1f.../leads \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{"lead_ids": ["lead_abc"]}'{ "list_id": "9c1f...", "removed": 1 }CSV import
POST /lead-lists/{list_id}/csv-import
Imports a CSV into a list and can start bulk enrichment in the same call. Returns 201. Needs lists:write; if dispatch_enrichment is true, also enrich:write.
The raw CSV is capped at about 150 KB or 600 rows; larger bodies return 413. Split bigger files before sending. Import itself is free; enrichment costs per lead (see Enrichment Types).
Request body
| Field | Type | Notes |
|---|---|---|
csv_content_base64 | string | Required. Base64-encoded UTF-8 CSV |
column_mapping | object | Required. { canonical_field: csv_header }. Canonical fields: first_name, last_name, full_name, email, phone, mobile_phone, work_phone, linkedin_url, company_name, company_domain, job_title, city, state, country |
dispatch_enrichment | boolean | Default true. Start bulk enrichment after import |
enrichment_type | enum | Required when dispatching: partial, phone_only or full |
quote_id | string | Optional signed quote (tool: "bulk_import_csv") |
phone_waterfall | enum | standard (default) or gb for the UK mobile waterfall on phone_only and full; see Enrichment Types |
idempotency_key | string | Optional. Replays the cached response for 24 hours (organization-wide), with no re-import and no re-charge |
Response
{
"list_id": "9c1f...",
"rows_parsed": 500,
"rows_imported": 488,
"rows_skipped": 12,
"skip_reasons": { "missing_required": 8, "duplicate_email": 4 },
"sample_skip_rows": [3, 17, 42],
"workflow_id": "bulk-enrich-9a1b...",
"enrichment_status_url": "/api/v2/enrichment/status/bulk-enrich-9a1b...",
"idempotency_replayed": false,
"timestamp_ms": 1717430400000
}B64=$(base64 -i leads.csv)
curl -X POST https://api.cleanlist.ai/api/v2/lead-lists/9c1f.../csv-import \
-H "Authorization: Bearer clapi_your_api_key" -H "Content-Type: application/json" \
-d "{
\"csv_content_base64\": \"$B64\",
\"column_mapping\": {\"first_name\": \"First\", \"last_name\": \"Last\", \"company_domain\": \"Domain\"},
\"dispatch_enrichment\": true,
\"enrichment_type\": \"partial\"
}"With an SDK: lead_lists.csv_import(...) (csvImport in TS and Java). See the SDKs.
After a dispatching import, poll the returned enrichment_status_url; see Enrichment status. If CSV import is switched off for maintenance, the endpoint returns 503 not_enabled.
Find people at a list’s companies
POST /lead-lists/{list_id}/find-people starts an async job that finds people (by title, seniority and location) at companies already in the list and imports them into it. Discovery and import are free. Poll GET /lead-lists/{list_id}/find-people/status/{workflow_id}. Full reference: People Finder API.
Folders
Folders organize lists, the same folders as in the app’s Lead Lists page. Folders belong to the workspace: every member sees every folder, and any member can rename or delete one. You can file a list only into a folder you created (owned: true). All four endpoints are free.
The folder endpoints aren’t available to AppSumo-only workspaces (a paid CluGTM plan on top lifts this). They return 403 feature_gated. A folder_id from another workspace returns 404 folder_not_found.
GET /folders
Scope: lists:read
Returns every folder in the workspace as a flat list, with no paging. Build a tree from parent_id if you need one.
{
"folders": [
{
"folder_id": "4b0e2c9a-...",
"name": "Q3 Campaigns",
"parent_id": null,
"list_count": 4,
"owned": true,
"created_at": "2026-06-28T14:32:00"
}
],
"total": 1,
"timestamp_ms": 1751404800000
}| Field | Meaning |
|---|---|
folder_id | Folder id; pass it as folder_id on create or update list |
parent_id | Parent folder, or null at the root |
list_count | Lists filed directly in this folder, from every owner |
owned | true when you created the folder |
POST /folders
Scope: lists:write · Returns 201
Creates a folder at the root. Body: name (required, 1 to 255 characters, trimmed). Create is idempotent on name: if you already own a folder with that name (case-insensitive), you get it back with reused: true instead of a duplicate.
curl -X POST https://api.cleanlist.ai/api/v2/folders \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Q3 Campaigns"}'{
"folder_id": "4b0e2c9a-...",
"name": "Q3 Campaigns",
"parent_id": null,
"list_count": 0,
"owned": true,
"created_at": "2026-06-28T14:32:00",
"reused": false,
"timestamp_ms": 1751404800000
}PATCH /folders/{folder_id}
Scope: lists:write
Renames a folder. Body: name (1 to 255 characters). An empty body returns 422. Returns the same object as create.
DELETE /folders/{folder_id}
Scope: lists:write
Deletes a folder and its child folders. Lists inside are unfiled (moved to the root), never deleted. Because that moves other members’ lists too, a folder that still holds lists returns 409 folder_not_empty with details.list_count, unless you pass ?force=true.
curl -X DELETE "https://api.cleanlist.ai/api/v2/folders/4b0e2c9a-...?force=true" \
-H "Authorization: Bearer clapi_your_api_key"{
"folder_id": "4b0e2c9a-...",
"deleted": true,
"lists_unfiled": 4,
"message": "Folder deleted; 4 lists moved to the root.",
"timestamp_ms": 1751404800000
}The folder endpoints are live on /api/v2 but aren’t in the served OpenAPI spec yet; call them over HTTP as shown.