LinkedIn Connector

API Reference

Base URL: http://localhost:3001 in this environment.

Authentication — two separate models

This API has two distinct ways to authenticate, for two distinct callers. Don't mix them up.

Session authAPI-key auth
Who it's forA signed-in human, or the browser extension acting for themAn external application/server calling this API programmatically
CredentialBearer token or cookie from /api/auth/sign-in/emailx-api-key header, from the dashboard
Routes/v1/me/*Everything else under /v1/*

Getting an API key

  1. Sign up, or sign in.
  2. On the dashboard, create a key under "API keys". The plaintext value is shown once — copy it immediately.
  3. Send it as x-api-key: <your key> on every request below.

Errors

400Invalid request body (Zod validation failure — check issues in the response)
401Missing/invalid credential
403Credential valid, but lacks the required scope
404Not found, or not yours (accounts/keys/posts are org-scoped — a wrong-org id looks identical to a missing one)
409Conflict — e.g. already reacted, or an idempotency-key replay of a different request
422Request is well-formed but the account can't do this right now (halted, needs reconnect, etc.)
429Rate/quota limit hit on a read — retry after the given Retry-After
503The LinkedIn worker tier is unreachable

Session-authed — /v1/me/*

What the extension calls. Send the bearer token as Authorization: Bearer <token>, or rely on the session cookie if calling from the dashboard itself.

GET/v1/me

Who am I, and my organization. Auto-creates and activates an organization on first call if you have none.

{ "data": { "user": { "id", "email", "name" }, "organization_id": "..." } }
POST/v1/me/accounts/connect

Connect a LinkedIn account. Body carries the session read by the extension from chrome.cookies — you will not normally call this by hand.

{ "li_at", "jsessionid", "user_agent", "timezone", "label"? }
→ 201 { "data": { "account_id", "status": "healthy", "reconnected": false } }
→ 422 { "error": "session_invalid" }   // LinkedIn rejected the session
GET/v1/me/accounts

List LinkedIn accounts connected to your organization.

GET/v1/me/accounts/:id
PATCH/v1/me/accounts/:id

Body: { "status": "paused" | "healthy" }. A halted account cannot be forced healthy — reconnect it instead.

DELETE/v1/me/accounts/:id
POST/v1/me/keys

Create an API key for your organization. Body: { "name", "permissions"? }. The plaintext key is only ever returned in this response.

GET/v1/me/keys

List your keys — prefix only, never the plaintext value.

DELETE/v1/me/keys/:id

Revoke a key immediately.

API-key-authed — /v1/*

Send x-api-key: <your key>. These are the same operations as /v1/me/* but for an external application rather than the extension's own user — an app you build against this API mints its own key and manages accounts through here.

POST/v1/accounts/connect
GET/v1/accounts
GET/v1/accounts/:id
GET/v1/accounts/:id/limits

Live quota: what's used, what's left, when the next action is allowed, per class.

PATCH/v1/accounts/:id
PUT/v1/accounts/:id/limits

Retune limits at runtime. Three independent mechanisms cap an account, and raising only one silently does nothing — the other two still bind:

{
  "classes": {                       // per-operation-class bands
    "SEARCH": { "dailyMin", "dailyMax", "hourlyMax",
                "spacingMinS", "spacingMaxS",
                "windowStart", "windowEnd", "enabled", "skipWindow" },
    "REACT": {...}, "READ": {...}, "WRITE": {...}
  },
  "warmupStages": [                  // the ramp, as data
    { "minDays": 0, "multiplier": 1.0 }
  ],
  "globalDailyCeiling": 1000000      // cross-class cap; null = worker default
}

Warm-up ramp. A new account's daily target is multiplied by the rung with the greatest minDays it has reached, so a fresh account does not behave like an established one. A single {"minDays":0,"multiplier":1.0} means no ramp. Sending [] clears the rows and returns the account to the built-in defaults — which is not the same as no ramp.

Global ceiling. A single daily cap shared across all four classes. This is the one that surprises people: with per-class limits at 1,000,000 and this left at its default, everything still stops at the default. null restores the worker-wide default.

The older body shape — a bare map of op-class to config, with no classes wrapper — is still accepted.

DELETE/v1/accounts/:id
GET/v1/accounts/-/defaults
PUT/v1/accounts/-/defaults

Org-level defaults that newly connected accounts inherit. Same body shape as PUT /v1/accounts/:id/limits. Set these once and every account you connect afterwards starts with your numbers instead of the built-in conservative ones — no per-account retuning.

Resolution is most-specific-first: the account's own rows → these org defaults → built-in constants. An unset value means inherit the next level down, never "unlimited" — so a brand-new org with nothing configured still gets a real ramp.

Changing these does not touch existing accounts. Use PUT /v1/accounts/:id/limits for those.

Unipile-compatible facade — /api/v1/*

If you already have code written against Unipile, change only the base URL. The facade mirrors Unipile's paths, request shapes, response envelopes and the status codes consumers branch on. Auth is the same header (X-API-KEY).

One deliberate difference: Unipile strips LinkedIn's status and error body, so every refused call looks like the same opaque 400 errors/malformed_request. Here every response also carries provider_status, provider_body and outcome. A Unipile parser ignores the extra keys; you get to see why something failed.

Unipile routeStatusNotes
POST /api/v1/linkedinservedMagic Route. { data } on 2xx; { type: "errors/…" } + LinkedIn's status on refusal. as_organization and bypass_redirect=false → 501 rather than silent downgrade.
POST /api/v1/linkedin/searchservedurl variant only. Returns { items, cursor, paging }; cursor is always null (single page).
GET /api/v1/linkedin/company/{id|vanity}servedNumeric id or vanity slug. viewer_permissions computed for the passed account. Industry labels/locations not yet resolved (_mapping: partial).
GET /api/v1/accounts, /accounts/{id}, DELETEservedStatus uses Unipile's vocabulary (OK / CREDENTIALS / CHECKPOINT / ERROR / STOPPED). 404 on /accounts/{id} is definitive.
POST /api/v1/accounts, POST /accounts/{id}servedCookie connect/reconnect: { provider, access_token: <li_at>, user_agent }. JSESSIONID is bootstrapped if omitted. Username/password → 501: this service never handles LinkedIn passwords.
GET /api/v1/users/me, /users/{identifier}servedprovider_id is the ACoAA… member token. organizations is [] for now.
chats, posts, invitations, followers, relations, webhooks, hosted links, checkpoint, search/parameters501Each returns errors/not_implemented with the closest working alternative in detail. Most are reachable today through POST /api/v1/linkedin.

The observed Unipile contract this was built against — every endpoint, field alias and status semantic two production consumers actually depend on — is in docs/unipile-reference/.

Raw passthrough needs the worker tier

POST/v1/linkedin

Forwards an arbitrary LinkedIn request through a connected account's live session. This is the escape hatch for every LinkedIn surface that has no dedicated endpoint here — profile and company reads, page settings, analytics, invitations, comments, follows.

{ "account_id", "request_url", "method"?, "op_class"?, "body"?, "headers"? }
→ 200 { "data": { "outcome", "provider_status", "provider_body", "provider_json",
                  "rejection_kind", "reason" }, "meta": { "quota", "notes" } }
→ 429 { "error": "quota_exhausted", ... }   ← our quota, not LinkedIn's
→ 409 { "error": "account_halted", ... }

LinkedIn's status and body are never stripped. A LinkedIn 400 or 403 comes back as HTTP 200 here with provider_status and provider_body intact, because a refusal is the answer you asked for, not a failure of the call. Our own status codes stay reserved for our own conditions, so LinkedIn's 429 and our quota 429 can never be confused.

request_url must be an absolute https://www.linkedin.com/… URL with the query string already built — LinkedIn's Rest.li syntax uses literal ( ) : , punctuation that a generic serialiser would encode into something it rejects.

op_class is READ or WRITE and decides which quota budget the call spends. Any mutating method must declare WRITE. Writes get a much tighter budget than reads, because write volume is what actually gets accounts restricted.

outcome is the field to branch on: ok (2xx), rejected (LinkedIn definitively refused — the request did not take effect, so retrying is safe), or ambiguous (timeout, 5xx, or a 2xx mutation with an unreadable body — it may have landed, so never blind-retry a non-idempotent write; read state back instead). rejection_kind: "rate_limited" is the one rejected you should not immediately retry.

A 2xx does not prove a field was written. LinkedIn accepts and silently ignores Rest.li $set keys it doesn't recognise, answering 200 while changing nothing. Verify any partial update by reading the entity back.

Headers are forwarded except session-bearing ones. In particular a csrf-token copied from a captured browser request is dropped — it is matched against the session cookie we attach, so a replayed one is guaranteed to mismatch. Send accept and x-restli-*; we supply the token.

Search needs the worker tier

POST/v1/search/posts
{ "account_id", "keyword", "filters"?, "limit"? }
→ 200 { "data": { "run_id", "posts": [...], "count" }, "meta": { "quota" } }
→ 429 { "error": "quota_exhausted", "retry_after", "next_available_at" }
POST/v1/search/searches

Save a recurring keyword search.

GET/v1/search/searches
PATCH/v1/search/searches/:id
DELETE/v1/search/searches/:id

Posts free — served from the store

GET/v1/posts

Query: keyword, author_urn, since, until, min_reactions, q, limit, cursor. Never touches LinkedIn — reads from what your searches have already collected.

GET/v1/posts/:urn
GET/v1/posts/-/stats

Reactions needs the worker tier

POST/v1/reactions

Body: { "account_id", "post", "type" } — post accepts a URN or a full LinkedIn post URL. type is one of LIKE PRAISE APPRECIATION EMPATHY INTEREST ENTERTAINMENT. Send an Idempotency-Key header to make retries safe.

→ 202 { "data": { "action_id", "reaction_id", "status": "queued", "estimated_start" } }
→ 409 { "error": "already_reacted" }

This is queued, not synchronous — the Governor paces writes and may schedule it minutes ahead. Poll GET /v1/actions/:id for the outcome.

DELETE/v1/reactions

Un-react. Body: { "account_id", "post" }.

POST/v1/reactions/bulk

Body: { "account_id", "items": [{ "post", "type" }] }. Each item is paced independently — not a bypass.

GET/v1/reactions
GET/v1/reactions/:id

Actions (async write status)

GET/v1/actions/:id
GET/v1/actions
DELETE/v1/actions/:id

Cancel — only while still queued/scheduled.

Webhooks

POST/v1/webhooks

Body: { "url", "events": [...] }.

GET/v1/webhooks
DELETE/v1/webhooks/:id
GET/v1/webhooks/:id/deliveries
POST/v1/webhooks/:id/redeliver

Audit & usage

GET/v1/audit/requests

Every LinkedIn call made on your behalf: endpoint, status, outcome, latency. This exists because you should never have to guess why an account got disconnected.

GET/v1/usage
GET/v1/stats

A note on safety

Every LinkedIn-touching call above is paced by a rate governor with randomized spacing, working-hours confinement, and a warm-up ramp for new accounts — and halts the account automatically the moment LinkedIn shows a warning sign, rather than retrying into a restriction. Writes (reactions) are always queued, never synchronous, specifically so pacing is enforced by the platform and not left to your application.