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
/api/v1/linkedin/campaignsList every campaign.
Response: { campaigns: Campaign[] }
curl -H "Authorization: Bearer $TOKEN" \ https://caldenmoore.co/api/v1/linkedin/campaigns
/api/v1/linkedin/campaigns/:id/leadsLeads for one campaign.
Response: { leads: CampaignLead[] }
curl -H "Authorization: Bearer $TOKEN" \ https://caldenmoore.co/api/v1/linkedin/campaigns/c_123/leads
/api/v1/linkedin/campaigns/:id/leadsPush 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/api/v1/linkedin/leadsAll 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"
/api/v1/linkedin/analyticsPer-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"
/api/v1/linkedin/inboxChat 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"
/api/v1/linkedin/accounts/connectBulk-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/api/v1/linkedin/accounts/checkpointSolve 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/api/v1/linkedin/accounts/checkpoint/resendRe-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/resendInfinite 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:
- On LinkedIn: Settings & Privacy → Sign in & security → Two-step verification → Authenticator app.
- 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.
- Pass that key as
totpSecret(API) or paste it into the "TOTP setup key" field on the Accounts page, alongsidesaveForReconnect: true. - 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.