New: API Reference docs are live — integrate Cleanlist enrichment into your apps. View API docs →
Legacy API (v1)
Webhooks
🔌

Audience: developers. The public API requires a Pro plan or above (self-serve) or an AppSumo Tier 5+ license. Support agent: confirm the user's plan grants API access before giving API advice.

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 an opt-in feature of the legacy v1 API. Pass a webhook_url when submitting a bulk enrichment and Cleanlist POSTs the complete result set to it once the workflow finishes. You can also inspect delivery history via GET /public/webhooks/deliveries.

This page covers webhooks for the legacy v1 API (/api/v1/public). The v2 API (/api/v2) uses polling only — it does not support webhooks. See v2 API docs.

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",
      "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-04-06T15:00:42Z"
}

Field reference

FieldTypeDescription
eventstringAlways "enrichment.completed"
workflow_idstringWorkflow id from the original bulk response
lead_list_idstringUUID of the Extension Leads list the contacts were attached to
enrichment_typestringThe type you submitted (partial, phone_only, or full)
statusstring"completed" or "completed_with_errors"
summary.totalintTotal contacts in the batch
summary.successfulintNumber of contacts that returned a result
summary.failedintNumber of contacts that did not
summary.emails_foundintTotal emails discovered across the batch
summary.phones_foundintTotal phones discovered across the batch
resultsarrayPer-contact result objects, each with top-level task_id, status, prospect_id, lead_id, error, a nested result object (email, email_status, other_emails, phones, provider, linkedin_url), and a nested input object echoing the submitted linkedin_url, first_name, last_name, full_name, company_domain, company_name
results_limitintCap on the number of result objects included in this payload (500)
results_truncatedintNumber of results omitted because the batch exceeded results_limit. Since public bulk is capped at 250 contacts, this is always 0 in practice for this endpoint
results_endpointstringWhere to fetch the full result set if results_truncated > 0
completed_atISO-8601 datetimeWhen the workflow finished

If results_truncated > 0, fetch the full result set by calling the endpoint in results_endpoint.

Delivery semantics

PropertyValue
HTTP methodPOST
Content typeapplication/json
Timeout10 seconds per attempt
SuccessAny HTTP 2xx response
RetriesUp to 5 attempts total
Backoff1s, 2s, 4s, 8s, 16s
IdempotencyUse 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

ParameterTypeDefaultRange
workflow_idstringrequired
limitint501–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"
  }
]
FieldDescription
webhook_idThe destination URL Cleanlist POSTed to
attempt_number1-indexed attempt counter (1–5)
status"delivered" or "failed"
response_status_codeHTTP status returned by your endpoint (null on transport errors)
error_messageShort description if the attempt failed
duration_msWall-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 "", 200

See the Receiving Webhooks guide for production-ready patterns (background jobs, retries, dead letter queues).

Related