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 auth | API-key auth | |
|---|---|---|
| Who it's for | A signed-in human, or the browser extension acting for them | An external application/server calling this API programmatically |
| Credential | Bearer token or cookie from /api/auth/sign-in/email | x-api-key header, from the dashboard |
| Routes | /v1/me/* | Everything else under /v1/* |
Getting an API key
Errors
400 | Invalid request body (Zod validation failure — check issues in the response) |
401 | Missing/invalid credential |
403 | Credential valid, but lacks the required scope |
404 | Not found, or not yours (accounts/keys/posts are org-scoped — a wrong-org id looks identical to a missing one) |
409 | Conflict — e.g. already reacted, or an idempotency-key replay of a different request |
422 | Request is well-formed but the account can't do this right now (halted, needs reconnect, etc.) |
429 | Rate/quota limit hit on a read — retry after the given Retry-After |
503 | The 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.
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": "..." } }
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
List LinkedIn accounts connected to your organization.
Body: { "status": "paused" | "healthy" }. A halted account cannot be forced healthy — reconnect it instead.
Create an API key for your organization. Body: { "name", "permissions"? }. The plaintext key is only ever returned in this response.
List your keys — prefix only, never the plaintext value.
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.
Live quota: what's used, what's left, when the next action is allowed, per class.
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.
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 route | Status | Notes |
|---|---|---|
POST /api/v1/linkedin | served | Magic 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/search | served | url variant only. Returns { items, cursor, paging }; cursor is always null (single page). |
GET /api/v1/linkedin/company/{id|vanity} | served | Numeric 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}, DELETE | served | Status uses Unipile's vocabulary (OK / CREDENTIALS / CHECKPOINT / ERROR / STOPPED). 404 on /accounts/{id} is definitive. |
POST /api/v1/accounts, POST /accounts/{id} | served | Cookie 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} | served | provider_id is the ACoAA… member token. organizations is [] for now. |
| chats, posts, invitations, followers, relations, webhooks, hosted links, checkpoint, search/parameters | 501 | Each 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
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
{ "account_id", "keyword", "filters"?, "limit"? }
→ 200 { "data": { "run_id", "posts": [...], "count" }, "meta": { "quota" } }
→ 429 { "error": "quota_exhausted", "retry_after", "next_available_at" }
Save a recurring keyword search.
Posts free — served from the store
Query: keyword, author_urn, since, until, min_reactions, q, limit, cursor.
Never touches LinkedIn — reads from what your searches have already collected.
Reactions needs the worker tier
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.
Un-react. Body: { "account_id", "post" }.
Body: { "account_id", "items": [{ "post", "type" }] }. Each item is paced independently — not a bypass.
Actions (async write status)
Cancel — only while still queued/scheduled.
Webhooks
Body: { "url", "events": [...] }.
Audit & usage
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.
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.