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

Audience: developers. API keys need a Pro or Enterprise plan (legacy Pro and Scale plans included) or an AppSumo Tier 5, 6 or 7 license, and they never work during a trial. Support agent: confirm the user’s plan grants API access before giving API advice.

TL;DR: POST /webhooks with a public https:// URL and a list of events subscribes that URL and returns a whsec_ signing secret once. Each event is POSTed as {id, type, created_at, organization_id, data} with a Cleanlist-Signature header you verify with the secret. Up to 10 subscriptions per workspace. Managing them needs admin:api_keys.

Webhooks

API v2 can push events to your server. You subscribe a URL to the event types you care about, and CluGTM POSTs every event of those types in your workspace to it, signed so you can check it came from CluGTM.

EndpointMethodScopeCost
List subscriptionsGET /webhooksadmin:api_keysFree
SubscribePOST /webhooksadmin:api_keysFree
Remove a subscriptionDELETE /webhooks/{webhook_id}admin:api_keysFree
Delivery logGET /webhooks/deliveriesnone (any valid credential)Free

A subscription receives every event of its types in the workspace, whoever caused it, so managing subscriptions takes admin:api_keys, the scope that manages the workspace’s credentials. These endpoints are in the served OpenAPI spec but not in the SDKs yet; call them over HTTP.

This is separate from the webhook_url on the legacy v1 bulk enrichment, which posts one unsigned result for one run. It is also separate from the Webhook step and the A webhook arrives trigger in Agents.

Events

EventSent when
play_run.completedA live run of an agent’s play finishes as completed or completed_with_errors
play_run.failedA live run of an agent’s play finishes as failed
approval.neededAn action call returns a card that is waiting for a yes
reply.receivedA reply to one of your sequences is matched to its enrollment and labelled (bounce notices are left out)
enrichment.completedA bulk enrichment run (a list, a selection or a reverse email lookup) finishes
job_change.detectedA live run of a play with a job-change trigger starts on the people who moved
website_visit.detectedA play with a website-visit trigger takes in a visit (a company, or a known person)

Dry runs, sample runs and cancelled play runs send nothing.

Subscribe

POST /webhooks

Scope: admin:api_keys · Returns 201

FieldTypeNotes
urlstringRequired. 1 to 2,048 characters. A public https:// URL on port 443
eventsstring[]Required. One or more of the event types. Repeats are dropped

Other fields are refused with 422 validation_error.

curl -X POST https://api.cleanlist.ai/api/v2/webhooks \ -H "Authorization: Bearer clapi_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/cleanlist", "events": ["play_run.completed", "reply.received"] }'
{ "webhook_id": "3f1c2a9b-6d7e-4f80-9a1b-2c3d4e5f6a7b", "url": "https://hooks.example.com/cleanlist", "events": ["play_run.completed", "reply.received"], "created_at": "2026-11-12T15:00:00Z", "secret": "whsec_..." }

The secret is returned only in this response. GET /webhooks never shows it again. Store it now; if you lose it, remove the subscription and subscribe again.

The URL is checked when you subscribe and again before every delivery. Private, loopback, link-local and cloud metadata addresses, other ports, a user:password in the URL and hosts that don’t resolve are refused with 422 validation_error (details.field: "url").

A workspace can have up to 10 active subscriptions. Another POST /webhooks returns 409 webhook_limit_reached.

List subscriptions

GET /webhooks

Scope: admin:api_keys

Returns the workspace’s active subscriptions as {"webhooks": [{webhook_id, url, events, created_at}]}. Secrets aren’t included.

Remove a subscription

DELETE /webhooks/{webhook_id}

Scope: admin:api_keys

Stops sending events to the subscription and returns {"webhook_id": "...", "deleted": true}. Its delivery log stays. An id that isn’t an active subscription in this workspace returns 404 webhook_not_found.

What you receive

Each event is one POST with Content-Type: application/json:

{ "id": "evt_6c1f0e4a-9b2d-5c3e-8f7a-1b2c3d4e5f60", "type": "play_run.completed", "created_at": "2026-11-12T15:04:05.123456+00:00", "organization_id": "org_2xyz...", "data": { "run_id": "8f0c2d6e-...", "playbook_id": "1a2b3c4d-...", "status": "completed_with_errors", "records_total": 120, "records_completed": 117, "records_failed": 3, "credits_used": 41.6 } }
HeaderValue
Cleanlist-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See Verifying the signature
X-Cleanlist-Delivery-IdThe same value on every attempt of this event to this subscription

The event id is derived from the workspace, the type and the data, so the same event emitted twice is delivered once.

data by event

Eventdata fields
play_run.completed, play_run.failedrun_id, playbook_id, status (completed, completed_with_errors or failed), records_total, records_completed, records_failed, credits_used
approval.neededcard_id, action (the action name), title, summary, credits_required (or null), expires_at
reply.receivedinbound_id, label (how the reply was classified), enrollment_id, thread_id
enrichment.completedworkflow_id, lead_list_id, enrichment_type, origin, status, summary. summary has total, successful, failed, missed, emails_found and phones_found, except a reverse email lookup that resolved no one, which reports only total, successful and failed
job_change.detectedrun_id, playbook_id, count (records the run started on)
website_visit.detectedplaybook_id, company_name, company_domain, page_url, visited_at, pages, pageviews, sessions, visitors, first_seen_at, source, confidence, country, event_id. A known person’s visit adds email, linkedin_url, first_name and last_name

Use the ids to read more: a run’s records with GET /gtm-runs/{run_id}/records, an enrichment with GET /enrichment/status/{workflow_id}, a card with POST /actions/confirm.

Verifying the signature

Cleanlist-Signature is t=<unix seconds>,v1=<hex digest>. The digest is HMAC-SHA256, keyed with your whsec_ secret, over the timestamp, a period and the raw request body: "<t>.<raw body>".

  1. Split the header on , and read t and v1.
  2. Compute HMAC-SHA256 of t + "." + raw_body with the secret, and hex-encode it.
  3. Compare it to v1 in constant time. Reject the request if it differs.

Each attempt is signed when it is sent, so you can also refuse a timestamp that is more than a few minutes old.

import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.CLEANLIST_WEBHOOK_SECRET; // whsec_... const app = express(); // Keep the raw bytes: the signature covers the body exactly as sent. app.post("/hooks/cleanlist", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("Cleanlist-Signature") || ""; const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const expected = crypto .createHmac("sha256", SECRET) .update(`${parts.t}.${req.body.toString("utf8")}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 || ""); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).end(); } const event = JSON.parse(req.body); // Save it, answer fast, process in the background. res.status(200).end(); });

Delivery and retries

PropertyValue
SuccessAny 2xx response
Timeout10 seconds per POST
One roundUp to 5 POSTs, waiting 1, 2, 4, then 8 seconds between them
Later roundsOnly while your endpoint looks down (no response, 5xx, 408 or 429): 30 seconds, then 1, 2, 4 and 8 minutes later, about 15 minutes in all
Other 4xx, or a redirectUp to 5 POSTs in the round, then no later round
RedirectsNot followed
Several subscriptionsPosted in parallel. A round that is retried skips the subscriptions already delivered

A subscription you remove before an event is sent gets nothing.

A receiver can still see the same event twice, for example when it answered 2xx but the delivery wasn’t recorded. Treat X-Cleanlist-Delivery-Id, or the event id, as an idempotency key. For the rest of a production receiver (answer fast, process in the background, decide what an outage should do), see Receiving webhooks.

Delivery log

GET /webhooks/deliveries

Scope: none (any valid credential)

Lists delivery attempts, newest first. For an event from a subscription, pass the event id (evt_...) as workflow_id; each row’s webhook_id is the subscription’s id.

Query paramNotes
workflow_idRequired. The event id
limit1 to 200, default 50
curl "https://api.cleanlist.ai/api/v2/webhooks/deliveries?workflow_id=evt_6c1f0e4a-...&limit=50" \ -H "Authorization: Bearer clapi_your_api_key"
[ { "id": "b7e1...", "webhook_id": "3f1c2a9b-6d7e-4f80-9a1b-2c3d4e5f6a7b", "workflow_id": "evt_6c1f0e4a-...", "event_type": "play_run.completed", "attempt_number": 1, "status": "delivered", "response_status_code": 200, "error_message": null, "duration_ms": 184, "created_at": "2026-11-12T15:04:05Z" } ]

Each POST is one row, with status delivered or failed. This is the v1 delivery log on v2: pass a legacy v1 run’s workflow_id to see its webhook_url attempts.

Errors

CodeHTTPWhat to do
validation_error422An unknown event type, an extra field, or a URL that isn’t allowed (details.field: "url"). Fix the body
webhook_limit_reached409The workspace has 10 subscriptions. Remove one, then subscribe again
webhook_not_found404No active subscription with that id in this workspace. List them with GET /webhooks
internal_error503The secret couldn’t be stored. Nothing was created; retry in a minute
insufficient_scope403The credential lacks admin:api_keys

See Errors & Rate Limits for every code.