TL;DR: Pass webhook_url to /public/enrich/bulk to receive a single POST when the workflow finishes. Cleanlist retries up to 5 times with exponential backoff. Inspect delivery history via GET /public/webhooks/deliveries.
Webhooks
Webhooks are the recommended way to receive enrichment results in production. Instead of polling, you supply a URL and Cleanlist POSTs the complete result set to it once the workflow finishes.
Opting in
Add webhook_url to your bulk enrichment request:
{
"enrichment_type": "partial",
"webhook_url": "https://your-app.com/webhooks/cleanlist",
"contacts": [...]
}That's the entire setup. There is no separate webhook registration step — webhooks are scoped to a single workflow.
Outbound payload
When the workflow completes, Cleanlist sends a single POST request to your webhook_url:
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",
"summary": {
"total": 3,
"successful": 2,
"failed": 1,
"emails_found": 2,
"phones_found": 0
},
"results": [
{
"task_id": "task_uuid_1",
"status": "completed",
"primary_email": "john.doe@acme.com",
"primary_email_status": "reliable",
"personal_phone_numbers": [],
"prospect": {
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe"
},
"company": {
"name": "Acme Corp",
"domain": "acme.com"
}
}
],
"results_limit": 250,
"results_truncated": 0,
"results_endpoint": "/api/v1/public/enrich/status?workflow_id=public_bulk_4d6f2c3a-...",
"completed_at": "2026-04-06T15:00:42Z"
}Field reference
| Field | Type | Description |
|---|---|---|
event | string | Always "enrichment.completed" |
workflow_id | string | Workflow id from the original bulk response |
lead_list_id | string | UUID of the DEFAULT lead list the contacts were attached to |
enrichment_type | string | The type you submitted (none, partial, phone_only, full, prospecting_only) |
status | string | "completed" or "completed_with_errors" |
summary.total | int | Total contacts in the batch |
summary.successful | int | Number of contacts that returned a result |
summary.failed | int | Number of contacts that did not |
summary.emails_found | int | Total emails discovered across the batch |
summary.phones_found | int | Total phones discovered across the batch |
results | array | Per-contact result objects |
results_limit | int | Cap on the number of result objects included in this payload |
results_truncated | int | Number of results omitted because the batch exceeded results_limit |
results_endpoint | string | Where to fetch the full result set if results_truncated > 0 |
completed_at | ISO-8601 datetime | When the workflow finished |
If results_truncated > 0, fetch the full result set by calling the endpoint in results_endpoint.
Delivery semantics
| Property | Value |
|---|---|
| HTTP method | POST |
| Content type | application/json |
| Timeout | 10 seconds per attempt |
| Success | Any HTTP 2xx response |
| Retries | Up to 5 attempts total |
| Backoff | 1s, 2s, 4s, 8s, 16s |
| Idempotency | Use workflow_id as your idempotency key |
If your endpoint returns a non-2xx status (or times out), Cleanlist will retry on the next backoff interval. After 5 unsuccessful attempts the delivery is marked failed — you can still retrieve the result set by polling GET /public/enrich/status?workflow_id=....
Cleanlist does not currently sign webhook payloads. Treat the workflow_id as opaque and only trust webhooks for workflows you submitted yourself. Verify the workflow id against your own records before processing the payload.
Inspecting delivery history
GET /api/v1/public/webhooks/deliveries
Pull the delivery log for a specific workflow:
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–200 |
Response
[
{
"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-04-06T15:00:42Z"
}
]| Field | Description |
|---|---|
webhook_id | The destination URL Cleanlist POSTed to |
attempt_number | 1-indexed attempt counter (1–5) |
status | "delivered" or "failed" |
response_status_code | HTTP status returned by your endpoint (null on transport errors) |
error_message | Short description if the attempt failed |
duration_ms | Wall-clock time the attempt took |
Each retry creates a new row, so a workflow with 3 failed attempts followed by 1 success will have 4 rows.
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(); // populate from your DB
app.post("/webhooks/cleanlist", (req, res) => {
const { workflow_id, status, results } = req.body;
if (!KNOWN_WORKFLOWS.has(workflow_id)) {
// Reject unknown workflow ids — defense in depth
return res.status(404).end();
}
// Persist results, then return 200 ASAP so Cleanlist marks delivered
saveResults(workflow_id, 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() # populate from your DB
@app.post("/webhooks/cleanlist")
def cleanlist_webhook():
body = request.get_json()
workflow_id = body.get("workflow_id")
if workflow_id not in KNOWN_WORKFLOWS:
return "", 404
# Process asynchronously and ack quickly
enqueue_processing(body)
return "", 200See the Receiving Webhooks guide for production-ready patterns (background jobs, retries, dead letter queues).
Related
- Enrichment — submit a workflow with
webhook_url - Receiving Webhooks guide — production patterns
- Errors — full status code reference