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.
| Endpoint | Method | Scope | Cost |
|---|---|---|---|
| List subscriptions | GET /webhooks | admin:api_keys | Free |
| Subscribe | POST /webhooks | admin:api_keys | Free |
| Remove a subscription | DELETE /webhooks/{webhook_id} | admin:api_keys | Free |
| Delivery log | GET /webhooks/deliveries | none (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
| Event | Sent when |
|---|---|
play_run.completed | A live run of an agent’s play finishes as completed or completed_with_errors |
play_run.failed | A live run of an agent’s play finishes as failed |
approval.needed | An action call returns a card that is waiting for a yes |
reply.received | A reply to one of your sequences is matched to its enrollment and labelled (bounce notices are left out) |
enrichment.completed | A bulk enrichment run (a list, a selection or a reverse email lookup) finishes |
job_change.detected | A live run of a play with a job-change trigger starts on the people who moved |
website_visit.detected | A 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
| Field | Type | Notes |
|---|---|---|
url | string | Required. 1 to 2,048 characters. A public https:// URL on port 443 |
events | string[] | 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
}
}| Header | Value |
|---|---|
Cleanlist-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See Verifying the signature |
X-Cleanlist-Delivery-Id | The 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
| Event | data fields |
|---|---|
play_run.completed, play_run.failed | run_id, playbook_id, status (completed, completed_with_errors or failed), records_total, records_completed, records_failed, credits_used |
approval.needed | card_id, action (the action name), title, summary, credits_required (or null), expires_at |
reply.received | inbound_id, label (how the reply was classified), enrollment_id, thread_id |
enrichment.completed | workflow_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.detected | run_id, playbook_id, count (records the run started on) |
website_visit.detected | playbook_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>".
- Split the header on
,and readtandv1. - Compute HMAC-SHA256 of
t + "." + raw_bodywith the secret, and hex-encode it. - Compare it to
v1in 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.
Node.js
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
| Property | Value |
|---|---|
| Success | Any 2xx response |
| Timeout | 10 seconds per POST |
| One round | Up to 5 POSTs, waiting 1, 2, 4, then 8 seconds between them |
| Later rounds | Only 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 redirect | Up to 5 POSTs in the round, then no later round |
| Redirects | Not followed |
| Several subscriptions | Posted 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 param | Notes |
|---|---|
workflow_id | Required. The event id |
limit | 1 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
| Code | HTTP | What to do |
|---|---|---|
validation_error | 422 | An unknown event type, an extra field, or a URL that isn’t allowed (details.field: "url"). Fix the body |
webhook_limit_reached | 409 | The workspace has 10 subscriptions. Remove one, then subscribe again |
webhook_not_found | 404 | No active subscription with that id in this workspace. List them with GET /webhooks |
internal_error | 503 | The secret couldn’t be stored. Nothing was created; retry in a minute |
insufficient_scope | 403 | The credential lacks admin:api_keys |
See Errors & Rate Limits for every code.