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 9. 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: POST /enrich/bulk takes 1 to 250 contacts and a required lead_list_id, and returns a workflow_id plus one task_id per contact. Poll GET /enrich/status with exactly one of workflow_id or task_id, or pass a webhook_url to get one POST with every result. Contacts already in that list are skipped. Your balance must cover the worst case before the run starts, but you’re charged only for data found.
Enrichment
The v1 API has two enrichment endpoints:
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/public/enrich/bulk | Start an enrichment run for up to 250 contacts |
GET | /api/v1/public/enrich/status | Poll a run or a single contact |
Each run goes through CluGTM’s waterfall, which tries providers in sequence until it finds reliable data.
Bulk enrichment
POST /api/v1/public/enrich/bulk
cURL
curl -X POST https://api.cleanlist.ai/api/v1/public/enrich/bulk \
-H "Authorization: Bearer clapi_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"enrichment_type": "partial",
"webhook_url": "https://your-app.com/webhooks/cleanlist",
"lead_list_id": "your_lead_list_id",
"contacts": [
{
"first_name": "John",
"last_name": "Doe",
"company_domain": "acme.com"
},
{
"linkedin_url": "https://www.linkedin.com/in/janedoe"
},
{
"first_name": "Sam",
"last_name": "Lee",
"company_name": "Globex"
}
]
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
enrichment_type | string | No (default "partial") | partial, phone_only or full. See Enrichment types |
webhook_url | URL | No | CluGTM POSTs every result here once the run finishes. See Webhooks |
lead_list_id | string | Yes | The lead list the enriched contacts are saved to. It must be a list in your API key’s workspace that the key’s user can edit, and its per-list contact limit applies. Extension Leads can’t be used. Copy the id from the list’s URL in the app (app.cleanlist.ai/lists/<id>) or get it from GET /api/v2/lead-lists |
contacts | array | Yes | 1 to 250 contact objects |
Contact object
Each contact must include either:
linkedin_url(gives the best match rate), orfirst_name+last_name+ (company_domainorcompany_name)
| Field | Type | Notes |
|---|---|---|
linkedin_url | string | LinkedIn profile URL. Enough on its own |
first_name | string | Required when there is no LinkedIn URL |
last_name | string | Required when there is no LinkedIn URL |
company_domain | string | For example "acme.com". More accurate than the name |
company_name | string | Use when you don’t have the domain |
Values are trimmed, and blank strings count as missing. A contact with only a company domain fails validation: to find people at a company, use the v2 Find People API first, then enrich the people it returns.
Duplicates
All contacts are saved to the list you pass as lead_list_id, and CluGTM skips duplicates before charging:
- A contact whose LinkedIn URL is already in that list is skipped.
- A contact sent without a LinkedIn URL is skipped when the same name and company is already in the list.
- A contact repeated inside the same request is enriched once.
Skipped contacts are not enriched or charged, and the message in the response says how many were skipped. If every contact is a duplicate, the request fails with 400 “All N prospects are duplicates already in this list.”
Credit pre-check
Before the run starts, your workspace balance must cover the worst case: the number of contacts (after duplicates are removed) times the price of the enrichment_type (1 for partial, 10 for phone_only, 11 for full). Otherwise the request fails with 402, for example:
{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Insufficient credits. Required: 33.0 for 3 prospects, Available: 12. Please purchase more credits to continue."
}
}You’re charged only for data found. See Credits.
Response
{
"workflow_id": "public_bulk_4d6f2c3a-1b2e-4a5b-9c8d-3e2f1a0b9d8e",
"status": "processing",
"message": "Queued 3 out of 3 prospects",
"task_ids": [
"task_id_1",
"task_id_2",
"task_id_3"
],
"total_contacts": 3
}| Field | Description |
|---|---|
workflow_id | The run. Use it to poll status and to look up webhook deliveries |
status | Initial state, usually processing |
message | Summary, including how many duplicates were skipped |
task_ids | One per accepted contact, for per-contact polling |
total_contacts | Number of contacts accepted into the run |
Request errors
| Condition | Status | Message or code |
|---|---|---|
Empty contacts | 400 | “At least one contact is required.” |
| More than 250 contacts | 400 | “Bulk enrichment supports up to 250 contacts per request.” |
| Every contact already in the list | 400 | “All N prospects are duplicates already in this list.” |
No lead_list_id | 422 | validation_error |
lead_list_id isn’t a UUID | 422 | validation_error |
lead_list_id isn’t a list in your workspace | 404 | list_not_found |
| You can view the list but not edit it | 403 | list_not_editable |
lead_list_id is your Extension Leads list | 403 | extension_leads_locked |
| The new contacts would take the list past its per-list contact limit | 403 | PERMISSION_DENIED, with details.error lead_cap_reached |
A contact without enough identifiers, or an unknown enrichment_type | 422 | validation_error |
| Balance below the worst-case cost | 402 | INSUFFICIENT_CREDITS |
| Missing or invalid key | 401 | See Authentication |
| Rate limit exceeded | 429 | See Errors |
validation_error, list_not_found, list_not_editable and extension_leads_locked come in the lowercase public envelope (error.code, error.problem, error.fix). The other errors, including the per-list limit, use the standard v1 envelope with an UPPERCASE error.code; for the limit, error.details also carries limit, current_count, remaining and attempted. See Errors.
Polling status
GET /api/v1/public/enrich/status
Poll by workflow_id (the whole run) or by task_id (one contact). Send exactly one: both or neither returns 400 “Provide exactly one of workflow_id or task_id.”
cURL
# Whole run
curl "https://api.cleanlist.ai/api/v1/public/enrich/status?workflow_id=public_bulk_4d6f2c3a-..." \
-H "Authorization: Bearer clapi_your_api_key"
# One contact
curl "https://api.cleanlist.ai/api/v1/public/enrich/status?task_id=task_id_1" \
-H "Authorization: Bearer clapi_your_api_key"Query parameters
| Parameter | Description |
|---|---|
workflow_id | The workflow_id from POST /enrich/bulk |
task_id | One of the task_ids from the same response |
Response by workflow_id
Run-level progress and counts:
{
"workflow": {
"workflow_id": "public_bulk_4d6f2c3a-...",
"status": "completed",
"enrichment_type": "partial",
"lead_list_id": "550e8400-e29b-41d4-a716-446655440000",
"total_prospects": 3,
"completed_prospects": 2,
"failed_prospects": 1,
"emails_found": 2,
"phones_found": 0,
"created_at": "2026-10-07T15:00:00Z",
"modified_at": "2026-10-07T15:00:42Z"
}
}Other fields may appear, such as opted_out_prospects (the failed contacts whose person opted out of data enrichment), and empty fields are left out. The run-level response doesn’t list each contact’s data: for that, poll each task_id, use a webhook, or read the list you passed as lead_list_id.
Response by task_id
One contact’s state and result:
{
"task": {
"task_id": "task_id_1",
"workflow_id": "public_bulk_4d6f2c3a-...",
"status": "completed",
"lead_id": "lead_uuid_1",
"result": {
"email": "john.doe@acme.com",
"email_status": "verified",
"phone": null,
"provider": "wiza",
"linkedin_url": "https://www.linkedin.com/in/johndoe"
},
"credits_charged": 1
}
}A contact that found nothing has status: "failed" and an error message such as “No verified work email was found.” A failed task can also carry:
| Field | Description |
|---|---|
failure_kind | Why it failed, for example miss (no data), error, rate_limited or person_opted_out |
failure_class | no_data, provider_error or timeout. Tells a provider outage apart from “no data found” |
credits_refunded | Credits given back for the contact, if any |
These fields are left out when they don’t apply.
Status values
status | Meaning |
|---|---|
processing | The run is still going |
completed | Every contact finished and none failed |
completed_with_errors | The run finished; at least one contact found nothing or hit an error |
failed | The run finished and no contact succeeded |
Per-contact (task) statuses are processing, completed and failed.
How long status is kept
- By
task_id: 24 hours after the contact’s last update. - By
workflow_id: about 7 days.
After that, the data lives on in the list you passed as lead_list_id. Read it in the app, or with GET /api/v2/lead-lists/{id}/leads (scope lists:read), using the lead_list_id from the run.
Polling or webhooks
You can use both at once.
| Approach | Best for |
|---|---|
Polling GET /enrich/status | Scripts, testing, jobs you run end to end |
Webhook (webhook_url) | Production pipelines and serverless functions that shouldn’t keep a loop running |
With webhook_url, CluGTM sends one POST with the summary and every contact’s result when the run finishes. See Webhooks.
Related
- Enrichment types: what each
enrichment_typereturns - Webhooks: callback payload and delivery logs
- Credits: costs and the
402pre-check - Lead lists: choosing the list v1 saves to
- API quickstart: end-to-end walkthrough
- v2 enrichment: the current enrichment API