LKDN Autopilot
en
Activate the service

The LKDN Autopilot API and MCP server

With the LKDN Autopilot API, your software, your CRM, your automations (n8n, Make, Zapier) and your AI assistants (Claude, ChatGPT) use your connected LinkedIn account, through us: they read conversations and connections, search for people, send messages and connection requests, publish posts and comment.

Three things to know first:

  1. One account, yours. The key is tied to your LKDN Autopilot account and to your LinkedIn account. You do not connect other accounts and you do not choose an account: the key already knows which one it is.
  2. One budget, shared with the autopilot. Every visit, connection request, message, comment, reaction and post made through the API uses the same daily budget as the autopilot, and so does every read that reaches LinkedIn (profiles, searches, lists, posts, companies), with the same limits and the same pauses between one action and the next. If the API uses 5 connection requests, the autopilot has 5 fewer that day. This is how your account stays safe.
  3. We do not keep your conversations. When you read a chat through the API, we read it from your account at that moment and pass it to you. We do not save it in our records or in our logs: if you need to keep it, your CRM keeps it.

API access is enabled on request, per account: write to us at info@lkdnautopilot.com.

Contents #

Getting started in five minutes #

  1. In the app, go to API and integrations (at the top of the home page).
  2. Create a key: a name (for example "my CRM") and what it may do, read only or read and write. The key is shown only once: copy it and keep it somewhere safe.
  3. Try it:
curl https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/public-api/v1/me \
  -H "Authorization: Bearer lkdn_sk_LA_TUA_CHIAVE"

The answer is your profile, the state of the connection and the LinkedIn plan we detected:

{
  "data": {
    "id": "per_3mQ8...",
    "name": "Giulia Demo",
    "headline": "Consulente, Demo Consulting Srl",
    "public_identifier": "giulia-demo",
    "linkedin_status": "connected",
    "linkedin_plan": "premium",
    "inmail_credits": 5,
    "simulated": false
  }
}

The base address of every call is:

https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/public-api/v1

The complete OpenAPI specification is in openapi.json: import it into Postman, Insomnia or a client generator.

Keys and authentication #

Every request carries the key in the header:

Authorization: Bearer lkdn_sk_...

Limits and the shared budget #

Your LinkedIn account has a daily budget, the same for the autopilot and for the API. We decide it in one single place, with the same rules: the LinkedIn plan we detected, the warm-up period of a new account, the limits you chose yourself and the ones we may lower if the account is at risk. The API cannot exceed it, in any case.

ActionDefault value per dayEndpoint
Visits with notification30POST /v1/people/{id}/visits
Connection requests20 (about 5 on a free LinkedIn account), 100 in 7 daysPOST /v1/invitations
Messages25POST /v1/conversations, POST /v1/conversations/{id}/messages
InMails3POST /v1/conversations with inmail: true
Comments10POST /v1/posts/{id}/comments
Reactions20POST /v1/posts/{id}/reactions
Posts2POST /v1/posts
Withdrawn requests10DELETE /v1/invitations/{id}
Total of the actions above70
Profiles read (without notification)40GET /v1/people/{id}
Profiles opened in all: visits and profiles read together70POST /v1/people/{id}/visits, GET /v1/people/{id}
Search results300POST /v1/searches
Lists, posts and pages read40GET /v1/me (up to 2), GET /v1/connections, GET /v1/invitations, GET /v1/people/{id}/posts, GET /v1/posts/{id}, GET /v1/posts/{id}/comments, GET /v1/posts/{id}/reactions, GET /v1/companies/{id}

Conversations and messages (GET /v1/conversations...) use no budget: they are already synchronised from your account, without a request to LinkedIn for every read. Comments and reactions read the post first: that read is always counted, even if the action then does not go out, but an action is never refused because of the read budget.

These are the default values: the ones of your account may be lower (warm-up, free plan, limits you chose). The real ones, for today, are always in GET /v1/usage, together with how much you have already used and how much the API has used.

No number, not even the ones we can raise on request, goes above 80% of the limits recommended for a LinkedIn account by the infrastructure we use to connect: for example at most 64 connection requests a day and 160 in 7 days, 80 profiles opened a day between visits and reads, 800 search results a day (2000 with Sales Navigator or Recruiter). The database refuses any setting beyond this margin.

Other rules that protect the account:

Every 429 has the Retry-After header: after how many seconds to try again. When the day's budget is used up, Retry-After points at the opening of your next working hours. An AI agent that gets a 429 must stop and say so, not retry in a loop.

Response format #

Responses are JSON, with English names that never change:

Every response has the X-Request-Id header: quote it if you write to us about a problem.

Errors follow the RFC 9457 standard (Content-Type: application/problem+json):

{
  "type": "https://lkdnautopilot.com/api-doc/#errori-daily_budget_exhausted",
  "title": "The daily budget for this action is used up, by the autopilot and the API together.",
  "status": 429,
  "code": "daily_budget_exhausted",
  "request_id": "req_9f2c...",
  "retry_after": 52310
}

The field to read in your code is code: it is stable and never changes meaning. The complete list is in Errors.

Idempotency-Key #

Every request that acts (POST and DELETE, except POST /v1/searches, which is a read) requires the Idempotency-Key header: a unique value you choose, from 8 to 200 characters, for example a UUID.

curl -X POST https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/public-api/v1/invitations \
  -H "Authorization: Bearer lkdn_sk_..." \
  -H "Idempotency-Key: 6b1f0e5e-2f4a-4c39-9a0e-4d8f2f0a1c11" \
  -H "Content-Type: application/json" \
  -d '{"person_id": "per_3mQ8...", "message": "Ciao Marco, ho letto il tuo post sulla logistica e mi è piaciuto."}'

502 action_outcome_unknown means that LinkedIn did not give us a readable answer and the action may or may not have gone out. Do not retry with a new key: check first (for a message, read the conversation).

Simulated mode #

While your account is getting ready, the API answers with simulated data (made-up people) and no action really goes out: actions still go through the limits and are recorded. Simulated responses have the LKDN-Mode: simulated header and, for actions, "simulated": true. It is the right way to build and test an integration without touching anyone.

Endpoints #

All paths are relative to the base address. Read: read permission. Action: write permission and Idempotency-Key.

Your account #

GET /v1/me (read). Your profile, linkedin_status (connected, reconnect_required, not_connected), linkedin_plan (free, premium, sales_navigator), your InMail credits. Uses up to two list reads (the profile and the InMail credits).

GET /v1/usage (read). Today's budget, shared with the autopilot:

{
  "data": {
    "date": "2026-10-25",
    "timezone": "Europe/Rome",
    "linkedin_plan": "premium",
    "invitation_note_available": true,
    "requests": { "today": 41, "per_day": 2000, "per_minute": 60 },
    "budget": {
      "invitations": { "used": 7, "limit": 20, "used_by_api": 2, "weekly_limit": 100 },
      "messages": { "used": 3, "limit": 25, "used_by_api": 3 },
      "list_reads": { "used": 6, "limit": 40, "used_by_api": 6 },
      "profile_retrievals": { "used": 21, "limit": 70, "used_by_api": 4 },
      "total_actions": { "used": 18, "limit": 70 }
    },
    "activity_window": { "open_now": true, "start": "09:00", "end": "18:00", "working_day": true },
    "backoff": null,
    "shared_with_autopilot": true
  }
}

backoff is null, or it says why the account is going slower today: {"kind": "all_stop" | "invite_stop" | "halved", "reason": "...", "until": "2026-10-26T23:00:00.000Z"} (everything stopped, connection requests stopped, pace halved) and until when.

People #

GET /v1/people/{id} (read). A profile: id is a per_... or the public_identifier. The person is not notified. Uses one "profile read" (and counts towards the profiles opened in all). It does not return other people's emails or phone numbers.

POST /v1/people/{id}/visits (action). Visits the profile: the person sees that you looked at them. Uses one visit.

GET /v1/people/{id}/posts (read). The person's recent posts. limit up to 50, cursor. Uses one list read per page.

GET /v1/connections (read). Your first-degree connections, most recent first. q filters by name, limit up to 200, cursor. Uses one list read per page.

GET /v1/search/filters?type=location&q=verona (read). Turns a place (type=location) or an industry (type=industry) into a flt_... filter to use in the search. Always use it: the search refuses a filter that does not come from here (400 invalid_filter), because LinkedIn would silently ignore an unknown filter and give you the results of a search different from the one you wanted.

POST /v1/searches (read). Searches for people:

{
  "keywords": "logistica",
  "titles": ["CFO", "direttore finanziario"],
  "locations": ["flt_..."],
  "industries": ["flt_..."],
  "degree": [2, 3],
  "limit": 10
}

At least one of keywords, titles, locations, industries. limit up to 10 (up to 25 with Sales Navigator); each result uses one "search result". For the next page repeat the same search with cursor. The response also has page.total_estimate.

Conversations #

GET /v1/conversations (read). Your conversations, most recent first: type (direct or group), participant_id, kind (message, inmail, sponsored, offer), inbox, unread_count, last_activity_at. unread=true for unread ones only, limit up to 100, cursor.

GET /v1/conversations/{id} (read). One conversation.

GET /v1/conversations/{id}/messages (read). The messages, most recent first: text, from_me, sender_id, sent_at, kind, seen, attachments (type and name, without a link). We read the text from your account at that moment and do not keep it.

POST /v1/conversations/{id}/messages (action). Reply in a one-to-one conversation: {"text": "..."} (up to 8000 characters). Group conversations are not supported (422 group_conversation_not_supported).

POST /v1/conversations (action). Write to a person: {"person_id": "per_...", "text": "..."}. It works with first-degree connections (otherwise 422 not_first_degree). For an InMail to someone you are not connected with: {"person_id": "per_...", "text": "...", "inmail": true, "subject": "..."}; it uses one InMail from the budget and one InMail credit from your plan.

Connection requests #

GET /v1/invitations?direction=sent (read). The requests you sent that are still pending; direction=received for the ones you received. Uses one list read per page.

POST /v1/invitations (action). {"person_id": "per_...", "message": "..."}. The note (message, up to 300 characters) is not available on a free LinkedIn account (403 invitation_note_not_available): on that plan the request goes out without a note. Already connected: 409 already_connected; request already pending: 409 already_invited.

DELETE /v1/invitations/{id} (action). Withdraws a request you sent that is still pending.

Posts #

GET /v1/posts/{id} (read). A post: id is a pst_..., a LinkedIn URN (urn:li:activity:...) or the activity number. Uses one list read.

GET /v1/posts/{id}/comments, GET /v1/posts/{id}/reactions (read). Comments and reactions. Uses one list read per page.

POST /v1/posts (action). Publishes a post on your profile: {"text": "..."} (up to 3000 characters).

POST /v1/posts/{id}/comments (action). {"text": "...", "reply_to": "cmt_..."}; reply_to to reply to a comment. The author of the post gets a notification.

POST /v1/posts/{id}/reactions (action). {"type": "like"}; type is one of like, celebrate, support, love, insightful, funny.

Companies #

GET /v1/companies/{id} (read). A company page: id is a cmp_... or the public name after linkedin.com/company/. Uses one list read.

Webhooks #

GET /v1/webhooks (read), POST /v1/webhooks (action), DELETE /v1/webhooks/{id} (action). See Webhooks.

Webhooks #

Instead of asking every minute "is there anything new?", we call you. Create a webhook from the app (API and integrations) or with:

curl -X POST https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/public-api/v1/webhooks \
  -H "Authorization: Bearer lkdn_sk_..." -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
  -d '{"url": "https://crm.example.com/hooks/lkdn", "events": ["message.received", "connection.accepted"]}'

The response contains the signing secret (whsec_...) only once.

EventWhendata
message.receiveda person writes to youconversation_id, message_id, sender_id, sent_at
connection.accepteda connection request is accepted (it can arrive up to 8 hours later)person_id, public_identifier, accepted_at
invitation.senta connection request goes out, from the autopilot or from the APIinvitation_id, person_id, source (autopilot or api), sent_at
account.status_changedthe connection of your account changes statestatus, previous_status, changed_at; states: connected, connecting, reconnect_required, disconnected, error

The body of the call:

{
  "id": "evt_0b6f1f40000040008000000000000001",
  "type": "message.received",
  "created_at": "2026-10-25T10:00:03.000Z",
  "data": {
    "conversation_id": "cnv_...",
    "message_id": "msg_...",
    "sender_id": "per_...",
    "sent_at": "2026-10-25T10:00:00.000Z"
  }
}

The text of the message does not travel in the webhook: read it with GET /v1/conversations/{conversation_id}/messages. This way no text stays in our delivery queues.

Every call has the headers LKDN-Event-Id, LKDN-Event-Type and LKDN-Signature: t=<secondi unix>,v1=<firma>. The signature is the HMAC-SHA256, in hexadecimal, of the string <t>.<corpo esatto> with your secret. Always verify it, and refuse a call older than 5 minutes:

// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret, rawBody, header, now = Math.floor(Date.now() / 1000)) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!m || Math.abs(now - Number(m[1])) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex');
  return timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
# Python
import hashlib, hmac, re, time

def verify(secret: str, raw_body: str, header: str) -> bool:
    m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
    if not m or abs(time.time() - int(m.group(1))) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{m.group(1)}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m.group(2))

Answer with a 2xx code within 10 seconds. Otherwise we try again after 1, 5 and 30 minutes, then 2, 6, 12 and 24 hours; after 8 attempts the event is lost, and after 20 lost events in a row the webhook switches off (you switch it back on from the app). A 410 Gone switches it off at once for that event. The same event can arrive twice: use id to recognise it. We accept only https addresses on port 443, never internal or private addresses, and we do not follow redirects.

MCP server: Claude, ChatGPT, n8n #

The MCP server exposes the same functions as tools for an AI assistant, with the same key and the same limits: there is no path that skips them. The tools that act say so in their description, and ask the assistant to show you the exact text and wait for your yes.

Address:

https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/mcp

Authentication: Authorization: Bearer lkdn_sk_.... For software that does not let you set a header there is a form with the key in the address, .../functions/v1/mcp/lkdn_sk_...: treat it as a password, because whoever has the address has the key. Use a dedicated key for this, and a read-only one if reading is enough for the assistant.

Tools: get_my_profile, get_usage, get_person, visit_profile, list_person_posts, list_connections, find_search_filters, search_people, list_conversations, get_conversation_messages, send_message, start_conversation, list_invitations, send_invitation, withdraw_invitation, get_post, list_post_comments, list_post_reactions, publish_post, comment_on_post, react_to_post, get_company.

Claude Code #

claude mcp add --transport http lkdn-autopilot \
  https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/mcp \
  --header "Authorization: Bearer lkdn_sk_..."

Claude Desktop #

In Settings, Developer, Edit Config (claude_desktop_config.json), with the mcp-remote bridge (it needs Node.js):

{
  "mcpServers": {
    "lkdn-autopilot": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/mcp",
               "--header", "Authorization:Bearer ${LKDN_API_KEY}"],
      "env": { "LKDN_API_KEY": "lkdn_sk_..." }
    }
  }
}

Restart Claude Desktop: the tools appear in the tools menu.

Claude.ai (custom connector) #

In Settings, Connectors, Add custom connector: as the URL use the form with the key in the address, https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/mcp/lkdn_sk_..., and leave the OAuth fields empty. The custom connector is available on the Claude plans that include it.

ChatGPT #

In Settings, Apps and connectors, with developer mode on: create a connector with the URL in the form with the key in the address and authentication "none". The tools that act ask for confirmation before going out.

n8n #

In the MCP Client Tool node of an AI Agent: Endpoint https://ayuhpqrbmizgsjstmcrt.supabase.co/functions/v1/mcp, HTTP Streamable transport, Bearer authentication with your key. For a flow without AI use the HTTP Request node on the REST API, with a "Header Auth" credential (Authorization = Bearer lkdn_sk_...), and a Webhook node as the destination of our webhooks.

Ready-made prompts #

To paste into Claude or ChatGPT with the MCP server connected.

Today's replies

Look at my unread LinkedIn conversations. For each one tell me who the person is (name, role, company),
what they wrote to me in one line, and whether you think it needs a reply today. Do not reply to anyone: suggest
a draft reply only for the ones that matter, and wait for my yes on every text.

Preparing for a meeting

Read the LinkedIn profile of <name or link> and their latest posts. Give me five lines: who they are, what they
work on now, what they wrote recently, one thing we have in common and one smart question to ask them.
Do not visit the profile with a notification and do not write to them.

Finding people for a segment

Search LinkedIn for owners and directors of dental practices in Veneto (use find_search_filters for the place).
Show me the first 10 in a table with name, role, city and degree of connection. Before searching, tell me
how many search results I have left today (get_usage). Do not send connection requests.

Connection requests with a real note

For each of these people <list of links>: read the profile, write me a connection note of at most
250 characters, in English, about something real in their profile, without selling anything and without links.
Show me all the notes. Send only the ones I approve, one at a time, and stop if the day's budget runs out.

Replying to comments on my posts

Take my latest post (look for it among the posts on my profile) and read the comments. For the ones I have not
replied to yet, suggest a short, friendly reply. Publish only the replies I approve.

The week's summary

Tell me how many connection requests I have had pending for more than two weeks and who they are. Suggest which
to withdraw. Do not withdraw any of them without my ok.

Rules that apply to every prompt: the assistant must not invent facts about the person, it must show you every text before sending it, and when it gets action_paced, daily_budget_exhausted or outside_activity_window it must stop and tell you.

Errors #

Every error has a stable code: it is a contract, it never changes meaning. Read the code, not the text.

StatuscodeWhat it means, what to do
400invalid_requesta field is missing or not valid (field, detail say which)
400invalid_cursorcursor malformed, expired or from another list: start again from the first page
400invalid_filtera search filter does not come from GET /v1/search/filters
400idempotency_key_requiredIdempotency-Key is missing on a request that acts
401invalid_api_keykey missing, unknown or revoked
403insufficient_scopethe key does not have the write permission
403api_access_not_enabledthe API is not enabled for your account
403subscription_inactivethe subscription is not active
403person_not_contactablethis person must not be contacted from this account
403invitation_note_not_availableon your LinkedIn plan the request cannot have a note
403linkedin_feature_missingit needs a LinkedIn product the account does not have
404not_foundthe resource does not exist or does not belong to your account
404route_not_foundthe endpoint does not exist
405method_not_allowedthe endpoint exists but not with this method
409linkedin_not_connectedno LinkedIn account connected: connect it from the app
409linkedin_reconnect_requiredthe account must be reconnected from the app
409linkedin_account_unavailablethe account is suspended for now
409account_pausedour team has paused the actions of this account
409idempotency_in_progressthe request with this Idempotency-Key is still in progress or has not finished
409already_connectedyou are already connected
409already_invitedthere is already a pending request to this person
422idempotency_key_reusedthe same Idempotency-Key with a different request
422invitation_not_allowedLinkedIn does not allow another request to this person: final
422recipient_unreachablethe person cannot be reached
422not_first_degreea message needs a first-degree connection: send a request or an InMail
422inmail_not_allowedthis person cannot be sent an InMail
422insufficient_inmail_creditsInMail credits used up
422comments_disabledcomments are closed on this post
422group_conversation_not_supportedgroup conversations are not supported
422action_refusedLinkedIn refused the action
422invalid_webhookthe webhook cannot be created (reason says why)
429rate_limitedtoo many requests for this key: wait for Retry-After
429daily_request_limitthe day's requests are used up for this account
429daily_budget_exhaustedthe day's budget for this action is used up, by the autopilot and the API together
429weekly_budget_exhaustedthe weekly budget of connection requests is used up
429action_pacedactions spaced out to protect the account: wait for Retry-After
429outside_activity_windowoutside your working hours
429linkedin_rate_limitedLinkedIn is limiting the account right now: try again later
429account_backoffLinkedIn asked the account to slow down: this action is paused until Retry-After
500internal_erroran error on our side
502linkedin_errorLinkedIn gave an answer we cannot read
502action_outcome_unknownthe action may or may not have gone out: check before trying again
503api_unavailablethe API is switched off right now
503service_not_configuredthe service is not configured yet
503linkedin_unavailableLinkedIn did not answer in time: try again later

Privacy and responsibility #

What it does not do #