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: Answer 2xx within 10 seconds, save the payload, process it in the background. Use workflow_id as your idempotency key and reject unknown ones (payloads aren't signed). Handle failed runs and retry contacts counted in errors or throttled. If every attempt fails, read the results from your Extension Leads list or poll per contact.
Receiving webhooks
The legacy v1 API sends webhooks: pass webhook_url to POST /enrich/bulk and Cleanlist POSTs the summary and every contact's result to your URL once the run finishes. This guide covers building a receiver you can rely on.
Webhooks exist only on the v1 bulk endpoint. The v2 API uses polling (GET /enrichment/status/{workflow_id}); see v2 polling.
The contract
| Property | Value |
|---|---|
| Method | POST |
| Content type | application/json |
| Body | See the webhook payload |
| Success | Any 2xx response |
| Timeout | 10 seconds per attempt |
| Attempts | Up to 5 |
| Wait between attempts | 1, 2, 4, then 8 seconds |
| Redirects | Not followed (a 3xx is a failed attempt) |
| Signature or custom headers | None |
All 5 attempts happen within roughly a minute, so a receiver that is down for longer misses the callback. Plan a fallback (see below).
Five rules for a production receiver
1. Answer fast, process later
Don't do the real work inside the request handler. Save the payload, return 200, and process it in a background job. A slow database or CRM call then never causes a timeout and a repeat delivery.
2. Be idempotent
The same workflow_id can arrive more than once, for example when your handler finished its work but answered after the 10-second timeout. Treat workflow_id as a unique key: if you've already processed it, answer 200 and stop.
3. Only trust runs you started
Payloads aren't signed and Cleanlist doesn't publish webhook source IPs, so anyone who learns your URL could POST to it. Defend by:
- storing the
workflow_idof every bulk run you start, - rejecting callbacks with an unknown
workflow_id(404), - using a long, hard-to-guess URL path over HTTPS.
4. Handle every run outcome
statusiscompleted,completed_with_errorsorfailed. Afailedrun (no contact succeeded) is still delivered once; don't wait for another callback.summary.failed=missed+errors+throttled.missedmeans nothing was found, so retrying won't help.errorsandthrottledcontacts are worth re-enriching later.results_truncatedis always0on v1, because a run has at most 250 contacts and a payload holds up to 500 results.
5. Decide what an outage should do
If you save before answering and your database is down:
- answer
5xxto get another attempt (good for short blips; remember attempts stop after 5), - or answer
2xxand rely on your own queue to retry.
Pick one and document it for whoever is on call.
Reference implementations
Express (Node.js)
import express from "express";
import { db } from "./db.js";
import { jobQueue } from "./queue.js";
const app = express();
app.use(express.json({ limit: "10mb" }));
app.post("/webhooks/cleanlist", async (req, res) => {
const payload = req.body;
const workflowId = payload?.workflow_id;
if (!workflowId) {
return res.status(400).json({ error: "missing workflow_id" });
}
// Rule 3: only runs we started
const run = await db.workflows.findOne({ id: workflowId });
if (!run) {
return res.status(404).json({ error: "unknown workflow_id" });
}
// Rule 2: idempotency
if (run.processed_at) {
return res.status(200).end();
}
// Rule 1: save, answer, process later
await db.webhookInbox.insert({
workflow_id: workflowId,
payload,
received_at: new Date(),
});
await jobQueue.enqueue("process-cleanlist-results", { workflowId });
res.status(200).end();
});
app.listen(3000, () => console.log("Receiver listening on :3000"));The background job:
export async function processCleanlistResults({ workflowId }) {
const inbox = await db.webhookInbox.findOne({ workflow_id: workflowId });
const { results, summary, status } = inbox.payload;
const failedLeadIds = [];
for (const row of results ?? []) {
if (row.status === "completed") {
await db.leads.upsert({
where: { source_id: row.task_id },
create: {
source_id: row.task_id,
email: row.result?.email,
email_status: row.result?.email_status,
phones: row.result?.phones ?? [],
first_name: row.input?.first_name,
last_name: row.input?.last_name,
company_domain: row.input?.company_domain,
},
update: {
email: row.result?.email,
email_status: row.result?.email_status,
phones: row.result?.phones ?? [],
},
});
} else if (row.lead_id) {
failedLeadIds.push(row.lead_id);
}
}
// Rule 4: errors and throttled rows are worth re-enriching later.
// Missed rows (nothing found) will not improve on a retry.
if (summary.errors + summary.throttled > 0) {
await flagForReEnrichment(workflowId, failedLeadIds);
}
await db.workflows.update(workflowId, {
processed_at: new Date(),
final_status: status,
summary,
});
}The payload gives totals for misses, errors and throttled lookups, not a label on each failed row. To retry, re-enrich the contacts from your Extension Leads list (in the app's Actions drawer, or with the v2 enrichment API). Sending the same contacts to POST /enrich/bulk again skips any that are already in your Extension Leads list.
Flask (Python)
from datetime import datetime, timezone
from flask import Flask, request, jsonify
from redis import Redis
from rq import Queue
from .db import db
from .jobs import process_cleanlist_results
app = Flask(__name__)
queue = Queue(connection=Redis())
@app.post("/webhooks/cleanlist")
def cleanlist_webhook():
payload = request.get_json(silent=True) or {}
workflow_id = payload.get("workflow_id")
if not workflow_id:
return jsonify(error="missing workflow_id"), 400
run = db.workflows.find_one({"id": workflow_id})
if not run:
return jsonify(error="unknown workflow_id"), 404
if run.get("processed_at"):
return "", 200 # already handled
db.webhook_inbox.insert_one({
"workflow_id": workflow_id,
"payload": payload,
"received_at": datetime.now(timezone.utc),
})
queue.enqueue(process_cleanlist_results, workflow_id)
return "", 200AWS Lambda (Node.js)
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({});
export const handler = async (event) => {
const payload = JSON.parse(event.body);
const workflowId = payload?.workflow_id;
if (!workflowId) {
return { statusCode: 400, body: '{"error":"missing workflow_id"}' };
}
// Hand off to a FIFO queue and answer right away
await sqs.send(
new SendMessageCommand({
QueueUrl: process.env.RESULTS_QUEUE_URL,
MessageBody: event.body,
MessageDeduplicationId: workflowId,
MessageGroupId: "cleanlist",
}),
);
return { statusCode: 200, body: "" };
};A consumer on the queue checks the workflow_id against your records and does the saving. The receiver stays small and fast.
Verifying delivery
If a callback seems to be missing, check the delivery log:
deliveries = requests.get(
"https://api.cleanlist.ai/api/v1/public/webhooks/deliveries",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"workflow_id": workflow_id},
).json()
for d in deliveries:
print(d["attempt_number"], d["status"], d["response_status_code"], d["error_message"])Each attempt is one row, newest first, with status delivered or failed. An empty list means Cleanlist hasn't sent anything for that run yet (it may still be running) or the run had no webhook_url. Error messages such as "Non-success response from webhook endpoint: 500" show what your endpoint returned.
Fallback: read instead of waiting
If every attempt fails, the results aren't lost:
- Extension Leads list: every contact is saved there. Read it in the app or with
GET /api/v2/lead-lists/{lead_list_id}/leads(scopelists:read). - Per-contact polling (within 24 hours):
GET /api/v1/public/enrich/status?task_id=...for eachtask_idfrom the bulk response. - Run-level counts (about 7 days):
GET /api/v1/public/enrich/status?workflow_id=....
status = requests.get(
"https://api.cleanlist.ai/api/v1/public/enrich/status",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"task_id": task_id},
).json()["task"]A robust integration has both paths: the webhook for speed and polling or list reads as a backup.
Testing locally
Expose your local server with a tunnel and pass the public URL as webhook_url:
- ngrok (opens in a new tab):
ngrok http 3000 - localtunnel (opens in a new tab):
npx localtunnel --port 3000 - Cloudflare Tunnel (opens in a new tab)
To just look at a payload, webhook.site (opens in a new tab) gives you a URL and a live request log.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Doing the work inside the handler | Timeouts, repeat deliveries | Answer fast, process in the background |
| Trusting every payload | Spoofed callbacks | Check workflow_id against your records |
Waiting for a second callback after a failed run | Run never processed | Handle failed like any other status |
webhook_url that redirects (for example http to https) | Every attempt failed with a 3xx code | Use the final HTTPS URL |
Returning 5xx for a permanent bug | 5 attempts, then nothing | Fix the bug, or answer 200 and log |
| No idempotency | Duplicate rows | Use workflow_id as a unique key |