LinkedIn Outreach Engine

API

Public REST API for external tooling — read campaigns/leads/analytics/inbox, push leads into a campaign. Same data as the dashboard, thin Bearer-token wrapper.

Auth

Every request needs an Authorization: Bearer <token> header. Generate or rotate your token on the Settings page (General tab, “API token” card) — shown once, only the hash is stored. Missing or wrong token → 401. These routes sit outside the session-cookie gate, so the token is the only thing protecting them; treat it like a password.

Authorization: Bearer <token>

Base URL

https://caldenmoore.co — every endpoint below is relative to this.

Endpoints

GET/api/v1/linkedin/campaigns

List every campaign.

Response: { campaigns: Campaign[] }

curl -H "Authorization: Bearer $TOKEN" \
  https://caldenmoore.co/api/v1/linkedin/campaigns
GET/api/v1/linkedin/campaigns/:id/leads

Leads for one campaign.

Response: { leads: CampaignLead[] }

curl -H "Authorization: Bearer $TOKEN" \
  https://caldenmoore.co/api/v1/linkedin/campaigns/c_123/leads
POST/api/v1/linkedin/campaigns/:id/leads

Push leads into a campaign. Dedupes by providerId within the campaign, and cross-campaign too if the campaign has skipExisting on.

Body: { leads: [{ name, providerId, slug?, openProfile?, jobTitle?, company?, customVariables? }] }

Response: { added, skipped, total }

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"leads":[{"name":"Jane Doe","providerId":"ACwAAA..."}]}' \
  https://caldenmoore.co/api/v1/linkedin/campaigns/c_123/leads
GET/api/v1/linkedin/leads

All leads across every campaign. Optional status filter.

Query: ?status=pending|invited|replied|accepted|skipped

Response: { leads: CampaignLead[] }

curl -H "Authorization: Bearer $TOKEN" \
  "https://caldenmoore.co/api/v1/linkedin/leads?status=replied"
GET/api/v1/linkedin/analytics

Per-campaign lifetime counters + the daily send/reply series.

Query: ?days= (1-90, default 30)

Response: { stats: [{id,name,status,leads,sent,connections,accepted,inmails,replies,positiveReplies}], series }

curl -H "Authorization: Bearer $TOKEN" \
  "https://caldenmoore.co/api/v1/linkedin/analytics?days=7"
GET/api/v1/linkedin/inbox

Chat list across connected accounts.

Query: ?accountId= ?unread=1 ?limit= (max 100, default 50)

Response: { chats: Chat[], cursor }

curl -H "Authorization: Bearer $TOKEN" \
  "https://caldenmoore.co/api/v1/linkedin/inbox?unread=1&limit=20"
POST/api/v1/linkedin/accounts/connect

Bulk-connect LinkedIn accounts by credentials or li_at cookie — same native-auth flow as the Accounts page, scriptable. Processed one row at a time; each result always includes the account_id even if a checkpoint is pending.

Body: { accounts: [{ label?, mode?: "password"|"cookie", username?, password?, accessToken?, userAgent?, proxy?: {host,port,username?,password?}, country?, totpSecret?, saveForReconnect? }] }

Response: { results: [{ label, ok, connected?, needsCheckpoint?, accountId?, checkpointType?, error? }] }

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"accounts":[{"label":"jane","mode":"password","username":"jane@x.com","password":"...","totpSecret":"JBSWY3DPEHPK3PXP","saveForReconnect":true}]}' \
  https://caldenmoore.co/api/v1/linkedin/accounts/connect
POST/api/v1/linkedin/accounts/checkpoint

Solve a checkpoint (2FA/OTP/IN_APP_VALIDATION/CAPTCHA/PHONE_REGISTER) returned by accounts/connect. Auto-solved already if totpSecret was given and the checkpoint was 2FA/OTP — this is for everything else, or a totpSecret-less row. Send code:"TRY_ANOTHER_WAY" to switch off IN_APP_VALIDATION.

Body: { accountId, code }

Response: { connected: true, accountId } | { needsCheckpoint: true, accountId, checkpointType }

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"accountId":"acc_123","code":"482913"}' \
  https://caldenmoore.co/api/v1/linkedin/accounts/checkpoint
POST/api/v1/linkedin/accounts/checkpoint/resend

Re-triggers the 2FA/OTP/IN_APP_VALIDATION notification for a pending checkpoint.

Body: { accountId }

Response: { ok: true }

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"accountId":"acc_123"}' \
  https://caldenmoore.co/api/v1/linkedin/accounts/checkpoint/resend

Infinite login (auto-reconnect)

A LinkedIn account can save its login credentials (encrypted server-side, AES-256-GCM) plus a TOTP authenticator secret. If that account later gets disconnected, the engine tick automatically re-logs in with the saved password and, if a 2FA/OTP checkpoint comes up, solves it itself using a generated TOTP code — no human needed. Cooldown of 1 hour between attempts per account so a genuinely broken login (bad password, LinkedIn ban) doesn't get hammered every 5-minute tick.

Setup, either via the UI or the API:

  1. On LinkedIn: Settings & Privacy → Sign in & security → Two-step verification → Authenticator app.
  2. When it shows a QR code, look for "Can't scan the image?" (or similar) to reveal a plain-text setup key instead of scanning it into an app.
  3. Pass that key as totpSecret (API) or paste it into the "TOTP setup key" field on the Accounts page, alongside saveForReconnect: true.
  4. LinkedIn still requires confirming setup with a live 6-digit code at that step — the Accounts page shows a live-updating code generated from the same key so you don't need an actual authenticator app open. The API doesn't generate a preview code (nothing to confirm server-side yet) — use the UI for the one-time LinkedIn setup step, then reuse the same secret via the API afterward if you want to.

Without a totpSecret, saveForReconnect alone still lets the engine auto-relogin with the password, it just can't clear a 2FA prompt by itself — that case falls back to the normal "needs manual reconnect" Slack notice. Credentials without saveForReconnect are never stored at all — the request round-trips to Unipile and is discarded.

Notes

  • Server-only fields (offer profile bodies, calendar/API keys, etc.) never appear in any response, on this API or the internal one.
  • No rate limiting beyond what the underlying Unipile calls already impose — this is a single-user tool, not a multi-tenant API.
  • Rotating the token in Settings invalidates the previous one immediately.