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.

bash
curl -H "Authorization: Bearer zm_your_api_key_here" \
  https://www.zen-mode.io/api/v1/tasks

Keep 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) every endpoint returns:

json
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"
}

Rate Limits

API Requests

100 per minute

Task Creation

30–50 per day per LinkedIn account (by account tier)

Exceeding limits returns 429 Too Many Requests.

Endpoints

POST/api/v1/tasksCreate a task

Queue a LinkedIn action. The desktop app picks it up automatically.

Request Body

json
{
  "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.

Response (201)

json
{
  "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"
  }
}
GET/api/v1/tasksList tasks

Retrieve 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

bash
curl -H "Authorization: Bearer zm_..." \
  "https://www.zen-mode.io/api/v1/tasks?status=completed&limit=10"
GET/api/v1/tasks/:idGet task status
bash
curl -H "Authorization: Bearer zm_..." \
  https://www.zen-mode.io/api/v1/tasks/task_m1abc_x7k2p9qr
DELETE/api/v1/tasks/:idCancel a pending task

Only tasks in pending or queued status can be cancelled.

bash
curl -X DELETE -H "Authorization: Bearer zm_..." \
  https://www.zen-mode.io/api/v1/tasks/task_m1abc_x7k2p9qr

Webhooks

Register a webhook URL to receive POST callbacks when a task finishes or a lead replies.

POST/api/v1/webhooksRegister a webhook
json
{
  "url": "https://your-server.com/zenmode-webhook",
  "events": ["task.completed", "task.failed", "lead.replied"]
}

URL must use HTTPS. 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.

GET/api/v1/webhooksList webhooks
DELETE/api/v1/webhooks?id=123Delete a webhook

Webhook 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

json
{
  "event": "task.completed",
  "data": {
    "task_id": "task_m1abc_x7k2p9qr",
    "action_type": "connect",
    "status": "completed",
    "result": { "success": true },
    "linkedin_account_id": "acc_abc123"
  },
  "timestamp": "2026-04-02T10:35:00.000Z"
}

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.

json
{
  "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"
  },
  "timestamp": "2026-07-20T15:12:41.000Z"
}

The payload carries identity and timestamps only — never the reply text. We do not push message content to a third-party URL. Fetch the reply itself from GET /api/v1/leads/{lead_id}/activity, on a request you make, authenticated with your own key.

Verifying Signatures

javascript
const crypto = require('crypto');

function verifyWebhook(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Code Examples

cURL

bash
# 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

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

javascript
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).

bash
curl -H "Authorization: Bearer zm_your_api_key_here" \
  "https://www.zen-mode.io/api/v1/analytics?days=30"

Example response:

json
{
  "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/status

Live desktop runtime status: whether the desktop app is active (heartbeat within 10 minutes), 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.

bash
curl -H "Authorization: Bearer zm_your_api_key_here" \
  https://www.zen-mode.io/api/v1/status

Example response (desktop running; when it isn't: { "desktop_active": false, "accounts": [] }):

json
{
  "desktop_active": true,
  "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" }
  ]
}

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 …), 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.

bash
curl -H "Authorization: Bearer zm_your_api_key_here" \
  "https://www.zen-mode.io/api/v1/leads?status=replied&limit=100"

Example response:

json
{
  "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.

bash
curl -H "Authorization: Bearer zm_your_api_key_here" \
  "https://www.zen-mode.io/api/v1/leads/37110/activity"

Example response:

json
{
  "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.

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.

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.

bash
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/suppressions

Example response (201):

json
{
  "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.

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

json
{
  "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, invitations withdrawn, and rows dropped by the expired-invite sweep 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

pendingprocessingcompleted
orprocessingfailed

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

Error Codes

StatusMeaning
400Bad request — missing or invalid fields
401Unauthorized — missing or invalid API key
403Forbidden — no active subscription
404Not found — task or webhook doesn't exist
429Rate limit exceeded — wait 60 seconds
500Server error

Need help?

Generate your API key and start sending tasks in minutes.