Authentication And Accounts
API keys, account ids, account statuses, resync, removal, writes per account, and connecting accounts with hosted auth.
API Keys#
Every API call carries a Direct API key in the X-API-KEY header, as with Unipile:
curl -sS "https://direct.lempile.com/api/v1/accounts" \
-H "X-API-KEY: $DIRECT_API_KEY"- Only the header is read (no
api_keyquery parameter). - A missing, wrong or revoked key answers
401with"type": "errors/missing_credentials", on every route, before anything else is checked. - Direct stores only each key's SHA-256 hash, never the key itself, and never writes a key in a log or an answer.
- Two routes are public, with no key:
GET /api/v1/openapi.json(this API's OpenAPI document) andGET /docs(its reference page).
Keys And Workspaces#
Every key belongs to one Lempile workspace, and sees and acts on that workspace's accounts and webhooks only.
- Getting a key. A Lempile admin creates it for your workspace in Lempile's dashboard (Direct → API keys). It starts with
ldk_and is shown once, when it's made: store it as a secret. Ask Lempile for one; each app should have its own. A workspace has at most 50 active keys. - Revoking a key. A Lempile admin revokes it in the same place. A revoked key answers
401 errors/missing_credentialsat once, on every route, for good. - Accounts.
GET /accountslists your workspace's accounts only, and itstotal_countandstatus_countscount those only. Any other account id, whether it belongs to another workspace or doesn't exist, answers exactly the same404 errors/resource_not_found(titleAccount not found), on every read, write (dry runs included), account, resync and hosted reconnect route, before any LinkedIn call. A key never learns that another workspace's account exists. - Webhooks belong to the workspace of the key that created them.
GET /webhookslists your workspace's only; another workspace's webhook id answers404toDELETE. Only your workspace's accounts send to your webhooks: an account id of another workspace named inaccount_idsis kept but never sends, and its name is never answered. - Hosted auth. A new account connected through your key's link joins your workspace. A reconnect link can only name one of your workspace's accounts (served, or removed earlier); any other answers
404. - Limits are per account, not per key: two keys using one account share its pacing and daily caps (see Errors And Limits).
- Writes are still turned on per account by Lempile (see Writes Per Account).
- Lempile's own admin keys see every workspace, and are the only keys Direct's internal routes (
/api/v1/admin/*,/api/v1/canary) answer; any other key gets403 errors/insufficient_permissionsthere. Those routes aren't part of this API.
Account IDs#
An account id is a Lempile account id (a UUID). It names one LinkedIn account connected in Lempile: its own browser, its own proxy, its own LinkedIn session.
curl -sS "https://direct.lempile.com/api/v1/accounts" \
-H "X-API-KEY: $DIRECT_API_KEY"{
"object": "AccountList",
"items": [
{
"object": "Account",
"type": "LINKEDIN",
"id": "00000000-0000-4000-8000-0000000000a1",
"name": "Ada Sender",
"created_at": "2026-10-28T09:12:44.512Z",
"connection_params": {
"im": {
"id": "ACoAAAbcdefghijklmnop",
"publicIdentifier": "ada-sender",
"username": "Ada Sender",
"premiumId": null,
"premiumContractId": null,
"premiumFeatures": ["premium"],
"organizations": []
}
},
"groups": [],
"sources": [{ "id": "00000000-0000-4000-8000-0000000000a1_MESSAGING", "status": "OK" }]
}
],
"cursor": null,
"total_count": 1,
"status_counts": { "OK": 1, "CONNECTING": 0, "CREDENTIALS": 0, "STOPPED": 0, "ERROR": 0 }
}Where the account id goes:
| Routes | Where |
|---|---|
Every read (GET /users/..., /chats/..., /posts/..., POST /linkedin/search) |
?account_id= query parameter, required |
Writes with a body (POST /users/invite, /chats, /chats/{chat_id}/messages, /posts, /posts/{post_id}/comments, /posts/reaction) |
account_id in the body, or ?account_id= |
DELETE writes (/users/invite/sent/{id}, /posts/{post_id}, /posts/{post_id}/comments/{comment_id}) |
?account_id= query parameter, required |
/accounts/{account_id} routes |
the path |
Unlike Unipile, every inbox call names its account, GET /chats/{chat_id} and POST /chats/{chat_id}/messages included: a chat id is LinkedIn's own thread id, which doesn't say which account it belongs to.
An account id Direct doesn't serve (unknown, removed, or another workspace's) answers 404 with "type": "errors/resource_not_found" and title Account not found.
Account Status#
sources[0].status is what Direct has seen, never a guess:
| Status | Meaning | What to do |
|---|---|---|
OK |
LinkedIn answered the account's last call (any read, write or check). | Nothing. |
CONNECTING |
Nothing has answered yet: a new account, before its first check. | Wait; GET /accounts/{id} checks it. |
CREDENTIALS |
LinkedIn refused the account's session (signed out, or a security check), or its browser is signed out. | Reconnect it with a hosted auth link (type: "reconnect"). |
ERROR |
The account's browser or proxy gave no working session, at least twice over 10 minutes with no call succeeding meanwhile. A single hiccup or a slow start is never ERROR. |
Try a resync in a few minutes; if it stays, contact Lempile. |
GET /accounts/{account_id}checks the account first when its status is older than 10 minutes (15 while it's failing): one cheap LinkedIn call through its own session.- For 5 minutes after LinkedIn signs an account out, every read and write for it answers
401errors/disconnected_accountat once, without asking LinkedIn again (GET /accounts/{account_id}still answers, withCREDENTIALS). - A change of status sends the
account_statuswebhook (see Webhooks).
Resync#
curl -sS -X POST "https://direct.lempile.com/api/v1/accounts/$ACCOUNT_ID/resync" \
-H "X-API-KEY: $DIRECT_API_KEY"GET /accounts/{account_id}/sync (Unipile's form) does the same. Direct drops the session it kept, reads a fresh one from the account's browser, and checks it with LinkedIn:
| Answer | Meaning |
|---|---|
200 {"object": "AccountResync", "status": "SYNC_DONE"} |
The fresh session works. The account_status webhook SYNC_SUCCESS is sent. |
200 {"object": "AccountResync", "status": "SYNC_ERROR"} |
The browser or the proxy didn't give a working session. Try again in a few minutes. |
401 errors/disconnected_account |
LinkedIn signed the account out. Reconnect it. |
Removing An Account#
curl -sS -X DELETE "https://direct.lempile.com/api/v1/accounts/$ACCOUNT_ID" \
-H "X-API-KEY: $DIRECT_API_KEY"{ "object": "AccountDeleted" }- Direct stops serving the account, and it stays off across restarts. Its kept session is dropped, and the
account_statuswebhookDELETEDis sent. - The LinkedIn session, the browser and the Lempile account are untouched: Direct never signs anyone out of LinkedIn.
provider_session_revoke=trueanswers501. - A removed account can come back through a hosted auth link with
type: "reconnect"and its id.
Writes Per Account#
Writes are off by default for each account. Lempile turns them on per account. Until then, a write answers 403:
{
"title": "Writes off",
"detail": "This account doesn't write through Lempile Direct.",
"type": "errors/insufficient_permissions",
"status": 403
}Dry runs (?dry_run=true) work for every served account, writes on or off.
Connecting Accounts: Hosted Auth#
POST /api/v1/hosted/accounts/link takes Unipile's body and answers a link to a page Direct serves:
| Field | Required | Notes |
|---|---|---|
type |
yes | create (a new account) or reconnect |
api_url |
yes | Any non-empty string, as Unipile requires it (Direct doesn't use it). |
providers |
for create |
One provider, a category or a list, taking LinkedIn: "LINKEDIN", ["LINKEDIN"], "*" or "*:MESSAGING". Optional for reconnect; when given, it must take LinkedIn too. |
expiresOn |
yes | ISO 8601 UTC, in the future. The link lasts 24 hours at most, whatever this says. |
reconnect_account |
for reconnect |
The account id to sign in again: one of your workspace's (a removed one works too). |
name |
no | Your reference (1 to 500 characters), sent back to notify_url. |
success_redirect_url, failure_redirect_url |
no | https. Where the person lands. |
notify_url |
no | https. Direct POSTs {status, account_id, name} there when the account works. |
bypass_success_screen |
no | Accepted, no effect: with success_redirect_url, Direct redirects at once. |
Unipile's other options (disabled_features, disabled_options, proxy, sync_limit, google_scopes, microsoft_scopes, single_use) are accepted and ignored. Direct answers 200 with {"object": "HostedAuthUrl", "url": "https://direct.lempile.com/connect/<token>"}.
What happens next:
- The person opens the
url. A new account will join your key's workspace. The page says "Connect your LinkedIn account" (or "Reconnect") with Cancel and Continue. - Continue goes to Lempile's own LinkedIn sign-in, in a real browser on Lempile's servers, with LinkedIn's security checks and codes handled there. Direct never sees the password.
- Back on Direct, the account is served and its session checked (up to 3 tries, 20 seconds apart, for a fresh browser).
- When it works: Direct POSTs
notify_urlwith exactly{"status": "CREATION_SUCCESS" | "RECONNECTED", "account_id", "name"}(retried like a webhook), sends theaccount_statuswebhooks, and redirects (303) tosuccess_redirect_url. - A cancel, a failed sign-in or a session that doesn't work redirects to
failure_redirect_url. A reconnect that comes back with a different account fails.
Notes:
- The link's token holds only a random id and its expiry. The redirect and notify URLs stay on Direct's server, encrypted, and never appear in a URL.
- An expired link shows "This link has expired" (
410). The way back from the sign-in stays open 30 minutes past the link's expiry, for a sign-in that took long. - One link starts at most 5 sign-in attempts. A reconnect keeps Lempile's own limit (3 failed attempts in 6 hours).
notify_urlgets no extra header: put your own secret in the URL and check it.- Direct never signs anyone in or out of LinkedIn itself: the sign-in is Lempile's, in the account's own browser.
Was this page helpful?