Developer Docs
ZenMode REST API
Push LinkedIn automation tasks and read your campaign data programmatically. Available on every paid plan except Novice.
Authentication
All API requests require a Bearer token. Generate an API key from your dashboard.
curl -H "Authorization: Bearer zm_your_api_key_here" \
https://www.zen-mode.io/api/v1/tasksKeep your API key secret. It is shown only once when created. If compromised, revoke it from the dashboard and generate a new one.
Scoping: your API key is your identity — every response is scoped to the key's own account. Resource ids passed as parameters (e.g. campaign_id) can only reference your own resources; anything else returns 404.
Plan requirement: the API is available on every active paid plan except Novice. On the Novice tier (or without an active plan) the analytics, status, leads and suppressions endpoints return:
HTTP 403
{
"error": "API access requires an active paid plan above the Novice tier. Upgrade your plan at https://www.zen-mode.io/dashboard/billing to use the API.",
"code": "plan_upgrade_required"
}The task, webhook and key endpoints currently return the same 403 with a shorter message and no code field. They are being unified — branch on the status code, and treat code as present-or-absent rather than guaranteed.
Rate Limits
API Requests
100 per minute
Task Creation
30–50 per rolling 24h, per ZenMode account (tier comes from your LinkedIn account, but the budget is shared across all your seats)
Two different 429s: the per-key request limit returns Retry-After: 60, while the daily task cap returns Retry-After: 3600 and names the cap in the message.
How execution actually works
The API queues work. It does not perform it. Every LinkedIn action is carried out by the ZenMode desktop app running on a real machine, signed in to a real LinkedIn session. If no desktop is running, nothing you push is refused — it simply waits.
Creating a lead writes a row. A desktop later reads that row, decides whether it may act, and sends. The two halves authenticate differently: your API key writes, and the desktop's own credential reads. You do not need any additional setup for the send — but you do need a desktop that is actually running.
What has to be true before a queued lead sends
- A desktop is running and its LinkedIn session is alive.
- The campaign is
active— re-checked continuously, so pausing stops work mid-run. - A campaign is running on that desktop for the campaign's LinkedIn account. Someone has to click Run: opening the app does not start a campaign. On desktop 1.3.204 and later, a running campaign's regular check also works through the other active campaigns on the same LinkedIn account, so your lead is picked up even if a different campaign on that account was the one started. On older versions only the started campaign is worked.
- The LinkedIn account is not disabled and not in a cooldown.
- The per-account daily send limit has not been reached.
- If the campaign uses warm-up, a brand-new lead is not eligible to be invited until warm-up has run for it. This is silent — the lead simply is not picked up yet.
Timing. If a desktop is already running a campaign on that LinkedIn account, it re-reads the queue on a cycle — expect up to about two hours before a freshly pushed lead is picked up. A campaign switched to active after the run started may not be picked up until the desktop reloads its campaign list (for example after Stop and Run). If nothing is running on that account, the lead waits until someone clicks Run: nothing on our side wakes a desktop up. Sends are then paced at roughly one action every 90–180 seconds.
Queued work never expires. Close the laptop overnight or over a weekend and the queue is still there. Pending leads stay eligible indefinitely — in production, leads created months earlier have gone on to send normally. Nothing times work out.
The flip side: task status will not tell you a desktop is off. It reads pending whether execution is thirty seconds or thirty days away. Poll GET /api/v1/status. The desktop reports in about once an hour, so desktop_active is true when it last reported within the past 75 minutes, and desktop_state tells you which case you are in: active, went_quiet (it has reported before, but not in the last 75 minutes: closed, asleep or crashed) or never_seen. Two limits: a desktop that has just closed can still read active for up to 75 minutes, and active means the app was open, not that a campaign is running — read accounts[].is_running for that.
Things that can quietly reduce what you pushed
- A target location can fail your leads. If the campaign has a target location, the desktop checks each lead's location before inviting and marks the ones that do not match as
failed(failure_reason: location_mismatch). This applies to leads you push, and not only when you send alocation: warm-up visits the profile and fills the location in. Leave the target location empty on an API-fed campaign if you want every pushed lead tried. A failed lead does not block a push, so pushing the same person to a different campaign creates a new lead that will fail the same way; pushing them to the same campaign again returns409 already_existswithout aleadobject. - A target job title does not filter leads you push (desktop 1.3.198 and later). On older versions a pushed lead whose title did not match was removed, and a removed lead cannot be pushed into that campaign again: re-pushing it there returns
409 already_existswithremoved: true. - A campaign with targeting set may add its own contacts. When the queue runs low and the campaign has targeting configured, the desktop can search LinkedIn and import more people, mixing them into your curated list. Leave targeting empty on an API-fed campaign if you want only the contacts you pushed.
Endpoints
/api/v1/tasks— Create a taskQueue a LinkedIn action. The desktop app picks it up automatically.
Request Body
{
"action_type": "connect",
"linkedin_account_id": "acc_abc123",
"payload": {
"profile_url": "https://linkedin.com/in/jane-doe",
"message": "Hi Jane, I'd love to connect!"
},
"priority": 0,
"max_retries": 3
}Action Types
connect — Send a connection request (with optional message)
message — Send a direct message to an existing connection
follow — Follow a profile
view_profile — View a profile (warm-up action)
withdraw_connection — Withdraw a pending connection request
Sends are recorded to message history. From desktop 1.3.184 onward, a completed message task — and the note on a connect task, when you include one — is written to the lead's message history, so it appears in GET /api/v1/leads/{id}/activity and in your ZenMode inbox alongside any reply. Earlier desktop versions performed the send but recorded nothing, so tasks completed before 1.3.184 will not appear in message history retrospectively.
No note on a free or unconfirmed LinkedIn plan. On a free or unconfirmed LinkedIn plan the connection request is sent without a note, and the task result and webhook carry note_dropped: true with note_dropped_reason: "free_or_unconfirmed_plan". Unconfirmed means ZenMode has not confirmed the account is on a paid plan (Premium or Sales Navigator). Both fields sit inside result on a connect task. note_dropped is false when the note went out or you sent none, and it is absent on results from desktop versions that do not report it, so treat a missing field as unknown rather than as a sent note. note_sent is true only when the note was typed into LinkedIn's note box and the request went out with it; any request that went out without a note carries note_sent: false.
Response (201)
{
"task": {
"id": "task_m1abc_x7k2p9qr",
"action_type": "connect",
"status": "pending",
"linkedin_account_id": "acc_abc123",
"payload": { ... },
"priority": 0,
"created_at": "2026-04-02T10:30:00.000Z"
}
}/api/v1/tasks— List tasksRetrieve your tasks with optional filters.
Query Parameters
status — Filter by status: pending, processing, completed, failed, cancelled
linkedin_account_id — Filter by LinkedIn account
limit — Results per page (default 50, max 100)
offset — Pagination offset
curl -H "Authorization: Bearer zm_..." \
"https://www.zen-mode.io/api/v1/tasks?status=completed&limit=10"/api/v1/tasks/:id— Get task statuscurl -H "Authorization: Bearer zm_..." \
https://www.zen-mode.io/api/v1/tasks/task_m1abc_x7k2p9qrA completed connect task whose note was not sent:
{
"task": {
"id": "task_m1abc_x7k2p9qr",
"action_type": "connect",
"status": "completed",
"result": {
"success": true,
"note_sent": false,
"note_dropped": true,
"note_dropped_reason": "free_or_unconfirmed_plan"
}
}
}/api/v1/tasks/:id— Report progress or a resultFor callers running their own execution. Accepts processing, completed or failed, plus an optional result object. Use DELETE to cancel — cancelled is not accepted here.
Marking a task completed or failed fires the matching webhook. There is no transition guard, so PATCHing a terminal status twice delivers the webhook twice — make your handler idempotent.
curl -X PATCH -H "Authorization: Bearer zm_..." \
-H "Content-Type: application/json" \
-d '{"status":"completed","result":{"note":"sent"}}' \
https://www.zen-mode.io/api/v1/tasks/task_m1abc_x7k2p9qr/api/v1/tasks/:id— Cancel a pending taskOnly tasks in pending or queued status can be cancelled.
curl -X DELETE -H "Authorization: Bearer zm_..." \
https://www.zen-mode.io/api/v1/tasks/task_m1abc_x7k2p9qrWebhooks
Register a webhook URL to receive POST callbacks when a task finishes or a lead replies.
/api/v1/webhooks— Register a webhook{
"url": "https://your-server.com/zenmode-webhook",
"events": ["task.completed", "task.failed", "lead.replied"]
}URL must use HTTPS on the standard port (443) and point to a public host; redirects from your endpoint are not followed. Valid events: task.completed, task.failed, lead.replied.
lead.replied is opt-in: an existing webhook keeps receiving only the events it registered for. Re-register with it listed to start receiving it.
/api/v1/webhooks— List webhooks/api/v1/webhooks?id=123— Delete a webhookWebhook Payload
When a subscribed event fires, ZenMode sends a signed POST to your webhook URL.
Headers
X-ZenMode-Signature — HMAC-SHA256 hex digest of the request body, signed with your webhook secret
X-ZenMode-Event — Event type (e.g. task.completed)
Body
{
"event": "task.completed",
"data": {
"task_id": "task_m1abc_x7k2p9qr",
"action_type": "connect",
"status": "completed",
"result": { "success": true, "note_sent": true, "note_dropped": false },
"linkedin_account_id": "acc_abc123"
},
"timestamp": "2026-04-02T10:35:00.000Z"
}data.result is the task's result, so on a connect task it carries the same note_dropped and note_dropped_reason fields as GET /api/v1/tasks/:id. On a free or unconfirmed LinkedIn plan the connection request is sent without a note, and the webhook carries note_dropped: true with note_dropped_reason: "free_or_unconfirmed_plan".
lead.replied
Fires when a lead's reply is first recorded — once per lead, on the transition. Re-syncing the same conversation does not fire it again, and it only fires on a confident match to that lead, so you will not get a reply attributed to the wrong person.
{
"event": "lead.replied",
"data": {
"lead_id": 37110,
"campaign_id": 42,
"name": "Omar Molina",
"linkedin_url": "https://www.linkedin.com/in/example",
"replied_at": "2026-07-20T15:12:40.000Z",
"reply_text": "Thanks for reaching out, but I'm not interested.",
"reply_subject": "Quick question about outreach at Audere"
},
"timestamp": "2026-07-20T15:12:41.000Z"
}New 7 September 2026: the payload now carries the reply itself. reply_text is the text of the most recent inbound message on the thread, and reply_subject is its subject line — present for InMail threads, which have one, and null for a regular LinkedIn message, which does not.
Both fields are nullable, and both are null together when the reply was recorded from a path that has no stored message. Treat a null as “not available”, never as an empty reply. The full thread is still available from GET /api/v1/leads/{lead_id}/activity.
This change is additive. No existing field was renamed, retyped or removed — replied_at and everything above it are unchanged — so an existing consumer keeps working without modification. If you validate this payload against a schema, make sure it permits unknown keys.
Verifying Signatures
Every webhook has its own signing secret, starting whsec_. Where to find it:
- Registered through the API: the
POST /api/v1/webhooksresponse carries it aswebhook.secret. This is the only response that returns it, so store it with your endpoint. - Registered in the dashboard: the API page shows it once when you add the webhook, and Reveal secret next to any webhook shows it again.
X-ZenMode-Signature is the hex HMAC-SHA256 of the exact raw request body, keyed with that secret. Compute it over the bytes you received, before any JSON parsing: re-serialising a parsed body can change it and the signature will not match. Compare in constant time.
The body's timestamp is covered by the signature, so you can also refuse a delivery whose timestamp is more than a few minutes old. Retries of one delivery happen within about a minute.
const crypto = require('crypto');
// rawBody: the request body exactly as received (a Buffer or string), not re-serialised JSON.
function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(String(signature || ''), 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on different lengths, so check that first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body for this route.
// app.post('/zenmode-webhook', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyWebhook(req.body, req.get('X-ZenMode-Signature'), process.env.ZENMODE_WEBHOOK_SECRET)) {
// return res.sendStatus(401);
// }
// const event = JSON.parse(req.body.toString('utf8'));
// res.sendStatus(200);
// });import hashlib, hmac
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")Code Examples
cURL
# Create a connection request task
curl -X POST https://www.zen-mode.io/api/v1/tasks \
-H "Authorization: Bearer zm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"action_type": "connect",
"linkedin_account_id": "acc_abc123",
"payload": {
"profile_url": "https://linkedin.com/in/jane-doe",
"message": "Hi Jane, would love to connect!"
}
}'
# Check task status
curl https://www.zen-mode.io/api/v1/tasks/task_m1abc_x7k2p9qr \
-H "Authorization: Bearer zm_your_api_key"
# List completed tasks
curl "https://www.zen-mode.io/api/v1/tasks?status=completed&limit=20" \
-H "Authorization: Bearer zm_your_api_key"Python
import requests
API_KEY = "zm_your_api_key"
BASE_URL = "https://www.zen-mode.io/api/v1"
headers = {"Authorization": f"Bearer {API_KEY}"}
# Create a task
task = requests.post(f"{BASE_URL}/tasks", headers=headers, json={
"action_type": "connect",
"linkedin_account_id": "acc_abc123",
"payload": {
"profile_url": "https://linkedin.com/in/jane-doe",
"message": "Hi Jane, would love to connect!"
}
}).json()
print(f"Task created: {task['task']['id']}")
# Poll for completion
import time
while True:
status = requests.get(
f"{BASE_URL}/tasks/{task['task']['id']}", headers=headers
).json()
if status["task"]["status"] in ("completed", "failed"):
print(f"Task {status['task']['status']}: {status['task'].get('result')}")
break
time.sleep(10)JavaScript / Node.js
const API_KEY = "zm_your_api_key";
const BASE_URL = "https://www.zen-mode.io/api/v1";
async function createTask(actionType, profileUrl, message) {
const res = await fetch(`${BASE_URL}/tasks`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
action_type: actionType,
payload: { profile_url: profileUrl, message },
}),
});
return res.json();
}
// Send a connection request
const { task } = await createTask(
"connect",
"https://linkedin.com/in/jane-doe",
"Hi Jane, would love to connect!"
);
console.log("Task ID:", task.id);
// Register a webhook to get notified
await fetch(`${BASE_URL}/webhooks`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://your-server.com/webhook",
events: ["task.completed", "task.failed"],
}),
});Read API — Analytics, Status & Leads
Read your own campaign data programmatically. Same Bearer-key authentication as the task endpoints; every response is scoped to the API key's account.
GET/api/v1/analytics
Campaign analytics: sent, accepted, replied, meetings booked, acceptance rate, reply rate, funnel, daily time-series, and a per-campaign comparison. Query params: days (1–365, default 30, or all) and campaign_id (optional, must be your own campaign).
curl -H "Authorization: Bearer zm_your_api_key_here" \
"https://www.zen-mode.io/api/v1/analytics?days=30"Example response:
{
"window": { "days": 30 },
"campaign_id": null,
"summary": {
"total_contacts": 412,
"sent": 305,
"accepted": 121,
"replied": 38,
"meetings_booked": 6,
"acceptance_rate_pct": 40,
"reply_rate_pct": 31,
"reply_rate_denominator": "accepted"
},
"funnel": { "sent": 305, "accepted": 121, "replied": 38, "meetings_booked": 6 },
"daily": [
{ "date": "2026-07-28", "connections_sent": 18, "connections_accepted": 7,
"replies_received": 3, "meetings_booked": 0 }
],
"campaigns": [
{ "id": 42, "name": "UK SaaS Founders", "status": "active",
"total_contacts": 180, "sent_count": 140, "connected_count": 61,
"replied_count": 19, "meeting_count": 3 }
]
}Note: reply rate is replied ÷ accepted (the response echoes reply_rate_denominator). Message open rates are not available — LinkedIn provides no prospect-side open data, so we don't invent one. The campaigns comparison is included only when no campaign_id filter is set.
GET/api/v1/analytics/outreach-volume
Outreach volume: how many connection requests and 1st, 2nd and 3rd follow-ups were sent in a date range, per campaign and in total. Query params: from and to (YYYY-MM-DD, inclusive, UTC days; both or neither, default the last 7 days, at most 366 days) and campaign_id (optional, must be your own campaign).
curl -H "Authorization: Bearer zm_your_api_key_here" \
"https://www.zen-mode.io/api/v1/analytics/outreach-volume?from=2026-09-16&to=2026-09-22"Example response:
{
"from": "2026-09-16",
"to": "2026-09-22",
"time_zone": "UTC",
"campaign_id": null,
"totals": { "connection_requests": 112, "follow_up_1": 43, "follow_up_2": 24,
"follow_up_3": 12, "follow_ups_total": 79, "reconstructed": 0 },
"campaigns": [
{ "campaign_id": 42, "campaign_name": "UK SaaS Founders", "campaign_type": "connect",
"connection_requests": 84, "follow_up_1": 31, "follow_up_2": 18, "follow_up_3": 9,
"follow_ups_total": 58, "reconstructed": 0 }
],
"recorded_from": "2026-09-23"
}Counts are of messages sent in the range, bucketed by the day they went out. Follow-ups sent before recorded_from are reconstructed from your message history and counted in reconstructed; from that day on each follow-up is recorded with its step as it is sent. Campaigns with nothing sent in the range are omitted.
GET/api/v1/status
Live desktop runtime status: whether the desktop app is active (it reports about once an hour; active means a report within the last 75 minutes, and desktop_state separates active, went_quiet and never_seen), its current phase (idle / starting / scraping / connecting / checking_replies / monitoring / error), connections sent today, and per-LinkedIn-account action counts vs limits when running multi-account.
curl -H "Authorization: Bearer zm_your_api_key_here" \
https://www.zen-mode.io/api/v1/statusExample response when the desktop is active:
{
"desktop_active": true,
"desktop_state": "active",
"last_seen": "2026-07-29T14:58:12.000Z",
"phase": "connecting",
"linkedin_status": "normal",
"active_campaign_count": 2,
"connections_sent_today": 14,
"accounts": [
{ "display_name": "Jane Doe", "is_running": true, "phase": "connecting",
"campaign_id": 42, "daily_actions": 14, "daily_limit": 30,
"weekly_actions": 61, "weekly_limit": 100, "linkedin_status": "normal",
"started_at": "2026-07-29T13:02:44.000Z",
"last_action_at": "2026-07-29T14:55:03.000Z" }
]
}When it has not reported in the last 75 minutes, and when it has never reported:
{ "desktop_active": false, "desktop_state": "went_quiet", "last_seen": "2026-07-29T12:40:02.000Z", "accounts": [] }
{ "desktop_active": false, "desktop_state": "never_seen", "last_seen": null, "accounts": [] }GET/api/v1/leads
Per-lead read across your campaigns: name, title, company, LinkedIn URL, status (pending / warming_up / sent / connected / replied / meeting_booked / failed / invitation_withdrawn …), replied_at, follow-up step reached, and calendar_clicked (the tracked calendar-link redirect — the only click signal that exists; general link click tracking and "who opened" are not available). Query params: campaign_id, status, updated_since (ISO 8601), limit (max 500), offset.
curl -H "Authorization: Bearer zm_your_api_key_here" \
"https://www.zen-mode.io/api/v1/leads?status=replied&limit=100"Example response:
{
"leads": [
{ "id": 37110, "campaign_id": 42, "name": "Omar Molina",
"title": "COO", "company": "Acme Ops", "location": "Mexico City, Mexico",
"linkedin_url": "https://www.linkedin.com/in/example",
"status": "replied", "follow_up_number": 1, "calendar_clicked": false,
"connection_sent_at": "2026-07-16T19:02:11.000Z",
"connection_accepted_at": "2026-07-17T17:58:25.000Z",
"replied_at": "2026-07-20T15:12:40.000Z",
"created_at": "2026-07-15T18:04:41.000Z",
"updated_at": "2026-07-20T15:12:40.000Z" }
],
"pagination": { "limit": 100, "offset": 0, "returned": 1 }
}GET/api/v1/leads/{id}/activity
The message history for one lead, oldest first. Each entry carries direction (sent / received), the full message text, its source, and created_at. Query params: direction, limit (max 500), offset. A lead you removed from a campaign returns 404, matching /api/v1/leads.
curl -H "Authorization: Bearer zm_your_api_key_here" \
"https://www.zen-mode.io/api/v1/leads/37110/activity"Example response:
{
"lead": { "id": 37110, "campaign_id": 42, "name": "Omar Molina",
"linkedin_url": "https://www.linkedin.com/in/example", "status": "replied" },
"activity": [
{ "id": 90114, "direction": "sent", "text": "Hi Omar — saw you lead ops at Acme...",
"source": "automation", "read_by_you": null,
"created_at": "2026-07-17T18:20:04.000Z" },
{ "id": 90562, "direction": "received", "text": "Thanks for reaching out — what does onboarding look like?",
"source": "voyager-sync", "read_by_you": false,
"created_at": "2026-07-20T15:12:40.000Z" }
],
"pagination": { "limit": 100, "offset": 0, "returned": 2 }
}Three things to read correctly before you build on this.
1. Outbound is not only what ZenMode sent. The source field tells you which: automation is a message ZenMode sent (a campaign follow-up, or a Cloud Queue task); voyager-sync and sync are mirrored from the LinkedIn thread itself, which includes messages you typed by hand in LinkedIn and conversation history predating the campaign; manual / web_sent / web_queued were sent from the ZenMode inbox. A lead can legitimately show more outbound messages than your sequence has steps — counting rows here is not counting campaign sends.
2. InMail is not distinguishable. Nothing on a message records its type, so an InMail and a regular message look identical here. Treat every row as a regular message.
3. read_by_you is your own inbox read state — NOT an open and NOT a read receipt. It means you have read that inbound reply in ZenMode. It says nothing about whether the prospect opened or read anything: LinkedIn gives us no open or read data, so it does not exist in this API. It is null on outbound rows, where the underlying value is set unconditionally and carries no information at all.
PATCH/api/v1/leads/{id}
Set a lead's status. The writable set is meeting_booked, replied, not_interested and connected — the same four the “mark as…” control in the ZenMode CRM writes. Anything else returns 400 naming the valid set. A lead you do not own, or one you removed from a campaign, returns 404, matching /api/v1/leads.
curl -X PATCH -H "Authorization: Bearer zm_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"status": "not_interested"}' \
"https://www.zen-mode.io/api/v1/leads/37110"Example response:
{
"changed": true,
"lead": { "id": 37110, "campaign_id": 42, "name": "Omar Molina",
"status": "not_interested", "replied_at": null,
"updated_at": "2026-08-27T09:14:02.000Z" },
"previous_status": "connected"
}It records a decision. It sends nothing, and it invents no history.
Only status and updated_at change. Marking a lead replied does not set replied_at, and marking one connected does not set connection_accepted_at — those timestamps describe real LinkedIn events, and a lead you mark by hand has not produced one. So a lead can read status: "replied" with replied_at: null; that is correct, not a gap.
Read changed, not the status code. A 200 and every 409 carry a changed boolean saying whether this request actually moved the lead — that is, whenever the request was well-formed and addressed a lead you own. A 400, 401, 403, 404, 429 or 500 carries no changed field at all, because nothing was evaluated. It matters because a 200 alone is ambiguous: setting a lead to the status it already had succeeds and moves nothing, and you should not have to compare previous_status against lead.status yourself to find that out.
Two transitions are refused with a 409 rather than applied quietly, each with changed: false, the lead's current status, and the requested_status echoed back. A lead whose invitation was withdrawn (invitation_withdrawn) accepts only replied, meeting_booked and connected — real engagement outranks a withdrawal, but a withdrawal is not reversible from this API. And connected will not demote a lead that already reached replied or meeting_booked:
{
"error": "This lead is already 'replied', which is further along than 'connected'. Refusing to demote it.",
"code": "promote_only_guard",
"changed": false,
"requested_status": "connected",
"lead": { "id": 37110, "status": "replied" }
}Create a lead — POST /api/v1/leads
Adds one contact to a campaign you own. It writes a single row and does nothing else — no invite is sent, no message is queued, no task is created, and the campaign is not started. Execution is the desktop app's job; see How execution actually works below.
POST /api/v1/leads
Authorization: Bearer zm_your_api_key
Content-Type: application/json
{
"campaign_id": 42, // required, positive integer
"linkedin_url": "https://www.linkedin.com/in/some-person", // required
"name": "Some Person", // optional
"title": "Head of Ops", // optional
"company": "Acme", // optional
"location": "London, UK" // optional
}The URL must contain either /in/<slug> or /sales/lead/<id> — those are the only two identities a lead can be keyed on. It is stored normalised: query strings and trailing slashes are stripped. The four optional fields are best-effort: a non-string value is stored as null rather than rejected, and there is no length limit.
201 returns the created lead in the same shape GET /api/v1/leads uses, an identity object carrying whichever of linkedin_slug / sales_lead_id was extracted, and a delivery object (below). A lead in a nurture campaign is created with status: "connected"; every other campaign type starts at "pending".
delivery — will anything actually work this lead?
A description of what the server could see at the moment of the push, not a promise. The desktop app does the sending, the server only knows what that desktop last reported (about once an hour), and a campaign can be paused a second after the response. There is deliberately no "will send" state.
"delivery": {
"state": "queued_running",
"reasons": ["warmup_first"],
"campaign": { "status": "active", "approval_state": "not_required", "linkedin_account_id": 12 },
"desktop": {
"state": "active", "last_seen_at": "2026-09-15T11:31:02.000Z", "age_seconds": 1740,
"app_version": "1.3.210", "account_running": true, "running_campaign_id": 42,
"works_all_active_campaigns_on_account": true
},
"checked_at": "2026-09-15T12:00:02.000Z"
}state is the first of these that applies:
paused_by_this_push— the campaign runs on someone else's LinkedIn account, its owner had approved it, and adding this lead reset that approval and paused the campaign. The owner has to re-approve.campaign_not_active— the campaign is paused or completed. If this push reset its approval without pausing it (it was not active),reasonsincludesapproval_reset_by_this_push.awaiting_approval— the campaign runs on someone else's account and is not approved, so no desktop will receive it.unknown— a lookup failed, or the desktop reported a run it did not attribute to a campaign. Treat as unknown, not as good or bad news.no_desktop_seen— no desktop has reported in the last 75 minutes (desktop.statesays whether one was ever seen).desktop_idle— a desktop is up, but it reported nothing running on this campaign's LinkedIn account. Someone has to click Run.queued_running— a desktop reported running this campaign, or (desktop 1.3.204 and later) another campaign on the same LinkedIn account. The next pickup can still be up to two hours away.
reasons can appear with any state: account_disabled, account_cooldown, warmup_first, stop_date_passed, daily_cap_reached, location_filter_applies, approval_reset_by_this_push. Business hours are not reported, because the desktop checks them in each contact's own timezone.
409 already_exists — read this before you write your integration
This is the most surprising behaviour in the API. By default a campaign is set to exclude people you have contacted before, and when it is, the duplicate check looks across your entire account — not just the campaign you are pushing into. So adding a known contact to a brand-new sequence returns 409, not 201.
The scope field tells you which rule applied: "all_campaigns" when the campaign has exclude_previously_messaged on, "this_campaign" when it is off. In production today 262 of 270 campaigns have it on, and all eight exceptions are nurture campaigns — so every connect campaign that exists returns "all_campaigns".
{
"error": "This person is already in one of your campaigns.",
"code": "already_exists",
"lead": { "id": 41200, "campaign_id": 17, "status": "sent", "removed": false },
"scope": "all_campaigns"
}lead points at the oldest matching row that blocks. removed: true means that lead was removed from its campaign; it is invisible to GET /api/v1/leads, so without this field the id would look like a phantom. A removed lead blocks the push unless someone removed it by hand from a different campaign — the same rule a CSV upload follows. A lead removed by the system (an invite withdrawn after going unanswered, or a do-not-contact decision) always blocks, and so does a lead removed by hand from the campaign you are pushing into. A lead whose only row is failed does not block a push to a different campaign. Matching is on identity, so case, ?utm= parameters and trailing slashes all collapse to the same person.
🔴 One edge case worth coding for: if two pushes of the same person race, the loser returns the same error and code but carries no lead and no scope. Read scope defensively.
409 suppressed is returned when the person is on your do-not-contact list. It carries code: "suppressed", the normalised linkedin_url and the identity. Remove the suppression first if it was intentional — see DELETE /api/v1/suppressions.
409 campaign_exclusion is returned when the campaign's own exclusion list (set in the dashboard) matches the person, the same list a CSV upload honours. matched_on is company or url. Company matching is deliberately loose: after dropping punctuation and suffixes such as Inc or Ltd, either name containing the other counts as a match.
409 csv_message_campaign is returned for a campaign whose messages come from columns of an uploaded CSV. Those messages are filled from each contact's own CSV row, which a pushed lead does not have, so the API refuses rather than add a lead that would be sent a placeholder. Push into a campaign that uses message templates instead.
Other responses
400 invalid_campaign_id campaign_id is required and must be a positive integer 400 linkedin_url is required 400 invalid_url linkedin_url must be a linkedin.com profile URL 400 no_identity URL has no /in/<slug> or /sales/lead/<id> path 400 inmail_requires_sn_url The campaign is InMail and the URL is not a Sales Navigator lead URL 404 Campaign not found (also returned for a campaign you do not own) 409 campaign_exclusion The campaign's exclusion list matches this person (matched_on: company | url) 409 csv_message_campaign The campaign's messages come from CSV columns the API cannot supply 503 approval_check_failed The campaign's approval state could not be confirmed; nothing was added 500 Failed to create lead
URL validation runs before the ownership check, so a malformed URL aimed at a campaign you do not own returns 400, not 404.
One side effect to know about. If the campaign runs on a LinkedIn account owned by someone else and that account holder had already approved it, adding a lead marks the campaign as changed and pauses it, so the owner can re-approve. That is deliberate — it never starts or escalates anything — and the response says so: delivery.state is paused_by_this_push. The check runs before the lead is written, so if it cannot run the request fails with 503 approval_check_failed and nothing is added. If the push then loses a duplicate race, the campaign has still been paused, and the 409 carries delivery to say so.
Create leads in bulk — POST /api/v1/leads/bulk
Adds up to 500 contacts to one campaign you own in a single request, with a result for each. Every item is judged exactly as POST /api/v1/leads would judge it on its own (validation, do-not-contact, the campaign's exclusion list, duplicates), plus one outcome only a batch can have: duplicate_in_request, for a person who appears earlier in the same request. Like the single endpoint, it writes rows and sends nothing.
POST /api/v1/leads/bulk
Authorization: Bearer zm_your_api_key
Content-Type: application/json
{
"campaign_id": 42,
"items": [
{ "linkedin_url": "https://www.linkedin.com/in/first-person", "name": "First Person", "company": "Acme" },
{ "linkedin_url": "https://www.linkedin.com/in/second-person" }
]
}The response is 200 whenever the request itself is valid, even if every item was refused. results has one entry per item, in the order you sent them:
{
"campaign_id": 42,
"summary": { "received": 2, "created": 1, "duplicate": 1, "duplicate_in_request": 0,
"suppressed": 0, "excluded": 0, "refused": 0, "invalid": 0 },
"results": [
{ "index": 0, "outcome": "created", "http_status": 201,
"lead": { "id": 51001, "campaign_id": 42, "status": "pending", ... },
"identity": { "linkedin_slug": "first-person", "sales_lead_id": null } },
{ "index": 1, "outcome": "duplicate", "http_status": 409, "code": "already_exists",
"error": "This person is already in one of your campaigns.", "hint": "...",
"lead": { "id": 41200, "campaign_id": 17, "status": "sent", "removed": false },
"scope": "all_campaigns" }
],
"delivery": { "state": "queued_running", ... }
}outcomeis one ofcreated,duplicate,duplicate_in_request(carriesfirst_index),suppressed,excluded(codecampaign_exclusion),refused(codecsv_message_campaign) orinvalid.http_statusis the status the single endpoint would have returned for that item. The other fields match that endpoint's body for the same case.- One
deliverycovers the whole request, because every item is in the same campaign. - A
duplicatewithlead: nullmeans the row appeared between our check and the write, or the same URL is already in this campaign as afailedlead.
Retries are safe. There is no idempotency key because none is needed: an item that already landed comes back as duplicate. The approval side effect described above runs once per request, and only when at least one item is about to be added.
Limits and errors
400 invalid_json Body is not a JSON object 400 invalid_campaign_id campaign_id is required and must be a positive integer 400 items_required items is missing, not an array, or empty 400 too_many_items More than 500 items; split the list 404 campaign_not_found Campaign not found (also returned for a campaign you do not own) 429 rate_limit_exceeded Over 100 requests/minute for this key (every v1 route) 429 item_rate_limit_exceeded Over 2,000 items/minute for this key; nothing in the request was processed 503 approval_check_failed Approval state could not be confirmed; nothing in the request was added 500 lead_create_failed Our fault; some items may have been added, and retrying is safe
Both rate limits are counted in memory on each server instance, so treat them as the contract rather than as an exact ceiling. Sending volume is limited by the desktop's LinkedIn caps, not by how fast you push.
Suppressions — do-not-contact
Add a person to your do-not-contact list, and take them off it again. Suppression is keyed on the person (their /in/ slug and /sales/lead/ id), not on a single campaign row, so both URL forms of the same person are covered and every future import skips them.
GET/api/v1/suppressions
Read your do-not-contact list back so your CRM can reconcile against it. Paging is limit / offset and ordering is newest-first, exactly like GET /api/v1/leads — limit defaults to 100 and caps at 500.
curl -H "Authorization: Bearer zm_your_api_key_here" \
"https://www.zen-mode.io/api/v1/suppressions?limit=100&offset=0"Example response:
{
"suppressions": [
{
"id": 4821,
"linkedin_url": "https://www.linkedin.com/in/example",
"name": "Example Person",
"reason": "api_suppression",
"identity": { "linkedin_slug": "example", "sales_lead_id": null },
"created_at": "2026-08-26T10:00:00.000Z"
}
],
"pagination": { "limit": 100, "offset": 0, "returned": 1 }
}This returns everyone on your do-not-contact list, not only the people suppressed through this API — contacts you removed in the CRM with “never contact again” (crm_removal_never_contact) and people dropped by the a Withdrawal campaign (invitation_withdrawn) block future imports in exactly the same way, so leaving them out would tell you someone is contactable when they are not. Use reason to tell them apart: DELETE removes any of them from the list, but only restores campaign rows for an api_suppression.
Ordering is newest-first, so a suppression created while you are paging lands on page 0 and can push a row you have not read yet onto the next page. For an exact reconciliation, walk to the end and walk again — this read and DELETE are both idempotent.
POST/api/v1/suppressions
Suppress a person. This does two things: it records them on your do-not-contact list so future imports skip them, and it stops outreach already in flight by removing their live rows from your campaigns. Without the second step the list would only be consulted at import time and a pending invite would still go out.
curl -X POST -H "Authorization: Bearer zm_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"linkedin_url": "https://www.linkedin.com/in/example"}' \
https://www.zen-mode.io/api/v1/suppressionsExample response (201):
{
"suppressed": true,
"already_suppressed": false,
"linkedin_url": "https://www.linkedin.com/in/example",
"identity": { "linkedin_slug": "example", "sales_lead_id": null },
"leads_stopped": 1,
"stopped_lead_ids": [37110]
}leads_stopped: 0 is normal — it just means that person was not in any of your campaigns. A URL with no /in/<slug> or /sales/lead/<id> path returns 400 no_identity rather than silently suppressing nobody.
DELETE/api/v1/suppressions
Suppression is reversible. If you suppress someone by mistake, this undoes it — it removes them from your do-not-contact list and restores the campaign rows the suppression itself removed. It is idempotent: undoing a suppression that is not there returns 200 with zero counts, so it is always safe to retry.
curl -X DELETE -H "Authorization: Bearer zm_your_api_key_here" \
"https://www.zen-mode.io/api/v1/suppressions?linkedin_url=https://www.linkedin.com/in/example"Example response:
{
"unsuppressed": true,
"linkedin_url": "https://www.linkedin.com/in/example",
"identity": { "linkedin_slug": "example", "sales_lead_id": null },
"exclusions_removed": 1,
"leads_restored": 1,
"restored_lead_ids": [37110]
}It restores only what the API suppression removed. Contacts you removed by hand in the CRM and invitations withdrawn by a Withdrawal campaign stay removed — undo those where you made them. That is deliberate: a blanket restore would resume outreach to people removed for entirely unrelated reasons.
Task Lifecycle
pending — Task queued, waiting for desktop app to pick up
processing — Desktop app is executing the task
completed — Action performed successfully on LinkedIn
failed — Action failed (check result for details)
cancelled — Task was cancelled before processing
Business hours. A connect or message task is handed to the desktop app only inside the account's business hours; outside them it stays pending and goes out at the first check inside the window. The hours are those of your campaign on the LinkedIn account that runs the task (the most recently updated one, active campaigns first), applied even if that campaign has business hours switched off. With no campaign they are 08:00–18:00, Monday to Friday, London time. follow, view_profile and withdraw_connection are not held.
Error Codes
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or invalid fields |
| 401 | Unauthorized — missing or invalid API key |
| 403 | Forbidden — your plan does not include API access (Novice, or no active plan) |
| 404 | Not found — task or webhook doesn't exist |
| 429 | Too many requests (wait 60s), or the daily task cap is reached (wait 3600s) — the body and Retry-After tell you which |
| 500 | Server error |
Need help?
Generate your API key and start sending tasks in minutes.