Skip to Content
New: meet Clu, and send email, LinkedIn and call steps with Sequences. Watch the videos →
🔌

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: Pass webhook_url to POST /enrich/bulk and CluGTM sends one POST (enrichment.completed) when the run finishes. status is completed, completed_with_errors or failed; summary.failed splits into missed, errors, throttled and opted_out. The URL must be HTTPS on port 443 at a public address. Up to 5 attempts, waits of 1, 2, 4 and 8 seconds, 10-second timeout, 2xx only. Payloads are not signed; each POST carries an X-Cleanlist-Delivery-Id header for deduplication. Check attempts with GET /webhooks/deliveries. For signed events across your workspace, use v2 webhooks.

Webhooks

The v1 bulk endpoint takes a per-run webhook. Pass a webhook_url with a bulk enrichment and CluGTM POSTs the summary and every contact’s result to it once the run finishes.

The v2 API has no per-run webhook_url. Instead it has webhook subscriptions: you subscribe a URL once to events such as enrichment.completed or play_run.completed, and each event is signed. 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:

{ "lead_list_id": "550e8400-e29b-41d4-a716-446655440000", "enrichment_type": "partial", "webhook_url": "https://your-app.com/webhooks/cleanlist", "contacts": [ { "linkedin_url": "https://www.linkedin.com/in/janedoe" } ] }

lead_list_id is required on every bulk request, with or without a webhook. There is no separate registration step: the URL applies to that one run.

The URL must use HTTPS on port 443 (no other port, no credentials in the URL) and resolve to a public address. Private, loopback and internal addresses are refused, and redirects are not followed. The request is still accepted, but a refused URL gets one failed delivery attempt (“Webhook URL is not allowed”) and no retries.

Outbound payload

When the run finishes, CluGTM 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, "opted_out": 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_idstringThe list you passed as lead_list_id, 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 + opted_out
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.opted_outintThe person opted out of data enrichment. Not worth retrying
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, throttled or opted out)
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 that 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
URLHTTPS on port 443, public addresses only. A refused URL gets one failed attempt and no retries
RedirectsNot followed. A 3xx counts as a failed attempt
SignatureNone
X-Cleanlist-Delivery-Id headerThe same value on every attempt for this run and URL
Idempotency keyX-Cleanlist-Delivery-Id, or 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 the list you passed as lead_list_id, 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: an attempt of the same delivery carries the same X-Cleanlist-Delivery-Id, and a URL that already answered 2xx for the run isn’t posted again.

Webhook payloads are not signed. The only custom header is X-Cleanlist-Delivery-Id. 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 CluGTM 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”, a timeout, or “Webhook URL is not allowed” for a refused URL), 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.