Developer Docs
ZenMode REST API
Push LinkedIn automation tasks and read your campaign data programmatically. Available on Practitioner and above (a free trial counts as the plan you are trialling). Not on Novice.
Prefer to ask in plain English? Connect ZenMode to Claude with MCP and check campaigns, find contacts and read conversations from a chat.Connect to Claude →Connect your AI assistant (MCP)
ZenMode also runs an MCP server, so any AI assistant that supports MCP (Claude, ChatGPT, Gemini, Cursor and others) can work with your account directly: check how your campaigns are doing, look people up, read a conversation, add people to a campaign or change who it targets. It can also write messages, build a campaign and start it once you say yes. ZenMode then sends them within your daily limits and business hours, while the desktop app is open. Same plans as the API.
Server address
https://www.zen-mode.io/api/mcpIn claude.ai (and the Claude desktop and mobile apps)
- Open Customize → Connectors, click +, then Add custom connector.
- Name it ZenMode, paste the server address above, and click Connect.
- On the ZenMode page that opens, sign in if asked, choose Read only or Read and change, pick an API key, and click Allow.
- In a chat, open the + menu, go to Connectors and switch ZenMode on.
In ChatGPT
- In settings, turn on Developer mode (under Apps → Advanced settings).
- Create a new app: name it ZenMode, paste the server address, choose OAuth and click Create.
- On the ZenMode page that opens, sign in if asked, choose what ChatGPT may do, pick an API key, and click Allow.
Developer mode depends on your ChatGPT plan, and OpenAI may limit changes on some plans.
In the Gemini app
- At gemini.google.com, open Settings → Connected Apps and, under Custom apps, choose Add a custom app.
- Paste the server address, click Next, then click Allow on the ZenMode page that opens.
Google offers custom apps in the Gemini app for personal Google accounts in the US for now.
In Claude Code, with an API key from the dashboard:
claude mcp add --transport http zenmode https://www.zen-mode.io/api/mcp --header "Authorization: Bearer YOUR_ZENMODE_API_KEY"In Cursor and other tools that read an mcpServers file, add ZenMode to the tool's MCP settings with an API key. (VS Code and Windsurf use a different file layout, so this block does not paste into them as it is.)
{
"mcpServers": {
"zenmode": {
"url": "https://www.zen-mode.io/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_ZENMODE_API_KEY"
}
}
}
}An assistant using an API key directly can only look things up unless that key was created with Let AI assistants make changes ticked. The full guide, with every tool an assistant can use, its limits and example prompts: Connect ZenMode to Claude.
Signals tools: Signals is included on every plan except Novice. On an account with Signals, an assistant can read what Signals watches, the credits left, and the people it found (by competitor, topic, date and score), and add them to a campaign exactly as the Signals tab does. Finding new people (1 credit each) and researching one (1 credit) take two calls: the first spends nothing and says the cost and the credits left; credits are spent only on the second call, with the confirm token, after you say yes.
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 on Practitioner and above, and so is the MCP server. A trial counts as the plan being trialled. On the Novice tier (or without an active plan) every endpoint returns:
HTTP 403
{
"error": "API and MCP access is on Practitioner and above. Upgrade here: https://www.zen-mode.io/dashboard/billing",
"code": "plan_upgrade_required",
"hint": "Upgrade here: https://www.zen-mode.io/dashboard/billing"
}The task, webhook, key and data endpoints all return this same 403 body. Your keys are never deleted: when the account moves to Practitioner or above they work again, with no other step.
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
Messages to people who have replied are refused. A message task is refused with 409 recipient_replied when that person has replied to this account after ZenMode first contacted them, in any campaign or conversation, or, for a person reached only through this API, after their first completed connect or message task. A task queued before the reply arrived is not sent: it ends failed with result.error: "recipient_replied" and a task.failed webhook. To send anyway, for example when you are deliberately replying to them, add "allow_after_reply": true to the request body (next to action_type). A 503 reply_check_unavailable means the check could not run and nothing was queued; retry with backoff.
Contacts who asked to stop are never messaged. A message or connect task to someone whose reply asked this account to stop is refused with 409 recipient_asked_to_stop, and allow_after_reply does not override it.
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 when a lead is sent an invitation, accepts it or replies.
/api/v1/webhooks— Register a webhook{
"url": "https://your-server.com/zenmode-webhook",
"events": ["task.completed", "task.failed", "lead.replied", "lead.accepted", "lead.sent"]
}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.accepted, lead.sent.
The lead.* events are opt-in: an existing webhook keeps receiving only the events it registered for. Re-register with them listed to start receiving them.
/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",
"linkedin_account_id": 12,
"linkedin_account_name": "Sales seat 1"
},
"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.
lead.sent and lead.accepted
lead.sent fires when a lead's connection request is first recorded as sent, and lead.accepted when the acceptance is first recorded. Each fires once per lead; recording the same step again sends nothing. An InMail is not a connection request and does not fire lead.sent.
{
"event": "lead.accepted",
"data": {
"lead_id": 37110,
"campaign_id": 42,
"name": "Omar Molina",
"linkedin_url": "https://www.linkedin.com/in/example",
"accepted_at": "2026-07-18T09:02:11.000Z",
"linkedin_account_id": 12,
"linkedin_account_name": "Sales seat 1"
},
"timestamp": "2026-07-18T09:02:12.000Z"
}lead.sent has the same shape with sent_at in place of accepted_at.
The seat. Every lead.* event carries linkedin_account_id and linkedin_account_name: the LinkedIn account the lead's campaign runs on (its label in ZenMode, else its LinkedIn name). When the campaign is not tied to an account and you have exactly one, that one; otherwise both are null. They are always present as 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,
"acceptance_rate_basis": { "campaign_type": "connect", "sent": 305, "accepted": 121 },
"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" }
}PUT/api/v1/leads/{id}/messages
Set or replace the connection note and follow-ups for one lead, instead of the campaign's. Send any of connection_request, follow_up_1, follow_up_2 and follow_up_3; steps you leave out are kept as they are. A step can only be set while it has not been sent. A lead you do not own, or one you removed from a campaign, returns 404. It sends nothing.
curl -X PUT -H "Authorization: Bearer zm_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"messages": {"connection_request": "Hi {{first_name}}, saw your post on onboarding at {{company}}.",
"follow_up_1": "Thanks for connecting. Worth a look? {{calendar_link}}"}}' \
"https://www.zen-mode.io/api/v1/leads/37110/messages"Example response (the text as it will be sent):
{
"lead_id": 37110,
"campaign_id": 42,
"messages": {
"connection_request": "Hi Omar, saw your post on onboarding at Acme.",
"follow_up_1": "Thanks for connecting. Worth a look? https://www.zen-mode.io/api/calendar/redirect?c=37110"
}
}Each message is filled in with the lead's name, title, company and location and checked the way a CSV upload is: a connection note over the account's note limit is refused and never shortened, and {{calendar_link}} becomes this lead's tracked calendar link. Unlike a CSV upload, a variable the lead has no value for is refused on every step rather than removed. Once a lead has any message set here, the steps you set send exactly your text and the steps you do not set send the campaign's messages; ZenMode's AI no longer writes messages for that lead, and a change to the campaign's messages no longer replaces them. This works on AI-written campaigns too; one that reviews messages still asks you to approve the lead's messages.
400 invalid_messages Not an object of the four keys with non-empty text (max 8000 characters each)
404 lead_not_found Not your lead, or it was removed from its campaign
409 unsupported_campaign_type Only connect and nurture campaigns send these messages
409 message_already_sent A step you sent has already gone out (or the lead is past it)
409 concurrent_update The lead's messages changed during the request; read it again and retry
422 campaign_sends_no_connection_note The campaign sends no connection request
422 follow_up_step_not_configured The campaign has no such follow-up step, so it would never be sent
422 missing_variables A variable the lead has no value for (missing_variables lists them)
422 connection_note_over_limit The note is too long for this account (length, limit)
422 empty_message The message is empty once its variables are filled inCreate 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
"messages": { // optional: this lead's own text (see PUT /api/v1/leads/{id}/messages)
"connection_request": "Hi {{first_name}}, ...",
"follow_up_1": "..."
}
}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 ZenMode took out of a different campaign before any invite (its title did not match, or it dropped out in warm-up) does not block either, and a lead taken out because it was already a connection does not block a push into a Nurture campaign. Any other removal by the system (an invite withdrawn after going unanswered, or a do-not-contact decision) always blocks, and so does any removed lead in 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 when the lead carries no messages. 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. Send the lead's own text in messages, or push into a campaign that uses message templates instead.
messages is checked exactly as PUT /api/v1/leads/{id}/messages checks it, before anything is written: a lead whose messages fail is not added (400 invalid_messages, a 409 or a 422 with the same codes). The text is stored with the lead, so no desktop ever sees it without its messages. POST /api/v1/leads/bulk takes the same field on each item.
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 and the lead has no messages
4xx (messages codes) See PUT /api/v1/leads/{id}/messages
503 approval_check_failed The campaign's approval state could not be confirmed; nothing was added
500 Failed to create leadURL 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.