New: meet Clu, and send email, LinkedIn and call steps with Sequences. Watch the videos →
Legacy API (v1)
Webhooks
🔌

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.

The v2 API has no webhooks; it uses polling. For automations in the app, the Agents Webhook step and the A webhook arrives trigger are separate features; see Agents.

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

FieldTypeDescription
eventstringAlways "enrichment.completed"
workflow_idstringThe run, as returned by POST /enrich/bulk
lead_list_idstringYour Extension Leads list, where the contacts were saved
enrichment_typestringpartial, phone_only or full
statusstringcompleted, completed_with_errors or failed (see below)
summary.totalintContacts in the run
summary.successfulintContacts that returned data
summary.failedintContacts that didn't. Always missed + errors + throttled
summary.missedintNo data was found for the contact
summary.errorsintEnrichment could not finish for the contact. Worth retrying
summary.throttledintA data provider rate-limited the lookup. Worth retrying later
summary.emails_foundintEmails found across the run
summary.phones_foundintPhones found across the run
resultsarrayOne object per contact (see below)
results_limitintMaximum results in one payload (500)
results_truncatedintResults left out because of results_limit. A v1 run has at most 250 contacts, so this is 0
results_endpointstringThe status-poll path for this run (run-level counts)
completed_atISO 8601 datetimeWhen the run finished

Run status

statusMeaning
completedEvery contact succeeded
completed_with_errorsThe run finished and at least one contact failed (missed, error or throttled)
failedThe 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

FieldDescription
task_idMatches an entry in task_ids from the bulk response
statuscompleted or failed
prospect_id, lead_idThe person and the lead in your Extension Leads list
resultemail, email_status, other_emails, phones, provider, linkedin_url. Can be null when the contact couldn't be processed
errorWhy the contact failed, for example "Enrichment could not finish for this row. Retry the row." null on success
inputThe identifiers you sent: linkedin_url, first_name, last_name, full_name, company_domain, company_name

Delivery

PropertyValue
MethodPOST
Content typeapplication/json
Timeout10 seconds per attempt
SuccessAny 2xx response
AttemptsUp to 5
Wait between attempts1, 2, 4, then 8 seconds
RedirectsNot followed. A 3xx counts as a failed attempt
SignatureNone
Idempotency keyUse 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

ParameterTypeDefaultRange
workflow_idstringrequired
limitint501 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"
  }
]
FieldDescription
webhook_idThe URL Cleanlist posted to
event_typeenrichment.completed
attempt_number1 to 5
statusdelivered or failed
response_status_codeYour endpoint's HTTP status, or null if the request never got a response
error_messageWhy the attempt failed (for example "Non-success response from webhook endpoint: 500" or a timeout), null on success
duration_msHow 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 "", 200

See Receiving webhooks for production patterns.

Related