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: Pass webhook_url to POST /enrich/bulk and Cleanlist sends one POST (enrichment.completed) when the run finishes. status is completed, completed_with_errors or failed; summary.failed splits into missed, errors and throttled. Up to 5 attempts, waits of 1, 2, 4 and 8 seconds, 10-second timeout, 2xx only. Payloads are not signed. Check attempts with GET /webhooks/deliveries.
Webhooks
Webhooks are opt-in and exist only on the v1 bulk endpoint. Pass a webhook_url with a bulk enrichment and Cleanlist POSTs the summary and every contact's result to it once the run finishes.
Opting in
Add webhook_url to the request body:
{
"enrichment_type": "partial",
"webhook_url": "https://your-app.com/webhooks/cleanlist",
"contacts": [
{ "linkedin_url": "https://www.linkedin.com/in/janedoe" }
]
}There is no separate registration step: the URL applies to that one run. Use a public HTTPS URL.
Outbound payload
When the run finishes, Cleanlist sends one request:
POST /webhooks/cleanlist HTTP/1.1
Host: your-app.com
Content-Type: application/json{
"event": "enrichment.completed",
"workflow_id": "public_bulk_4d6f2c3a-1b2e-4a5b-9c8d-3e2f1a0b9d8e",
"lead_list_id": "550e8400-e29b-41d4-a716-446655440000",
"enrichment_type": "partial",
"status": "completed_with_errors",
"summary": {
"total": 3,
"successful": 2,
"failed": 1,
"missed": 1,
"errors": 0,
"throttled": 0,
"emails_found": 2,
"phones_found": 0
},
"results": [
{
"task_id": "task_id_1",
"status": "completed",
"prospect_id": "prospect_uuid_1",
"lead_id": "lead_uuid_1",
"result": {
"email": "john.doe@acme.com",
"email_status": "verified",
"other_emails": [],
"phones": [],
"provider": "wiza",
"linkedin_url": "https://www.linkedin.com/in/johndoe"
},
"error": null,
"input": {
"linkedin_url": "https://www.linkedin.com/in/johndoe",
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"company_domain": "acme.com",
"company_name": "Acme Corp"
}
}
],
"results_limit": 500,
"results_truncated": 0,
"results_endpoint": "/api/v1/public/enrich/status?workflow_id=public_bulk_4d6f2c3a-...",
"completed_at": "2026-10-07T15:00:42Z"
}Field reference
| Field | Type | Description |
|---|---|---|
event | string | Always "enrichment.completed" |
workflow_id | string | The run, as returned by POST /enrich/bulk |
lead_list_id | string | Your Extension Leads list, where the contacts were saved |
enrichment_type | string | partial, phone_only or full |
status | string | completed, completed_with_errors or failed (see below) |
summary.total | int | Contacts in the run |
summary.successful | int | Contacts that returned data |
summary.failed | int | Contacts that didn't. Always missed + errors + throttled |
summary.missed | int | No data was found for the contact |
summary.errors | int | Enrichment could not finish for the contact. Worth retrying |
summary.throttled | int | A data provider rate-limited the lookup. Worth retrying later |
summary.emails_found | int | Emails found across the run |
summary.phones_found | int | Phones found across the run |
results | array | One object per contact (see below) |
results_limit | int | Maximum results in one payload (500) |
results_truncated | int | Results left out because of results_limit. A v1 run has at most 250 contacts, so this is 0 |
results_endpoint | string | The status-poll path for this run (run-level counts) |
completed_at | ISO 8601 datetime | When the run finished |
Run status
status | Meaning |
|---|---|
completed | Every contact succeeded |
completed_with_errors | The run finished and at least one contact failed (missed, error or throttled) |
failed | The run finished and no contact succeeded |
A failed run is still delivered, so handle it in your receiver rather than waiting for a second callback.
Result objects
| Field | Description |
|---|---|
task_id | Matches an entry in task_ids from the bulk response |
status | completed or failed |
prospect_id, lead_id | The person and the lead in your Extension Leads list |
result | email, email_status, other_emails, phones, provider, linkedin_url. Can be null when the contact couldn't be processed |
error | Why the contact failed, for example "Enrichment could not finish for this row. Retry the row." null on success |
input | The identifiers you sent: linkedin_url, first_name, last_name, full_name, company_domain, company_name |
Delivery
| Property | Value |
|---|---|
| Method | POST |
| Content type | application/json |
| Timeout | 10 seconds per attempt |
| Success | Any 2xx response |
| Attempts | Up to 5 |
| Wait between attempts | 1, 2, 4, then 8 seconds |
| Redirects | Not followed. A 3xx counts as a failed attempt |
| Signature | None |
| Idempotency key | Use workflow_id |
A non-2xx response, a timeout or a connection error triggers the next attempt. After 5 failed attempts the delivery is marked failed and not retried again. The results are still saved in your Extension Leads list, and per-contact status can be polled for 24 hours (see Enrichment).
You may occasionally receive the same workflow_id twice, for example when your endpoint processed a request but answered after the 10-second timeout. Make your receiver idempotent.
Webhook payloads are not signed and carry no custom headers. Store the workflow_id of every run you start and ignore callbacks for any other workflow_id.
Inspecting delivery history
GET /api/v1/public/webhooks/deliveries
curl "https://api.cleanlist.ai/api/v1/public/webhooks/deliveries?workflow_id=public_bulk_4d6f2c3a-...&limit=50" \
-H "Authorization: Bearer clapi_your_api_key"Query parameters
| Parameter | Type | Default | Range |
|---|---|---|---|
workflow_id | string | required | |
limit | int | 50 | 1 to 200 |
Response
Attempts for runs in your workspace, newest first:
[
{
"id": "delivery-uuid",
"webhook_id": "https://your-app.com/webhooks/cleanlist",
"workflow_id": "public_bulk_4d6f2c3a-...",
"event_type": "enrichment.completed",
"attempt_number": 1,
"status": "delivered",
"response_status_code": 200,
"error_message": null,
"duration_ms": 184,
"created_at": "2026-10-07T15:00:42Z"
}
]| Field | Description |
|---|---|
webhook_id | The URL Cleanlist posted to |
event_type | enrichment.completed |
attempt_number | 1 to 5 |
status | delivered or failed |
response_status_code | Your endpoint's HTTP status, or null if the request never got a response |
error_message | Why the attempt failed (for example "Non-success response from webhook endpoint: 500" or a timeout), null on success |
duration_ms | How long the attempt took |
Every attempt is its own row, so 3 failures followed by a success give 4 rows. An empty list means no webhook was sent for that run (no webhook_url, or the run hasn't finished yet).
Implementing a receiver
A minimal Express handler:
import express from "express";
const app = express();
app.use(express.json({ limit: "10mb" }));
const KNOWN_WORKFLOWS = new Set(); // fill from your database
app.post("/webhooks/cleanlist", (req, res) => {
const { workflow_id, status, summary, results } = req.body;
if (!KNOWN_WORKFLOWS.has(workflow_id)) {
return res.status(404).end(); // not a run you started
}
// Save first, answer fast, process in the background.
saveResults(workflow_id, status, summary, results).catch(console.error);
res.status(200).end();
});
app.listen(3000);A minimal Flask handler:
from flask import Flask, request
app = Flask(__name__)
KNOWN_WORKFLOWS = set() # fill from your database
@app.post("/webhooks/cleanlist")
def cleanlist_webhook():
body = request.get_json(silent=True) or {}
if body.get("workflow_id") not in KNOWN_WORKFLOWS:
return "", 404
enqueue_processing(body) # process asynchronously
return "", 200See Receiving webhooks for production patterns.
Related
- Enrichment: start a run with
webhook_url - Receiving webhooks: production receiver patterns
- Errors and rate limits