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

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

PropertyValue
MethodPOST
Content typeapplication/json
BodySee the webhook payload
SuccessAny 2xx response
Timeout10 seconds per attempt
AttemptsUp to 5
Wait between attempts1, 2, 4, then 8 seconds
RedirectsNot followed (a 3xx is a failed attempt)
Signature or custom headersNone

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_id of 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

  • status is completed, completed_with_errors or failed. A failed run (no contact succeeded) is still delivered once; don't wait for another callback.
  • summary.failed = missed + errors + throttled. missed means nothing was found, so retrying won't help. errors and throttled contacts are worth re-enriching later.
  • results_truncated is always 0 on 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 5xx to get another attempt (good for short blips; remember attempts stop after 5),
  • or answer 2xx and 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 "", 200

AWS 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:

  1. Extension Leads list: every contact is saved there. Read it in the app or with GET /api/v2/lead-lists/{lead_list_id}/leads (scope lists:read).
  2. Per-contact polling (within 24 hours): GET /api/v1/public/enrich/status?task_id=... for each task_id from the bulk response.
  3. 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:

To just look at a payload, webhook.site (opens in a new tab) gives you a URL and a live request log.

Common mistakes

MistakeSymptomFix
Doing the work inside the handlerTimeouts, repeat deliveriesAnswer fast, process in the background
Trusting every payloadSpoofed callbacksCheck workflow_id against your records
Waiting for a second callback after a failed runRun never processedHandle failed like any other status
webhook_url that redirects (for example http to https)Every attempt failed with a 3xx codeUse the final HTTPS URL
Returning 5xx for a permanent bug5 attempts, then nothingFix the bug, or answer 200 and log
No idempotencyDuplicate rowsUse workflow_id as a unique key

Related