# Linked API — Complete Documentation > Secure automation API for LinkedIn – teams and AI agents. Automate messaging, networking, data extraction, and outreach on LinkedIn. Use via API, SDK, CLI, AI agents, or no-code platforms. The API Reference is also the SDK and CLI reference. Consecutive code fences marked `tab=HTTP`, `tab=Node`, `tab=Python` and `tab=CLI` are the same call on each surface (a fence marked `tab=HTTP,CLI` is the same text for both), and a block between `:::surface ` and `:::` applies only to the surfaces it names. Identifiers in the text are written as in Node and in HTTP JSON (camelCase); the Python SDK spells methods, parameters and result attributes in snake_case (`sendMessage` → `send_message`, `reconnectionLink` → `reconnection_link`), while names inside HTTP JSON stay camelCase. --- # API Reference ## Core concepts This page covers the fundamental concepts and design principles behind Linked API, providing the foundation you need to build effective integrations. > How results represent LinkedIn data – [URL normalization](/docs/data-conventions#url-normalization), [URNs](/docs/data-conventions#linkedin-urns), [viewer state](/docs/data-conventions#viewer-state), [time zones](/docs/data-conventions#timezone-normalization) and [missing values](/docs/data-conventions#handling-missing-values) – is described in [Data Conventions](/docs/data-conventions). How workflows run one at a time and how long they take is in [Workflows Overview](/docs/workflows-overview). ## How the API is organized Linked API has two kinds of endpoints: - **Account endpoints** work on behalf of one connected LinkedIn account, chosen by the `identification-token` header: - [workflows](/docs/workflows-overview) do something on LinkedIn now – search, open a profile, send a message or a connection request; - [signals](/docs/signals-overview) (coming soon) will tell you when something happens – a new post, a comment, an accepted invitation; - workflow results – and, once signals launch, signal events – can be polled or received as [webhook events](/docs/webhooks). - **[Admin API](/docs/admin-overview) endpoints** manage your Linked API account itself: subscription, seats, connected accounts, limits and webhooks. They respond immediately and need only the `linked-api-token` header. ## One account, one browser Each connected LinkedIn account works in its own cloud browser, at the pace of a real user, and runs one thing at a time. Everything it does stays inside the account's [working hours](/docs/working-hours) and spends its [limits](#limits-management). Once [signals](/docs/signals-overview) launch, their checks will share the same browser, running between your workflows. ## Limits management LinkedIn limits vary depending on your account's age, social selling index, subscription (e.g., Sales Navigator), and other factors. > It is your responsibility to understand and follow the limits appropriate for your account. To help you stay within safe boundaries, Linked API allows you to configure action limits for each connected account directly from the platform dashboard. When a workflow contains an action that would exceed a configured limit, that action will return a `limitExceeded` error instead of executing. You can also [manage limits programmatically](/docs/admin-limits) via the Admin API, [monitor your activity](/docs/checking-api-usage-statistics), [check SSI](/docs/action-st-retrieve-ssi), and review our [guide on LinkedIn limits](/guides/understanding-linkedin-limits). ## Making requests This page explains how to make requests to Linked API, including available endpoints, authorization process, and response structure. :::surface node,python With the SDKs you do not build these requests yourself: create the client once with both tokens, as shown in [Authorization](#authorization), and call its methods – each one makes a request described here. The [Methods index](/sdks/methods) lists them. ::: :::surface cli With the CLI you do not build these requests yourself: `linkedin setup` saves both tokens once, as shown in [Authorization](#authorization), and every command makes a request described here. The [command list](/cli/commands) lists them. ::: ## Endpoints `https://api.linkedapi.io/workflows` – for [executing workflows](/docs/executing-workflows). `https://api.linkedapi.io/signals` – for [managing signals](/docs/managing-signals) (coming soon). `https://api.linkedapi.io/signals/poll` – for [polling signal events](/docs/signal-events) (coming soon). `https://api.linkedapi.io/inbox/poll` – for [polling the inbox](/docs/monitoring-inbox). `https://api.linkedapi.io/network/poll` – for [polling network events](/docs/monitoring-network). `https://api.linkedapi.io/stats/actions` – for checking [API usage statistics](/docs/checking-api-usage-statistics). `https://api.linkedapi.io/account` – for [checking your tokens](#checking-your-tokens). `https://api.linkedapi.io/admin/*` – for [managing your account](/docs/admin-overview). ## Authorization Requests to all endpoints except the [Admin API](/docs/admin-overview) must include **2 authorization headers**: - `linked-api-token` – your main token that enables overall Linked API access. - `identification-token` – unique token specific to each managed LinkedIn account. You can obtain these tokens through [our platform](https://app.linkedapi.io/), as demonstrated below: ![](/images/docs/tokens.webp) :::surface http Send both tokens as headers with every request: ::: :::surface node,python In the SDKs you pass both tokens once, when you create the client, and every call sends them: ::: :::surface cli In the CLI, [`linkedin setup`](/cli/getting-started#setup) asks for both tokens once and saves them as an account, and every command sends them. Admin commands send only the Linked API token. ::: ```text tab=HTTP linked-api-token: your-linked-api-token identification-token: your-identification-token ``` ```typescript tab=Node import LinkedApi from "@linkedapi/node"; const linkedapi = new LinkedApi({ linkedApiToken: "your-linked-api-token", identificationToken: "your-identification-token", }); ``` ```python tab=Python from linkedapi import LinkedApi, LinkedApiConfig linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) ``` ```bash tab=CLI linkedin setup # asks for both tokens and saves them as an account linkedin account list # the accounts saved so far ``` :::surface node,python The [Admin API](/docs/admin-overview#authentication) has its own client, created with the Linked API token only. ::: ## Checking your tokens `GET /account` returns the name and LinkedIn profile URL of the account the identification token belongs to. It runs no workflow, so it is the quickest way to check that both tokens work. Invalid or missing tokens return the [errors any request can return](/docs/common-errors#errors-any-request-can-return). ```bash tab=HTTP curl https://api.linkedapi.io/account \ -H "linked-api-token: your-linked-api-token" \ -H "identification-token: your-identification-token" ``` ```typescript tab=Node const { data } = await linkedapi.getAccountInfo(); console.log(data?.name, data?.url); ``` ```python tab=Python info = linkedapi.get_account_info() print(info.data.name, info.data.url) ``` ```bash tab=CLI linkedin setup # Linked API Token: account_**** # Identification Token: id_**** # Verifying tokens... OK # Account "John Doe" saved and set as active. ``` :::surface node,python The SDK method returns the account in `data` and throws `LinkedApiError` when the request fails. ::: :::surface cli `linkedin setup` checks the tokens this way and saves the account under its LinkedIn name; `linkedin account update` checks new tokens the same way. ::: ```json { "success": true, "result": { "name": "John Doe", "url": "https://www.linkedin.com/in/john-doe" } } ``` ## Response structure All responses in Linked API have the following structure: 1. **For successful requests:** ```json { "success": true, "result": { /* Endpoint specific result */ } } ``` 2. **For requests with errors:** ```json { "success": false, "error": { /* Common or endpoint specific error */ } } ``` ## Data Conventions This page describes how results represent LinkedIn data: which URLs you get back, how LinkedIn URNs identify people and content, how your account relates to them, how times are expressed, and what a missing value looks like. ## URL normalization Linked API normalizes all LinkedIn URLs in responses, regardless of the format provided in action parameters. Normalized URLs consistently follow these rules: - Use `https` protocol. - Include `www` subdomain. - Exclude trailing slash (`/`). For example, if action parameters include a URL like `http://linkedin.com/in/person1/`, the response will return it as `https://www.linkedin.com/in/person1`. Consider this behavior when implementing URL comparisons or storage in your integration. ## LinkedIn URNs Alongside URLs, action results expose LinkedIn's own internal identifiers in **URN** format. A URN is permanent: it does not change when a person edits their profile address or a company renames its page, which makes it a reliable key for storing and deduplicating records on your side. Four types of URNs are returned: - `urn:li:member:123456789` – identifies a person. - `urn:li:organization:1234567` – identifies a company. - `urn:li:jobPosting:1234567890` – identifies a job posting. - `urn:li:activity:1234567890123456789` – identifies a post. A post URN can also be `urn:li:share:` or `urn:li:ugcPost:`, and the same post may be identified by more than one of them. A URN identifies the same entity regardless of where it was found. The person returned by [st.searchPeople](/docs/action-st-search-people) and the same person returned by [nv.openPersonPage](/docs/action-nv-open-person-page) carry an identical `urn`, so results of Standard and Sales Navigator actions can be matched against each other and against previously stored data. The field is named `urn` when it identifies the entity the result is about, and receives a prefix when it points to a different entity. For example, `companyUrn` identifies the current company of a person and `personUrn` identifies the participant of a conversation. Post URNs can also be sent back: every post action accepts `postUrn` in place of `postUrl`, so an identifier returned by one action can address the post in the next one without building a URL. See [addressing a post by URN](/docs/publishing-posts#addressing-a-post-by-urn). > URNs are provided on a best-effort basis. LinkedIn does not expose an identifier on every page, and when an identifier is absent or ambiguous, the field is returned as `null` rather than guessed. Treat URNs as an enrichment of the existing URL fields, not as a replacement for them. ## Viewer state Results that describe a person or a piece of content also say how the account running the workflow relates to it, in a `viewerState` object. It is read from the same page as the rest of the result, so it adds no time to the action. - On a person – search results, employees, decision makers, a person page, and the author of a post or comment: - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` when LinkedIn shows no degree, for example on your own content or on comments read in the feed or from LinkedIn's classic post layout. - On a post or a comment: - `reaction` – your reaction (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`), or `null` if you have not reacted or the page does not show it. Comments read in the feed or from LinkedIn's classic post layout always report `null`, so `null` alone does not prove you have not reacted. - `isOwn` – `true` if your account is the author, `false` if someone else is, or `null` if this cannot be told. Content published as a company page you administer counts as the company's, so it is `false`. ## Timezone normalization All date and time values returned by Linked API are in the **UTC** **timezone**. To display these values correctly in your integration, you should apply the appropriate offset for your user's local timezone. ## Handling missing values When certain data is unavailable on LinkedIn, some fields in action results may return as `null` or empty array (`[]`). Missing fields are always **explicitly included** in the response rather than being omitted. ## Common Errors When a request fails, the response has `success: false` and an `error` object (see [Response structure](/docs/making-requests#response-structure)): ```json { "success": false, "error": { "type": "invalidLinkedApiToken", "message": "The provided 'linked-api-token' is invalid." } } ``` - `type` – the error identifier. Handle errors in your code by `type`. - `message` – a human-readable explanation for logs and people. :::surface http The same request with an invalid `linked-api-token`, and the response it gets: ::: :::surface node,python In the SDKs a failed request throws `LinkedApiError` with the same `type` and `message`: ::: :::surface cli In the CLI a failed request writes the same `type` and `message` to stderr as `{"error", "message"}` and exits with a non-zero [exit code](/cli/flags-and-output#exit-codes): `2` for token errors, `3` for subscription errors, `6` for `tooManyRequests`. ::: ```bash tab=HTTP curl -i -X POST https://api.linkedapi.io/workflows \ -H "Content-Type: application/json" \ -H "linked-api-token: invalid-token" \ -H "identification-token: your-identification-token" \ -d '{"actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1"}' # HTTP/2 401 # {"success":false,"error":{"type":"invalidLinkedApiToken","message":"The provided 'linked-api-token' is invalid."}} ``` ```typescript tab=Node import { LinkedApiError } from "@linkedapi/node"; try { const workflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/person1" }); } catch (e) { if (e instanceof LinkedApiError) { console.error(e.type, e.message); } } ``` ```python tab=Python from linkedapi import CheckConnectionStatusParams, LinkedApiError try: workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/person1") ) except LinkedApiError as e: print(e.type, e.message) ``` ```bash tab=CLI linkedin connection status https://www.linkedin.com/in/person1 echo $? # stderr: {"error":"invalidLinkedApiToken","message":"The provided 'linked-api-token' is invalid."} # stderr: Run "linkedin account update" to refresh your tokens. # 2 ``` :::surface node,python A network failure throws it with the type `httpError`. See [Handling results](/sdks/handling-results). ::: ## Errors any request can return These errors do not depend on the endpoint: they come from checks every request goes through – authorization and the request rate. Admin API requests do not use the `identification-token`, so the two errors about it never apply there. | Type | HTTP status | Cause | What to do | |------|-------------|-------|------------| | `linkedApiTokenRequired` | 401 | The `linked-api-token` header is missing. | Add the header – see [Authorization](/docs/making-requests#authorization). | | `invalidLinkedApiToken` | 401 | The `linked-api-token` value matches no token. | Copy the token again from the [platform](https://app.linkedapi.io/). | | `identificationTokenRequired` | 401 | The `identification-token` header is missing. | Add the token of the LinkedIn account the request is for. | | `invalidIdentificationToken` | 401 | The `identification-token` value matches no LinkedIn account, or the account has been deleted. | Copy the account token again from the [platform](https://app.linkedapi.io/). | | `tooManyRequests` | 429 | More than 100 requests to one endpoint within 60 seconds with one `linked-api-token`, across all its LinkedIn accounts. Checking a workflow result with `GET /workflows/{workflowId}` allows 600. | Wait and retry. Spread requests over time instead of sending them in bursts. | | `unexpectedError` | 400 | The request failed on the Linked API side. | Retry. If the error repeats, contact support. | The `message` of each of these errors is fixed text: | Type | `message` | |------|-----------| | `linkedApiTokenRequired` | `'linked-api-token' is missing in request headers.` | | `invalidLinkedApiToken` | `The provided 'linked-api-token' is invalid.` | | `identificationTokenRequired` | `'identification-token' is missing in request headers.` | | `invalidIdentificationToken` | `The provided 'identification-token' is invalid.` | | `tooManyRequests` | `Too many requests. Please try again later.` | | `unexpectedError` | `Unexpected error: ` – the same for every such failure, without details | ## Errors described elsewhere Errors that belong to one part of the API are described next to it: - **Starting a workflow** – an invalid workflow, a missing subscription or Plus seat, a signed-out account, working hours: [Executing Workflows](/docs/executing-workflows#starting-workflows). - **Actions inside a workflow** – errors an action reports in its result: [Actions Overview](/docs/actions-overview#common-errors) and each action page. - **Signals** (coming soon) – [Managing Signals](/docs/managing-signals#errors). - **Webhooks** – each method of [Managing Webhooks](/docs/admin-webhooks). - **Admin API** – [Admin API Overview](/docs/admin-overview#common-errors). ## Working hours This page explains how per-account working hours affect your API requests, what the off-hours policies do, and how to handle a workflow that is parked until the window reopens. :::surface node,python In the SDKs working hours show up in two places: when the policy rejects a request, `execute()` throws `LinkedApiError` with the type `outsideWorkingHours`; when it parks one, `execute()` and `status()` return `pendingReason` set to `outsideWorkingHours`. ::: :::surface cli In the CLI working hours show up in two places: when the policy rejects a request, the command exits with code `1` and writes the `outsideWorkingHours` error to stderr; when it parks one, the command waits, and `linkedin workflow status ` shows a `message` with the time the window opens. ::: ## What working hours are Each connected LinkedIn account can carry a **weekly activity window** – the hours during which Linked API is allowed to act on it. A LinkedIn account that is active around the clock looks like a user who never sleeps, which is one of the strongest automation signals there is. Giving an account a human-shaped schedule removes that signal without costing you any throughput inside the window. Working hours are configured per account in [our platform](https://app.linkedapi.io/), next to the account limits. There is no API for changing them – they are an account-owner decision, not a per-request one. ![](/images/docs/working-hours-settings.webp) The window is stored with an explicit timezone, so it follows the account's local wall clock across daylight-saving changes rather than drifting by an hour twice a year. | Setting | Meaning | | --- | --- | | Days | Which weekdays the account is active on. | | Time range | Start and end of the daily window, in the account's timezone. | | Timezone | The zone the window is expressed in. Defaults to the timezone of the device you configure it from. | | Outside working hours | What happens to requests that arrive outside the window – see below. | An account with **no** window configured is never gated. If you have never touched this setting, nothing about your integration changes. ## Off-hours policies The policy decides what happens to a request that arrives outside the window. It is the **Outside working hours** selector in the screenshot above. ### Queue until the window opens The workflow is accepted and parked. It starts automatically when the window reopens – you do not need to resubmit it. This is the default for newly connected accounts. ### Reject with an error The request fails immediately with the `outsideWorkingHours` error and nothing is queued. ```json { "success": false, "error": { "type": "outsideWorkingHours", "message": "This LinkedIn account is outside its configured working hours." } } ``` :::surface node,python In the SDKs `execute()` throws `LinkedApiError` with the type `outsideWorkingHours`. ::: :::surface cli The CLI writes `{"error": "outsideWorkingHours", "message": "..."}` to stderr and exits with code `1`. ::: ### Run anyway Your API requests run at any time – the window is not enforced for them. Note that this applies to **your** requests only. Work Linked API schedules for itself – inbox and network synchronisation – stays inside the window under every policy, and the cloud browser behind the account is released outside the window under every policy too. That is where the account-safety and infrastructure benefits of the schedule come from. ## Telling a parked workflow from a queued one Every response describing a workflow that has not started yet carries a `pendingReason`. It appears both in the response to `POST /workflows` and in the [workflow result](/docs/executing-workflows) response. :::surface node,python In the SDKs it is `pendingReason` (Python `pending_reason`) on what `execute()` and `status()` return. ::: :::surface cli The CLI does not print `pendingReason`: `linkedin workflow status ` prints the `status` and the `message`, and for a parked workflow the `message` names the time the window opens. ::: ```json { "success": true, "result": { "workflowId": "wf-64835e7c-a0gc-40ae-8338-108f02sSe643", "workflowStatus": "pending", "pendingReason": "outsideWorkingHours", "message": "This workflow is scheduled and will start when the account working hours open at Mon, Aug 10, 09:00 (Europe/Berlin)." } } ``` | Value | Meaning | | --- | --- | | `queued` | Waiting its turn behind other work on the same account. Minutes, typically. | | `outsideWorkingHours` | Parked until the account working hours reopen. Can be the next working day. | | `null` | The workflow is not pending. | This distinction matters for polling. A `queued` workflow is worth polling every few seconds. An `outsideWorkingHours` one will not move until the window reopens, so polling it in a tight loop achieves nothing – back off until the time named in `message`, or surface that time to your user. ## When a parked workflow expires A parked workflow does not wait forever. If it is still pending 7 days after it was first parked, and the window has opened at least once in that time without the workflow being started, it fails with a dedicated error: ```json { "success": true, "result": { "workflowStatus": "failed", "failure": { "reason": "workingHoursWaitExpired", "message": "This workflow remained pending for 7 days after it was first parked by the account working hours, and was not started." } } } ``` A workflow that expires this way never entered the `running` state, so it emits the `workflow.created` and `workflow.completed` [webhook events](/docs/webhook-event-types#workflow-events) but no `workflow.started`. This is worth watching if you combine a narrow window with a high request volume: an account is only able to run one workflow at a time, so a window of a couple of hours per week puts a hard ceiling on how much work can drain through it. ## Seeing the current state The account list in the platform shows each account's schedule underneath its connection status, and marks the ones that are outside their window right now – so you can tell at a glance why nothing is starting for an account. ![](/images/docs/working-hours-account-list.webp) An account with no schedule line carries no window at all and is never gated. ## Workflows Overview A workflow does something on LinkedIn now, on behalf of one connected account. This page explains what a workflow is made of and how Linked API runs it. ## What a workflow is Every action you ask Linked API to perform on LinkedIn right away – send a connection request, comment on a post, retrieve company data – runs as a **workflow**. Workflows are constructed using building blocks called **actions**. Each action represents a specific operation within your workflow. To learn more about this concept, see the [actions overview](/docs/actions-overview) and [building workflows](/docs/building-workflows) pages. This workflow-based approach, rather than REST-style endpoints, allows you to build any automation sequence imaginable. It provides complete flexibility, which is why we chose workflows as our foundation. ## Request timing Since Linked API [fully emulates](/safety) actions of a real LinkedIn user, requests are not executed instantly. For example, simple workflows like visiting a person's page and liking their latest post might take around 20 seconds, while more complex workflows, such as retrieving detailed company information including a list of its employees, can take several minutes. ## Sequential execution Linked API executes workflows sequentially. While you can send multiple workflow requests in quick succession, they **will not be executed in parallel**. Each workflow request is **accepted immediately**, but its execution will not start until the previous workflow is completed. For example, if you request a workflow to [check a connection status](/docs/checking-connection-status) and immediately request another workflow to [send a message](/docs/sending-message), the second workflow will begin **only after the first workflow is completed**. This approach ensures alignment with realistic user behavior on LinkedIn, as it mirrors how a real user would interact with the platform. ## Executing workflows > For details on how to build workflows, visit the [building workflows](/docs/building-workflows) page. Executing a workflow consists of 2 key steps: 1. Starting a workflow and receiving its `workflowId`, current `workflowStatus`, and human-readable `message`. 2. Periodically checking for the result using the `workflowId`. ## Starting workflows :::surface http To start executing a workflow, make a `POST` request to the following endpoint: ```text POST https://api.linkedapi.io/workflows ``` In the request body, include a JSON that describes your workflow, for example: ::: :::surface node,python,cli Start the workflow with the request it should run, for example: ::: ```json tab=HTTP { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/person1", }); console.log(workflow.workflowId); ``` ```python tab=Python from linkedapi import CheckConnectionStatusParams workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/person1") ) print(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'JSON' { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1" } JSON ``` :::surface node,python Every SDK method has `execute()`, which starts the workflow and returns its `workflowId`; any workflow JSON goes through [`customWorkflow`](/sdks/custom-workflow). See [Predefined vs Custom Workflows](/sdks/predefined-vs-custom). ::: :::surface cli Commands such as `linkedin connection status` start the workflow and wait for its result, and so does `linkedin workflow run` for any workflow JSON – see [Custom Workflows](/cli/workflows). The CLI has no separate start step: while it waits it writes the workflow ID and status to stderr as progress (hidden by `--quiet`), and at the end it prints the completion as `data`. The response below is what the API returns to the start request. ::: :::surface http Upon successful request, you will receive the following response: ::: :::surface node,python **Under the hood** – `execute()` sends this request and returns the fields of its `result` directly: `workflowId`, `workflowStatus`, `pendingReason` and `message` (`workflow_id`, `workflow_status`, `pending_reason` and `message` in Python), without the `success` and `result` wrapper: ::: ```json { "success": true, "result": { "workflowId": "wf-64835e7c-a0gc-40ae-8338-108f02sSe643", "workflowStatus": "pending", "pendingReason": "queued", "message": "This workflow is scheduled and will start after 2 other workflows already scheduled ahead of it." } } ``` - `workflowId` – unique workflow identifier for checking the result. - `workflowStatus` – current workflow status. Possible values at start are `pending` and `running`. - `pendingReason` – why the workflow has not started yet. `queued` means it is waiting its turn behind other work on the same account; `outsideWorkingHours` means it is parked until the account [working hours](/docs/working-hours) reopen. It is `null` once the workflow is no longer pending. - `message` – human-readable execution message, such as queue position or estimated duration. This value can change while polling. :::surface http In case of an unsuccessful request, you will receive the following response: ::: :::surface node,python **Errors** – an unsuccessful start throws `LinkedApiError` with the `type` and `message` of this response: ::: :::surface cli **Errors** – the CLI prints an unsuccessful start to stderr as `{"error": "", "message": "…"}` and exits with the matching [exit code](/cli/flags-and-output#exit-codes). The types are the ones below: ::: ```json { "success": false, "error": { "type": "linkedApiTokenRequired", "message": "'linked-api-token' is missing in request headers." } } ``` - `error` – either a [common error](/docs/common-errors) or one of the following specific errors: - `invalidWorkflow` – workflow configuration is not valid due to violated [action constraints](/docs/actions-overview) or invalid [action parameters](/docs/actions-overview), including a parameter the root action does not define: `{validation_details}`. - `subscriptionRequired` – no purchased subscription seats are available for this LinkedIn account (message: `No purchased subscription seats available for this LinkedIn account.`). Add seats, renew the subscription for existing seats, or check for payment issues on the [platform](https://app.linkedapi.io/). - `plusPlanRequired` – the workflow contains Sales Navigator actions (`nv.*`), which need a Plus seat and a Sales Navigator subscription on the LinkedIn account. - `linkedinAccountSignedOut` – the LinkedIn account has been signed out in our cloud browser. Reconnect it on the [platform](https://app.linkedapi.io/). - `outsideWorkingHours` – the account is outside its configured [working hours](/docs/working-hours) and its off-hours policy is set to reject. Nothing is queued. ## Checking for result > Prefer to be notified instead of polling? Register a [webhook](/docs/webhooks) and Linked API will `POST` an event to your endpoint when the workflow completes. After starting your workflow and receiving the `workflowId`, you need to periodically check the workflow result: ```text tab=HTTP GET https://api.linkedapi.io/workflows/{workflowId} ``` ```typescript tab=Node // Waits until the workflow completes const { data, errors } = await linkedapi.checkConnectionStatus.result(workflowId); // Checks once, without waiting const status = await linkedapi.checkConnectionStatus.status(workflowId); ``` ```python tab=Python # Waits until the workflow completes result = linkedapi.check_connection_status.result(workflow_id) # Checks once, without waiting status = linkedapi.check_connection_status.status(workflow_id) ``` ```bash tab=CLI # Check the current status linkedin workflow status abc123 # Wait for completion linkedin workflow status abc123 --wait --json ``` :::surface node,python `result()` polls for you and returns `{ data, errors }` once the workflow completes; `status()` checks once. How the SDK reports workflow and request errors is described in [Handling Results and Errors](/sdks/handling-results). ::: :::surface http Depending on the workflow execution status, you'll receive one of these results: ::: :::surface node,python **Under the hood** – while the workflow is pending or running, `status()` returns its `workflowStatus`, `pendingReason` and `message`. Once it completes, `status()` and `result()` return the method's own `{ data, errors }` mapped from the completion, not the response itself. A failed workflow throws `LinkedApiError` whose `type` is the `failure.reason` below. These are the API responses they read: ::: :::surface cli **Output** – while the workflow is pending or running, `linkedin workflow status` prints `{"success": true, "data": {"workflowId", "status", "message"}}`, without `pendingReason`. Once it completes, it prints the completion as `data`; `--wait` waits for that. A failed workflow prints nothing on stdout: stderr gets `{"error": "", "message": "…"}` and the command exits with that reason's [exit code](/cli/flags-and-output#exit-codes) – `4` for `linkedinAccountSignedOut` and `languageNotSupported`, `1` for any other, such as `cancelledByUser` or `workingHoursWaitExpired`. See [workflow status](/cli/workflows#workflow-status). These are the API responses it reads: ::: 1. **Workflow is pending (queued for execution):** ```json { "success": true, "result": { "workflowStatus": "pending", "pendingReason": "queued", "message": "This workflow is scheduled and will start after 2 other workflows already scheduled ahead of it." } } ``` Workflows are executed sequentially per LinkedIn account. If you submit multiple workflows, the first one starts executing immediately while the others are queued. The `pending` status means the workflow is waiting in the queue and will start running once the previous workflows complete. A workflow can also be pending because the account is outside its [working hours](/docs/working-hours). In that case `pendingReason` is `outsideWorkingHours` and the workflow will not move until the window reopens, which can be the next working day: ```json { "success": true, "result": { "workflowStatus": "pending", "pendingReason": "outsideWorkingHours", "message": "This workflow is scheduled and will start when the account working hours open at Mon, Aug 10, 09:00 (Europe/Berlin)." } } ``` Back off your polling in that case rather than retrying every few seconds – the `message` names the time the window opens. 2. **Workflow is running:** ```json { "success": true, "result": { "workflowStatus": "running", "pendingReason": null, "message": "This workflow usually takes about 30-60 seconds." } } ``` While a workflow is `pending` or `running`, the `message` field may update between polling requests. For example, a queued workflow can first return a queue-position message and later return an estimated-duration message after it starts running. 3. **Workflow completed successfully:** ```json { "success": true, "result": { "workflowStatus": "completed", "completion": { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "pending" } } } } ``` - `completion` – results of all actions included in this workflow. 4. **Workflow completed with action error:** ```json { "success": true, "result": { "workflowStatus": "completed", "completion": { "actionType": "st.checkConnectionStatus", "success": false, "error": { "type": "limitExceeded", "message": "The configured limit for this action category has been exceeded." } } } } ``` 5. **Workflow failed:** ```json { "success": true, "result": { "workflowStatus": "failed", "failure": { "reason": "linkedinAccountSignedOut", "message": "This LinkedIn account has been signed out in our cloud browser." } } } ``` - `failure` – object describing the workflow failure reason: - `linkedinAccountSignedOut` – your LinkedIn account has been signed out in our cloud browser. This occasionally happens as LinkedIn may sign out accounts after an extended period. You'll need to visit [our platform](https://app.linkedapi.io/) and reconnect your account. - `languageNotSupported` – your LinkedIn account uses a language Linked API does not support yet. Supported languages are English, German, Spanish, French and Portuguese. If you need another one, please contact our support – we prioritize new languages based on user requests. > In case of an unsuccessful request, you'll receive one of the [common errors](/docs/common-errors). ## Cancelling workflows Some workflows in Linked API can take a long time to complete. If you decide you no longer need a workflow to finish, you have the option to cancel it. You can cancel a running or pending workflow directly on [our platform](https://app.linkedapi.io/stats). Simply click the three dots menu next to the workflow and select "Cancel". Alternatively, you can cancel a workflow from your code: ```text tab=HTTP DELETE https://api.linkedapi.io/workflows/{workflowId} ``` ```typescript tab=Node // Call cancel() on the same method you used to start the workflow const cancelled = await linkedapi.checkConnectionStatus.cancel(workflowId); ``` ```python tab=Python # Call cancel() on the same method you used to start the workflow cancelled = linkedapi.check_connection_status.cancel(workflow_id) ``` ```bash tab=CLI # The CLI has no cancel command: send the HTTP request with curl curl -X DELETE "https://api.linkedapi.io/workflows/$WORKFLOW_ID" \ -H "linked-api-token: your-linked-api-token" \ -H "identification-token: your-identification-token" ``` :::surface cli The CLI has no cancel command: send the request with `curl`, as above, or cancel the workflow on the [platform](https://app.linkedapi.io/stats). ::: :::surface http,cli Upon successful cancellation, you will receive the following response: ::: :::surface node,python **Under the hood** – `cancel()` returns `true` (`True` in Python) when this response reports the workflow cancelled; an unsuccessful request throws `LinkedApiError`: ::: ```json { "success": true, "result": { "cancelled": true } } ``` > In case of an unsuccessful request, you'll receive one of the [common errors](/docs/common-errors). **Important notes:** - **Partial execution.** The workflow is cancelled at its current execution point. Any actions that have already been completed cannot be undone. For example, if the workflow has already sent messages or connection requests from your LinkedIn account, these actions will remain executed. - **No intermediate data.** Once a workflow is cancelled, no data is preserved or returned. You will not receive any intermediate results or partial data that may have been collected before cancellation. ## Building workflows Workflows are constructed using building blocks called actions, each having its own purpose (you may learn more about actions on the [actions overview](/docs/actions-overview) page). A workflow, after being successfully executed, produces a `completion`, that contains the results of all actions. See the [executing workflows](/docs/executing-workflows) page for details. Here you'll learn how to build various workflows, from single-action to advanced. > In most cases, you don't need to build workflows from scratch. The left menu contains pages with pre-built workflows for popular scenarios you can simply copy and use. :::surface node In the Node SDK, `customWorkflow` runs a workflow definition as is, so every example below works unchanged and returns its completion in `data`. For a single action, a predefined method builds the workflow for you – see [Predefined vs custom](/sdks/predefined-vs-custom). ::: :::surface python In the Python SDK, `custom_workflow` runs a workflow definition as is, with its keys as written, so every example below works unchanged and returns its completion in `data`. For a single action, a predefined method builds the workflow for you – see [Predefined vs custom](/sdks/predefined-vs-custom). ::: :::surface cli In the CLI, `linkedin workflow run` runs a workflow definition as is and prints its completion. For a single action, a command builds the workflow for you – see [Commands](/cli/commands). ::: ## Single-action workflows Single-action workflows contain exactly one action. You can only use actions that allow [root start in their constraints](/docs/actions-overview). ### Example 1 Let's say you want to build a workflow to check the connection status between your LinkedIn account and another person. You only need to use [st.checkConnectionStatus](/docs/action-st-check-connection-status) action to accomplish this. **Workflow:** ```json tab=HTTP { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.checkConnectionStatus", personUrl: "https://www.linkedin.com/in/person1" }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1", } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1" } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "pending" } } ``` ### Example 2 Let's say you want to build a workflow to retrieve basic information about a company. You only need to use [st.openCompanyPage](/docs/action-st-open-company-page) action to accomplish this. **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": true } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openCompanyPage", companyUrl: "https://www.linkedin.com/company/company1", basicInfo: true }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": True, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": true } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/company1", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "Cupertino, California", "headquarters": "US", "industry": "Information Technology", "specialties": "Cloud Computing, AI, Software Development", "website": "https://techcorp.com", "employeeCount": 500, "yearFounded": 2019, "jobsCount": 12 } } ``` ## Array workflows Array workflows contain multiple actions executed sequentially. You can only use actions that allow [root start in their constraints](/docs/actions-overview). > When you need to perform multiple identical operations, use one array workflow with multiple actions instead of separate single-action workflows. This approach is significantly faster due to API optimizations. ### Example 1 Let's say you want to build a workflow to check the connection status between your LinkedIn account and 5 different people. You need to use [st.checkConnectionStatus](/docs/action-st-check-connection-status) action multiple times to accomplish this. **Workflow:** ```json tab=HTTP [ { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person2" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person3" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person4" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person5" } ] ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute([ { actionType: "st.checkConnectionStatus", personUrl: "https://www.linkedin.com/in/person1" }, { actionType: "st.checkConnectionStatus", personUrl: "https://www.linkedin.com/in/person2" }, { actionType: "st.checkConnectionStatus", personUrl: "https://www.linkedin.com/in/person3" }, { actionType: "st.checkConnectionStatus", personUrl: "https://www.linkedin.com/in/person4" }, { actionType: "st.checkConnectionStatus", personUrl: "https://www.linkedin.com/in/person5" } ]); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( [ { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1", }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person2", }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person3", }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person4", }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person5", }, ] ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' [ { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person2" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person3" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person4" }, { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person5" } ] EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json [ { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "connected" } }, { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "notConnected" } }, { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "pending" } }, { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "connected" } }, { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "connected" } } ] ``` ### Example 2 Let's say you want to build a workflow to retrieve basic information about several people. You need to use [st.openPersonPage](/docs/action-st-open-person-page) action multiple times to accomplish this. **Workflow:** ```json tab=HTTP [ { "actionType": "st.openPersonPage", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": true }, { "actionType": "st.openPersonPage", "label": "person2", "personUrl": "https://www.linkedin.com/in/person2", "basicInfo": true } ] ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute([ { actionType: "st.openPersonPage", label: "person1", personUrl: "https://www.linkedin.com/in/person1", basicInfo: true }, { actionType: "st.openPersonPage", label: "person2", personUrl: "https://www.linkedin.com/in/person2", basicInfo: true } ]); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( [ { "actionType": "st.openPersonPage", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": True, }, { "actionType": "st.openPersonPage", "label": "person2", "personUrl": "https://www.linkedin.com/in/person2", "basicInfo": True, }, ] ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' [ { "actionType": "st.openPersonPage", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": true }, { "actionType": "st.openPersonPage", "label": "person2", "personUrl": "https://www.linkedin.com/in/person2", "basicInfo": true } ] EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json [ { "actionType": "st.openPersonPage", "label": "person1", "success": true, "data": { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/person1", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8OeJoj0mrkxx7Jiuy0", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "countryCode": "US", "position": "Software Engineer", "companyName": "TechCorp", "companyUrl": "https://www.linkedin.com/company/12345678" } }, { "actionType": "st.openPersonPage", "label": "person2", "success": true, "data": { "name": "Jane Doe", "publicUrl": "https://www.linkedin.com/in/person2", "headline": "Product Manager at Cognito Inc.", "location": "Lisbon, Portugal", "countryCode": "PT", "position": "Product Manager", "companyName": "Cognito Inc.", "companyUrl": "https://www.linkedin.com/company/87654321" } } ] ``` ## Advanced workflows With Linked API, you can build **any workflow you can imagine** by combining arrays and parent-child action relationships through `then` parameter. Only two rules apply: - You must follow the [constraints of all actions](/docs/actions-overview) used. - The final workflow cannot exceed **5 levels of nesting**. ### Example 1 Let's say you want to build a workflow that searches for companies using specific filters and keywords, then retrieves basic information about all companies from the search results. You need to use [st.searchCompanies](/docs/action-st-search-companies), [st.doForCompanies](/docs/action-st-do-for-companies) and [st.openCompanyPage](/docs/action-st-open-company-page) actions to accomplish this. **Workflow:** ```json tab=HTTP { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"] }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "basicInfo": true } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.searchCompanies", term: "Tech Inc", filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York"], industries: ["Software Development", "Robotics Engineering"] }, then: { actionType: "st.doForCompanies", then: { actionType: "st.openCompanyPage", basicInfo: true } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], }, "then": { "actionType": "st.doForCompanies", "then": {"actionType": "st.openCompanyPage", "basicInfo": True}, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": [ "51-200", "201-500" ], "locations": [ "San Francisco", "New York" ], "industries": [ "Software Development", "Robotics Engineering" ] }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "basicInfo": true } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.searchCompanies", "success": true, "data": [ { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/techcorp", "industry": "Information Technology", "location": "California", "then": { "actionType": "st.openCompanyPage", "success": true, "data": { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/techcorp", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "California", "headquarters": "US", "industry": "Information Technology", "specialties": "Cloud Computing, AI, Software Development", "website": "https://techcorp.com", "employeeCount": 500, "yearFounded": 2019, "jobsCount": 12 } } }, { "name": "Techical Life", "publicUrl": "https://www.linkedin.com/company/techlife", "industry": "Software Development", "location": "Mountain View", "then": { "actionType": "st.openCompanyPage", "success": true, "data": { "name": "Techical Life", "publicUrl": "https://www.linkedin.com/company/techlife", "description": "TechLife is a worldwide leader in software development for sustainability.", "location": "Mountain View", "headquarters": "US", "industry": "Software Development", "specialties": "Software Development, AI", "website": "https://techlife.com", "employeeCount": 163, "yearFounded": 2016, "jobsCount": 4 } } } ] } ``` ### Example 2 Let's say you want to build a workflow that retrieves basic company information, finds all managers, gets their basic information and education details, then sends them connection requests. You need to use [st.openCompanyPage](/docs/action-st-open-company-page), [st.retrieveCompanyEmployees](/docs/action-st-retrieve-company-employees), [st.doForPeople](/docs/action-st-do-for-people), [st.openPersonPage](/docs/action-st-open-person-page), [st.retrievePersonEducation](/docs/action-st-retrieve-person-education) and [st.sendConnectionRequest](/docs/action-st-send-connection-request) actions to accomplish this. **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "basicInfo": true, "then": { "actionType": "st.retrieveCompanyEmployees", "filter": { "position": "Manager" }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": true, "then": [ { "actionType": "st.retrievePersonEducation" }, { "actionType": "st.sendConnectionRequest" } ] } } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openCompanyPage", companyUrl: "https://www.linkedin.com/company/techcorp", basicInfo: true, then: { actionType: "st.retrieveCompanyEmployees", filter: { position: "Manager" }, then: { actionType: "st.doForPeople", then: { actionType: "st.openPersonPage", basicInfo: true, then: [ { actionType: "st.retrievePersonEducation" }, { actionType: "st.sendConnectionRequest" } ] } } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "basicInfo": True, "then": { "actionType": "st.retrieveCompanyEmployees", "filter": {"position": "Manager"}, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": True, "then": [ {"actionType": "st.retrievePersonEducation"}, {"actionType": "st.sendConnectionRequest"}, ], }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "basicInfo": true, "then": { "actionType": "st.retrieveCompanyEmployees", "filter": { "position": "Manager" }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": true, "then": [ { "actionType": "st.retrievePersonEducation" }, { "actionType": "st.sendConnectionRequest" } ] } } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/techcorp", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "California", "headquarters": "US", "industry": "Information Technology", "specialties": "Cloud Computing, AI, Software Development", "website": "https://techcorp.com", "employeeCount": 500, "yearFounded": 2019, "jobsCount": 12, "then": { "actionType": "st.retrieveCompanyEmployees", "success": true, "data": [ { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Product Manager at TechCorp", "location": "New York, USA", "then": { "actionType": "st.openPersonPage", "success": true, "data": { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johndoe", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "headline": "Product Manager at TechCorp", "location": "New York, USA", "countryCode": "US", "position": "Product Manager", "companyName": "TechCorp", "hashedCompanyUrl": "https://www.linkedin.com/company/87654321", "then": [ { "actionType": "st.retrievePersonEducation", "success": true, "data": [ { "schoolName": "Harvard University", "schoolHashedUrl": "https://www.linkedin.com/company/12345678", "details": "Master of Science in Computer Science, Artificial Intelligence" }, { "schoolName": "MIT", "schoolHashedUrl": "https://www.linkedin.com/company/87654321", "details": "Bachelor of Science in Electrical Engineering and Computer Science" } ] }, { "actionType": "st.sendConnectionRequest", "success": true } ] } } } ... ] } } } ``` > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Actions overview Each action represents a specific operation within a [workflow](/docs/building-workflows). On this page, you'll find details about action definition and an overview of all available actions. :::surface node In the Node SDK each predefined method is an action: it takes the action's parameters without `actionType`, `label` and `then`, and child actions become options such as `retrieveExperience` on `fetchPerson`. [`customWorkflow`](/sdks/custom-workflow) takes action objects exactly as described on this page. ::: :::surface python In the Python SDK each predefined method is an action: it takes the action's parameters in snake_case, without `actionType`, `label` and `then`, and child actions become options such as `retrieve_experience` on `fetch_person`. [`custom_workflow`](/sdks/custom-workflow) takes action objects exactly as described on this page, with their keys as written. ::: :::surface cli In the CLI each command is an action: it takes the action's parameters as arguments and flags, and options such as `--experience` on `person fetch` add child actions. [`linkedin workflow run`](/cli/workflows#workflow-run) takes action objects exactly as described on this page. ::: ## Action definition Each action is defined by 3 characteristics: **constraints**, **parameters**, and **result options**: ### Constraints Constraints specify the valid parent-child relationships between actions in a workflow (defined using the `then` parameter in action JSON structure). On documentation pages, constraints are described with three elements: > ⏺️ **Root Start:** indicates whether an action can be placed at the beginning of a workflow (in the root position). > ⬆️ **Parent Actions:** specifies which actions can be parent actions to this action. > ⬇️ **Child Actions:** specifies which actions can be child actions of this action. If constraints are violated, you'll receive an `invalidWorkflow` error. ### Parameters Parameters configure the action's behavior and are unique to each action type. However, there are 3 common parameters: ```json { "actionType": "st.openPersonPage", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "then": { ... } } ``` - `actionType` – required value that specifies which action is being executed. - `label` (optional) – custom label for tracking this action in workflow completion. When included, this same `label` appears in the result JSON, making it easy to locate the action. - `then` (optional) – object or array of child actions to be executed within this action (see [constraints](/docs/actions-overview) for more details). A parameter the root action does not define is rejected with `invalidWorkflow` rather than ignored. The message names the field and, when a valid one is close enough, suggests it – for example, `unknown field 'filter.fromMember' — did you mean 'fromMembers'?`; otherwise it lists the valid fields. This keeps a misspelled filter from silently running an unfiltered action. Parameters of child actions inside `then` are not checked this way, and actions that take no parameters of their own ignore extra fields. ### Result options Actions can have 2 possible result options: successful and unsuccessful execution, indicated by the `success` field being either `true` or `false`. The result format is unique for each action type. Here are examples: 1. **Successful execution:** ```json { "actionType": "st.checkConnectionStatus", "label": "person1", // if included in parameters "success": true, "data": { "connectionStatus": "pending" } } ``` - `data` – object containing action-specific results and child action results. 2. **Unsuccessful execution:** ```json { "actionType": "st.checkConnectionStatus", "label": "person1", // if included in parameters "success": false, "error": { "type": "personNotFound", "message": "The provided URL is not an existing LinkedIn person." } } ``` - `error` – object containing error details, including `type` and `message`. ### Common errors Some errors can be returned by any action, regardless of its type: - `limitExceeded` – configured limit for this action category has been exceeded. - `unexpectedError` – unexpected error occurred during action execution. In addition to these, each action may define its own specific errors (such as `personNotFound`, `messagingNotAllowed`, etc.). Refer to the individual action pages for their specific error types. ## Available actions Linked API provides 2 types of actions, each with its own namespace: - **Standard actions [st.]**: actions that relate to the standard LinkedIn interface. - **Sales Navigator actions [nv.]**: actions that relate to the Sales Navigator interface. ### Standard actions [st.] - [st.sendMessage](/docs/action-st-send-message) – allows you to send a message to a person. - [st.syncInbox](/docs/action-st-sync-inbox) – allows you to enable [whole-inbox monitoring](/docs/monitoring-inbox) so you can poll every incoming conversation. - [st.syncConversation](/docs/action-st-sync-conversation) – allows you to [load the message history](/docs/working-with-conversations) with a person. - [st.manageConversation](/docs/action-st-manage-conversation) – allows you to manage a conversation thread by archiving, starring, or muting it. - [st.checkConnectionStatus](/docs/action-st-check-connection-status) – allows you to check the connection status between your account and another person. - [st.sendConnectionRequest](/docs/action-st-send-connection-request) – allows you to send a connection request to a person. - [st.withdrawConnectionRequest](/docs/action-st-withdraw-connection-request) – allows you to withdraw the connection request sent to a person. - [st.acceptInvitation](/docs/action-st-accept-invitation) – allows you to accept an incoming connection, company-follow, or newsletter-subscription invitation. - [st.ignoreInvitation](/docs/action-st-ignore-invitation) – allows you to ignore an incoming connection, company-follow, or newsletter-subscription invitation. - [st.retrievePendingRequests](/docs/action-st-retrieve-pending-requests) – allows you to retrieve pending connection requests sent from your account. - [st.retrieveInvitations](/docs/action-st-retrieve-invitations) – allows you to retrieve incoming connection, company-follow, and newsletter-subscription invitations. - [st.retrieveConnections](/docs/action-st-retrieve-connections) – allows you to retrieve your connections and perform additional person-related actions if needed. - [st.removeConnection](/docs/action-st-remove-connection) – allows you to remove a person from your connections. - [st.syncNetwork](/docs/action-st-sync-network) – allows you to enable [network monitoring](/docs/monitoring-network) so you can poll connection events as they happen. - [st.searchCompanies](/docs/action-st-search-companies) – allows you to search for companies applying various filtering criteria. - [st.searchPeople](/docs/action-st-search-people) – allows you to search for people applying various filtering criteria. - [st.searchJobs](/docs/action-st-search-jobs) – allows you to search for jobs applying various filtering criteria. - [st.searchPosts](/docs/action-st-search-posts) – allows you to search for posts applying various filtering criteria. - [st.openCompanyPage](/docs/action-st-open-company-page) – allows you to open a company page to retrieve its basic information and perform additional company-related actions if needed. - [st.retrieveCompanyEmployees](/docs/action-st-retrieve-company-employees) – allows you to retrieve company employees and perform additional person-related actions if needed. - [st.retrieveCompanyDMs](/docs/action-st-retrieve-company-dms) – allows you to retrieve company decision makers and perform additional person-related actions if needed. - [st.retrieveCompanyPosts](/docs/action-st-retrieve-company-posts) – allows you to retrieve posts published by a company and perform additional post-related actions if needed. - [st.openPersonPage](/docs/action-st-open-person-page) – allows you to open a person page to retrieve their basic information and perform additional person-related actions if needed. - [st.retrievePersonExperience](/docs/action-st-retrieve-person-experience) – allows you to retrieve information about a person's experience. - [st.retrievePersonEducation](/docs/action-st-retrieve-person-education) – allows you to retrieve information about a person's education. - [st.retrievePersonSkills](/docs/action-st-retrieve-person-skills) – allows you to retrieve information about a person's skills. - [st.endorsePersonSkill](/docs/action-st-endorse-person-skill) – allows you to endorse a skill on the profile of a person who is your 1st-degree connection. - [st.retrievePersonLanguages](/docs/action-st-retrieve-person-languages) – allows you to retrieve information about a person's languages. - [st.retrievePersonPosts](/docs/action-st-retrieve-person-posts) – allows you to retrieve posts published by a person and perform additional post-related actions if needed. - [st.retrievePersonComments](/docs/action-st-retrieve-person-comments) – allows you to retrieve comments left by a person. - [st.retrievePersonReactions](/docs/action-st-retrieve-person-reactions) – allows you to retrieve reactions made by a person. - [st.openPost](/docs/action-st-open-post) – allows you to open a post to retrieve its data and perform additional post-related actions if needed. - [st.createPost](/docs/action-st-create-post) – allows you to publish a post, optionally with media attachments and mentions. - [st.createRepost](/docs/action-st-create-repost) – allows you to repost a post, either as is or with your own commentary. - [st.reactToPost](/docs/action-st-react-to-post) – allows you to react to a post using any available reaction type. - [st.commentOnPost](/docs/action-st-comment-on-post) – allows you to leave a comment on a post. - [st.retrievePostComments](/docs/action-st-retrieve-post-comments) – allows you to retrieve comments for a post and perform additional comment-related actions if needed. - [st.openComment](/docs/action-st-open-comment) – allows you to open a comment to react to it or reply to it. - [st.reactToComment](/docs/action-st-react-to-comment) – allows you to react to a comment using any available reaction type. - [st.replyToComment](/docs/action-st-reply-to-comment) – allows you to reply to a comment. - [st.openJob](/docs/action-st-open-job) – allows you to open a LinkedIn job and optionally retrieve its details. - [st.doForCompanies](/docs/action-st-do-for-companies) – allows you to apply the actions specified in its `then` parameter to each company provided by the parent action. - [st.doForPeople](/docs/action-st-do-for-people) – allows you to apply the actions specified in its `then` parameter to each person provided by the parent action. - [st.doForPosts](/docs/action-st-do-for-posts) – allows you to apply the actions specified in its `then` parameter to each post provided by the parent action. - [st.doForSkills](/docs/action-st-do-for-skills) – allows you to endorse each skill provided by the parent action, or only the skills whose names match the patterns you specify. - [st.doForComments](/docs/action-st-do-for-comments) – allows you to apply the actions specified in its `then` parameter to each comment provided by the parent action. - [st.doForJobs](/docs/action-st-do-for-jobs) – allows you to apply the actions specified in its `then` parameter to each job provided by the parent action. - [st.retrieveSSI](/docs/action-st-retrieve-ssi) – allows you to retrieve your current [SSI (Social Selling Index)](/guides/linkedin-social-selling-index). - [st.retrievePerformance](/docs/action-st-retrieve-performance) – allows you to retrieve performance analytics from your [LinkedIn dashboard](https://www.linkedin.com/dashboard/). - [st.retrieveFeed](/docs/action-st-retrieve-feed) – allows you to retrieve posts from your own home feed. - [st.retrieveProfileViewers](/docs/action-st-retrieve-profile-viewers) – allows you to retrieve the list of people who recently viewed your own profile. ### Sales Navigator actions [nv.] - [nv.sendMessage](/docs/action-nv-send-message) – allows you to send a message to a person in Sales Navigator. - [nv.syncInbox](/docs/action-nv-sync-inbox) – allows you to enable [whole-inbox monitoring](/docs/monitoring-inbox) in Sales Navigator so you can poll every incoming conversation. - [nv.syncConversation](/docs/action-nv-sync-conversation) – allows you to [load the Sales Navigator message history](/docs/working-with-conversations) with a person. - [nv.manageConversation](/docs/action-nv-manage-conversation) – allows you to manage a conversation thread in Sales Navigator by archiving or unarchiving it. - [nv.searchCompanies](/docs/action-nv-search-companies) – allows you to search for companies in Sales Navigator applying various filtering criteria. - [nv.searchPeople](/docs/action-nv-search-people) – allows you to search for people in Sales Navigator applying various filtering criteria. - [nv.openCompanyPage](/docs/action-nv-open-company-page) – allows you to open a company page in Sales Navigator to retrieve its basic information and perform additional company-related actions if needed. - [nv.retrieveCompanyEmployees](/docs/action-nv-retrieve-company-employees) – allows you to retrieve company employees from Sales Navigator and perform additional person-related actions if needed. - [nv.retrieveCompanyDMs](/docs/action-nv-retrieve-company-dms) – allows you to retrieve company decision makers from Sales Navigator and perform additional person-related actions if needed. - [nv.openPersonPage](/docs/action-nv-open-person-page) – allows you to open a person page in Sales Navigator to retrieve their basic information and perform additional person-related actions if needed. - [nv.doForCompanies](/docs/action-nv-do-for-companies) – allows you to apply the actions specified in its `then` parameter to each company provided by the parent action in Sales Navigator. - [nv.doForPeople](/docs/action-nv-do-for-people) – allows you to apply the actions specified in its `then` parameter to each person provided by the parent action in Sales Navigator. ## Overview Messaging in Linked API covers two things: **sending** messages and **reading** them. For reading, start with the inbox – it catches every new message on the account; load a person's earlier history only once, when you need it. :::surface http | Task | Tool | |---|---| | Send a message to a person | [`st.sendMessage`](/docs/action-st-send-message) with `personUrl` | | Reply into an existing conversation | [`st.sendMessage`](/docs/action-st-send-message) with `threadId` | | Archive, star, or mute a conversation | [`st.manageConversation`](/docs/action-st-manage-conversation) | | Receive new messages from everyone | [`st.syncInbox`](/docs/action-st-sync-inbox) once per account, then [`/inbox/poll`](/docs/monitoring-inbox#polling) or [`inbox.*` webhook events](/docs/webhook-event-types#inbox-events) | | Read the whole conversation with one person | [`/inbox/poll` with `personUrl`](/docs/monitoring-inbox#polling-one-person) | | Get a person's messages from before the inbox was enabled | [`st.syncConversation`](/docs/action-st-sync-conversation) once for that person | ::: :::surface node | Task | Call | |---|---| | Send a message to a person | [`linkedapi.sendMessage`](/docs/action-st-send-message) with `personUrl` | | Reply into an existing conversation | [`linkedapi.sendMessage`](/docs/action-st-send-message) with `threadId` | | Archive, star, or mute a conversation | [`linkedapi.manageConversation`](/docs/action-st-manage-conversation), or `manageConversation` on `sendMessage` | | Receive new messages from everyone | [`linkedapi.syncInbox`](/docs/action-st-sync-inbox) once per account, then [`linkedapi.pollInbox()`](/docs/monitoring-inbox#polling) or [`inbox.*` webhook events](/docs/webhook-event-types#inbox-events) | | Read the whole conversation with one person | [`linkedapi.pollInbox()` with `personUrl`](/docs/monitoring-inbox#polling-one-person) | | Get a person's messages from before the inbox was enabled | [`linkedapi.syncConversation`](/docs/action-st-sync-conversation) once for that person | ::: :::surface python | Task | Call | |---|---| | Send a message to a person | [`linkedapi.send_message`](/docs/action-st-send-message) with `person_url` | | Reply into an existing conversation | [`linkedapi.send_message`](/docs/action-st-send-message) with `thread_id` | | Archive, star, or mute a conversation | [`linkedapi.manage_conversation`](/docs/action-st-manage-conversation), or `manage_conversation` on `send_message` | | Receive new messages from everyone | [`linkedapi.sync_inbox`](/docs/action-st-sync-inbox) once per account, then [`linkedapi.poll_inbox()`](/docs/monitoring-inbox#polling) or [`inbox.*` webhook events](/docs/webhook-event-types#inbox-events) | | Read the whole conversation with one person | [`linkedapi.poll_inbox()` with `person_url`](/docs/monitoring-inbox#polling-one-person) | | Get a person's messages from before the inbox was enabled | [`linkedapi.sync_conversation`](/docs/action-st-sync-conversation) once for that person | ::: :::surface cli | Task | Command | |---|---| | Send a message to a person | [`linkedin message send `](/docs/action-st-send-message) | | Reply into an existing conversation | [`linkedin message send --thread-id `](/docs/action-st-send-message) | | Archive, star, or mute a conversation | [`linkedin message manage `](/docs/action-st-manage-conversation), or `--manage` on `message send` | | Receive new messages from everyone | [`linkedin inbox sync`](/docs/action-st-sync-inbox) once per account, then [`linkedin inbox get`](/docs/monitoring-inbox#polling) | | Read the whole conversation with one person | [`linkedin inbox get --person `](/docs/monitoring-inbox#polling-one-person) | | Get a person's messages from before the inbox was enabled | [`linkedin message get `](/docs/action-st-sync-conversation) once for that person | ::: > 💡 For **Sales Navigator**, use the same flow with the `nv.` actions: [`nv.sendMessage`](/docs/action-nv-send-message) sends InMail to people you are not connected to, and [`nv.syncInbox`](/docs/action-nv-sync-inbox), [`nv.syncConversation`](/docs/action-nv-sync-conversation) and [`nv.manageConversation`](/docs/action-nv-manage-conversation) work like their standard counterparts. The same `/inbox/poll` endpoint returns both inboxes, tagged with a `type`. ## Sending [`st.sendMessage`](/docs/action-st-send-message) sends a message from your account, the same way you would in LinkedIn. Address it by the person's `personUrl` to message a 1st-degree connection, or by a `threadId` from inbox polling to reply into a conversation you already have – no profile URL needed. To archive, star, or mute the conversation right after sending, add [`st.manageConversation`](/docs/action-st-manage-conversation) as a child action; on its own, it manages any conversation by `threadId`. See [Sending messages](/docs/sending-message) for examples. ## Reading Everything you read comes from one endpoint, `/inbox/poll`. It draws on two sources: - **Inbox.** Enable monitoring once per account with [`st.syncInbox`](/docs/action-st-sync-inbox). From that moment, a periodic sync captures every new message in every conversation, both incoming and the ones you send. Poll them with an advancing `since`, or receive them as [webhook events](/docs/webhook-event-types#inbox-events). See [Monitoring inbox](/docs/monitoring-inbox). - **History.** The inbox does not import anything from before it was enabled. When you need earlier messages with a specific person, run [`st.syncConversation`](/docs/action-st-sync-conversation) for them once. It is a one-time load, not a way to check for new messages – those keep arriving through the inbox. See [Loading conversation history](/docs/working-with-conversations). Pass `personUrl` to `/inbox/poll` to get the whole known conversation with one person from both sources, each message once. Its `threads` tell you how far back the history goes: if `historyComplete` is `false` and you need more, that is when to load the history. ### Did this lead reply? A common check after outreach: did the person answer your message? Enable inbox monitoring **before** you send – it captures only messages that arrive after it is turned on, so a reply that came earlier will not be there. Then poll [`/inbox/poll`](/docs/monitoring-inbox#polling-one-person) with their `personUrl` and `since` set a few minutes before you sent your message: ```json tab=HTTP { "personUrl": "https://www.linkedin.com/in/person1", "since": "2023-03-14T10:20:00Z" } ``` ```typescript tab=Node const { data, errors } = await linkedapi.pollInbox({ personUrl: "https://www.linkedin.com/in/person1", since: "2023-03-14T10:20:00Z" }); ``` ```python tab=Python from linkedapi import InboxPersonPollRequest result = linkedapi.poll_inbox( InboxPersonPollRequest( person_url="https://www.linkedin.com/in/person1", since="2023-03-14T10:20:00Z", ) ) ``` ```bash tab=CLI linkedin inbox get --person https://www.linkedin.com/in/person1 --since 2023-03-14T10:20:00Z ``` Message times are whole minutes, so a reply sent in the same minute as your message can carry a time earlier than the exact moment you sent yours. Pass the `type` of your message (`st` or `nv`) and take the **earliest** message with `sender` `us` in the result – the one you sent; later messages of yours, such as follow-ups, must not move this point. Treat a message whose `sender` is `them` and whose `time` is not earlier than it as the reply. If your message is not in the result yet, the periodic sync has not captured it, and the answer is not known yet – not "no reply". A `them` message in the same minute as yours is ambiguous: with minute precision it may have been sent just before yours. If you store people by public URL, see [which URL to pass](/docs/monitoring-inbox#polling-one-person). A reply appears only after the periodic sync captures it, so an empty result means there is no reply **yet** – poll again later, or subscribe to the `inbox.messageReceived` [webhook event](/docs/webhook-event-types#inbox-events) instead of polling. For a walkthrough of the whole flow from the first message to the reply, see [How to automate LinkedIn messages](/guides/how-to-automate-linkedin-messages). ## Sending messages To send a message to a person in LinkedIn, you need to include [st.sendMessage](/docs/action-st-send-message) action in your workflow. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: **Workflow:** ```json tab=HTTP { "actionType": "st.sendMessage", "personUrl": "https://www.linkedin.com/in/person1", "text": "Hi! I'd love to connect and discuss some ideas." } ``` ```typescript tab=Node const workflow = await linkedapi.sendMessage.execute({ personUrl: "https://www.linkedin.com/in/person1", text: "Hi! I'd love to connect and discuss some ideas." }); const { errors } = await linkedapi.sendMessage.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SendMessageParams workflow = linkedapi.send_message.execute( SendMessageParams( person_url="https://www.linkedin.com/in/person1", text="Hi! I'd love to connect and discuss some ideas.", ) ) result = linkedapi.send_message.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin message send \ https://www.linkedin.com/in/person1 \ "Hi! I'd love to connect and discuss some ideas." ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – the method returns no `data`; a failed action comes back in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints `{"success": true, "data": null}`, or the action's `error`: ::: ```json { "actionType": "st.sendMessage", "success": true } ``` > 💡 If you want to send a message in **Sales Navigator** (spend your Sales Navigator in-mail credits), use [`nv.sendMessage`](/docs/action-nv-send-message) action. > 💡 To reply into a known conversation without resolving the person's profile URL, pass a `threadId` (as returned by [inbox polling](/docs/monitoring-inbox)) instead of `personUrl`. See [`st.sendMessage`](/docs/action-st-send-message). > 💡 To read replies, see the [Messaging overview](/docs/messaging-overview). > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Monitoring inbox Inbox monitoring lets you track **every** incoming conversation on an account, instead of syncing people one by one. It involves 2 key steps: :::surface node In the Node SDK this is two calls: `linkedapi.syncInbox` once per account, then `linkedapi.pollInbox()` to read the messages. ::: :::surface python In the Python SDK this is two calls: `linkedapi.sync_inbox` once per account, then `linkedapi.poll_inbox()` to read the messages. ::: :::surface cli In the CLI this is two commands: `linkedin inbox sync` once per account, then `linkedin inbox get` to read the messages. ::: 1. **Enabling**. Enable inbox sync once per account. From that point on, Linked API watches your whole inbox in the background and captures new messages across all threads. 2. **Polling**. After inbox sync is enabled, poll a single endpoint to retrieve captured messages and receive new ones — **without syncing each conversation**. > 💡 **This is the default way to read messages.** The inbox captures every new message on the account, and the same endpoint returns the whole conversation with one person when you pass [`personUrl`](#polling-one-person). See the [Messaging overview](/docs/messaging-overview) for which tool fits which task. ## Enabling [Execute a workflow](/docs/executing-workflows) with the [`st.syncInbox`](/docs/action-st-sync-inbox) action (or [`nv.syncInbox`](/docs/action-nv-sync-inbox) for Sales Navigator). You only need to do this once per account. Standard and Sales Navigator inboxes can both be enabled on the same account; polled messages are tagged with a `type` so you can tell them apart. ```json tab=HTTP { "actionType": "st.syncInbox" } ``` ```typescript tab=Node const workflow = await linkedapi.syncInbox.execute({}); const { errors } = await linkedapi.syncInbox.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.sync_inbox.execute() result = linkedapi.sync_inbox.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin inbox sync # standard inbox linkedin inbox sync --nv # Sales Navigator inbox ``` :::surface http **Completion:** ```json { "actionType": "st.syncInbox", "success": true } ``` ::: Enabling only switches monitoring on: the action records the setting and returns. The messages themselves are picked up afterwards by a sync that runs periodically, so the first ones appear on the next sync rather than the moment the action completes. > **Note:** When that sync finds new messages in a thread – including messages you send yourself – it opens the profile page of the other participant in that thread to resolve their public URL – but only when Linked API does not already know it, so a person is visited once rather than on every message. Those visits are made from your own account, and such a visit is visible to the person whose profile is opened. ## Polling :::surface http Unlike enabling, you don't execute workflows to poll the inbox. Instead, send a `POST` request to the following endpoint: ```text POST https://api.linkedapi.io/inbox/poll ``` The request body is a single object. All fields are optional: ::: :::surface node,python,cli Unlike enabling, polling is a direct request, not a workflow. All parameters are optional: ::: ```json tab=HTTP { "since": "2023-01-01T00:00:00Z", "type": "st", "threadId": "2-abc123..." } ``` ```typescript tab=Node const { data, errors } = await linkedapi.pollInbox({ since: "2023-01-01T00:00:00Z", // Optional: only messages after this timestamp type: "st", // Optional: "st" | "nv"; omit for both threadId: "2-abc123..." // Optional: restrict to a single thread }); ``` ```python tab=Python from linkedapi import InboxPollRequest result = linkedapi.poll_inbox( InboxPollRequest( since="2023-01-01T00:00:00Z", # Optional: only messages after this timestamp type="st", # Optional: "st" | "nv"; omit for both thread_id="2-abc123...", # Optional: restrict to a single thread ) ) ``` ```bash tab=CLI linkedin inbox get --since 2023-01-01T00:00:00Z --type st --thread-id 2-abc123... ``` :::surface node In the Node SDK this is the `pollInbox` method; it takes the fields below and returns the `result` object below as `data`, so the messages are in `data.messages`. ::: :::surface python In the Python SDK this is the `poll_inbox` method; it takes the fields below as an `InboxPollRequest`. It returns the messages themselves: `result.data` is a list of `InboxMessage` objects with the fields below in snake_case, so iterate `result.data` directly. To read one person's conversation, pass an `InboxPersonPollRequest` instead – see [Polling one person](#polling-one-person). ::: :::surface cli In the CLI this is `linkedin inbox get`: ```bash linkedin inbox get [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--since` | string | Only retrieve messages after this ISO timestamp | | `--type` | string | Filter by inbox type: `st` or `nv` (default: both) | | `--thread-id` | string | Restrict to a single conversation thread | | `--person` | string | LinkedIn profile URL. Return the whole known conversation with this person, including history loaded by `message get`, instead of the whole inbox | > Requires inbox monitoring to be enabled once with `inbox sync`. With `--person`, history loaded by `message get` is returned even without inbox monitoring. With `--person`, the `--json` output carries `threads` next to `messages`, and each message carries `inboxMessageId`: - `inboxMessageId` – `id` of the same message in `inbox get` output without `--person`, or `null` if the inbox did not capture it. - `threads` – every thread with the person, each with `threadId` (may be `null`), `type`, `personUrn`, `historyFrom` (the earliest message Linked API has) and `historyComplete` (`true` only when the thread was read back to its first message). If `historyComplete` is `false` and you need earlier messages, run `message get ` once. See [Monitoring inbox](/docs/monitoring-inbox#polling-one-person) for the full field reference. ::: - `since` (optional) – timestamp indicating the starting point for retrieving messages. If not provided, all captured messages are returned. - `type` (optional) – enum to filter by inbox type. If omitted, both standard and Sales Navigator messages are returned: - `st` – standard inbox messages only. - `nv` – Sales Navigator inbox messages only. - `threadId` (optional) – restrict the result to a single conversation thread. - `personUrl` (optional) – return the conversation with one person instead of the whole inbox. See [Polling one person](#polling-one-person) for how this changes the response. :::surface http If your request is correct, you will receive the following response: ::: :::surface node **Result** – `data` is the `result` object below: ::: :::surface python **Result** – `result.data` is the `messages` list below, as `InboxMessage` objects with snake_case fields: ::: :::surface cli **Output** – with `--json` or into a pipe, the CLI prints this `result` as `data`, so the messages are in `data.messages`: ::: ```json { "success": true, "result": { "messages": [ { "id": "f4d92946-8821-6da6-ff4c-c36f0671292a", "type": "nv", "threadId": "2-abb123...", "personUrn": "urn:li:member:987654321", "personHashedUrl": "https://www.linkedin.com/in/ACwAA9FmKpQ4T1r7XbNz", "personPublicUrl": null, "sender": "them", "text": "Saw your post on pipeline automation. Can you share more?", "time": "2023-01-02T10:42:00Z" }, { "id": "2a1b7f25-daba-5e20-70e9-c6427bb4f660", "type": "st", "threadId": "2-abc123...", "personUrn": "urn:li:member:123456789", "personHashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPo3Gho0o", "personPublicUrl": "https://www.linkedin.com/in/person1", "sender": "us", "text": "Sure, let's talk this afternoon.", "time": "2023-01-02T10:38:00Z" }, { "id": "f4d92946-8821-6da6-ff4c-c36f0671292a", "type": "st", "threadId": "2-abc123...", "personUrn": "urn:li:member:123456789", "personHashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPo3Gho0o", "personPublicUrl": "https://www.linkedin.com/in/person1", "sender": "them", "text": "Hi! Are you available for a quick chat?", "time": "2023-01-02T10:35:00Z" } ] } } ``` - `messages` – flat array of messages across all threads, ordered newest first. - `id` – unique identifier for the message. - `type` – inbox type the message belongs to (`st` or `nv`). - `threadId` – identifier of the conversation thread. Pass it to [`st.sendMessage`](/docs/action-st-send-message) / [`nv.sendMessage`](/docs/action-nv-send-message) to reply directly into the thread. - `personUrn` – [URN](/docs/data-conventions#linkedin-urns) of the other participant, or `null` if LinkedIn does not expose it. - `personHashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the other participant. This is the form the inbox exposes; every action that takes a person URL accepts it. The standard and Sales Navigator inboxes return different hashed values for the same person, so match people on `personUrn` rather than on the `personUrl` string when you need identity across both surfaces. - `personPublicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the other participant, or `null` if it has not been resolved. It can be `null` on **any** message, including the first one in a thread. - `personUrl` – **deprecated**, replaced by `personHashedUrl`, which carries the same value. Still returned so existing integrations keep working; do not use it in new code. - `sender` – enum indicating who sent the message. Possible values: - `us` – message was sent by you (through the LinkedIn UI or the Linked API). - `them` – message was sent by the other person. - `text` – message text. - `time` – timestamp when the message was sent or received. > 💡 **Handling `personPublicUrl`.** A person is resolved by the periodic sync, and only when their thread produces a message after monitoring was enabled. The field is therefore `null` for everyone you have not heard from yet, and some people are never resolvable at all – so always handle `null` rather than assuming a message carries a public URL. Messages captured before this field existed, and people whose resolution did not succeed, stay `null` until that person sends another message or Linked API learns their public URL another way, for example through [`st.openPersonPage`](/docs/action-st-open-person-page). `personHashedUrl` is always present and is the safe input for follow-up actions. The inbox only captures messages that arrive after monitoring is enabled – unless you ask for one person with [`personUrl`](#polling-one-person), which also returns history loaded for that person. If inbox monitoring is not enabled on the account, the request still succeeds with an empty `messages` array. **Recommended flow for integrations:** 1. Leave `since` empty on the first request to retrieve everything captured so far. 2. For subsequent requests, pass the **timestamp of the most recent message** you received as `since` to fetch only newer messages. 3. Continue polling with an advancing `since`. Whenever new messages arrive, update `since` to the latest message time. `since` is inclusive and message times are whole minutes, so the messages at the latest time come back on the next request. Remember their `id`s and skip them, so you never process – or reply to – the same message twice. > 💡 For push-based delivery instead of polling, subscribe to the inbox [Webhook Events](/docs/webhook-event-types#inbox-events). They fire for accounts with inbox monitoring enabled and carry the same message fields. ## Polling one person Pass `personUrl` to read the whole known conversation with one person. Besides the messages the inbox captured, the result includes that person's earlier history if it was loaded with [`st.syncConversation`](/docs/action-st-sync-conversation) or [`nv.syncConversation`](/docs/action-nv-sync-conversation) – a message present in both appears once. Loaded history is returned even if inbox monitoring is not enabled on the account. ```json tab=HTTP { "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const { data, errors } = await linkedapi.pollInbox({ personUrl: "https://www.linkedin.com/in/person1" }); ``` ```python tab=Python from linkedapi import InboxPersonPollRequest result = linkedapi.poll_inbox( InboxPersonPollRequest( person_url="https://www.linkedin.com/in/person1", ) ) ``` ```bash tab=CLI linkedin inbox get --person https://www.linkedin.com/in/person1 ``` - `personUrl` – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. A hashed URL, standard or Sales Navigator, always matches. A public URL matches only once Linked API knows this person's public URL; if inbox messages from this person carry `personPublicUrl: null`, pass their `personHashedUrl` instead. - `since`, `type` and `threadId` (optional) – work as described above and narrow the messages further. Leave `since` empty to get the full conversation. :::surface http If your request is correct, you will receive the following response: ::: :::surface node **Result** – `data` is the `result` object below, with `messages` and `threads`: ::: :::surface python **Result** – `result.data` is an `InboxPersonPollResult` with the `messages` and `threads` below, in snake_case: ::: :::surface cli **Output** – with `--json` or into a pipe, the CLI prints this `result` as `data`, with `messages` and `threads`: ::: ```json { "success": true, "result": { "messages": [ { "id": "8c0a1f3e-5b7d-4e21-9a6c-2f4d8e1b7c90", "inboxMessageId": "8c0a1f3e-5b7d-4e21-9a6c-2f4d8e1b7c90", "type": "st", "threadId": "2-abc123...", "personUrn": "urn:li:member:123456789", "personHashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPo3Gho0o", "personPublicUrl": "https://www.linkedin.com/in/person1", "sender": "them", "text": "Thanks, the deck looks great. Let's talk next week.", "time": "2023-03-14T16:05:00Z" }, { "id": "2a1b7f25-daba-5e20-70e9-c6427bb4f660", "inboxMessageId": "e61c94d2-0b3a-4f8e-b5d7-91a2c3e4f5a6", "type": "st", "threadId": "2-abc123...", "personUrn": "urn:li:member:123456789", "personHashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPo3Gho0o", "personPublicUrl": "https://www.linkedin.com/in/person1", "sender": "us", "text": "Here is the deck we discussed.", "time": "2023-03-14T10:20:00Z" }, { "id": "f4d92946-8821-6da6-ff4c-c36f0671292a", "inboxMessageId": null, "type": "st", "threadId": "2-abc123...", "personUrn": "urn:li:member:123456789", "personHashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPo3Gho0o", "personPublicUrl": "https://www.linkedin.com/in/person1", "sender": "them", "text": "Hi! Are you available for a quick chat?", "time": "2022-11-02T10:35:00Z" } ], "threads": [ { "threadId": "2-abc123...", "type": "st", "personUrn": "urn:li:member:123456789", "historyFrom": "2022-11-02T10:35:00Z", "historyComplete": true } ] } } ``` - `messages` – messages exchanged with this person across all their threads, ordered newest first. Each message carries the same fields as in the response above, plus `inboxMessageId`: - `id` – identifier of the message in this view. It stays the same between requests, including after new messages arrive, but it can differ from the `id` the same message has in the response without `personUrl`. - `inboxMessageId` – `id` of the same message in the response without `personUrl`, which is also `messageId` in [inbox webhook events](/docs/webhook-event-types#inbox-events), or `null` if the inbox did not capture this message – for example, history from before monitoring was enabled. Use it to match this view against the messages you have already received from the inbox. - `threadId` – may be `null` in this view: a thread whose history was loaded through the person's profile keeps `threadId: null` until Linked API discovers the thread during the syncing period. After `syncUntil`, the same conversation can appear twice in `threads` – once with `threadId: null` (the loaded history, which tells you how far back it goes) and once with the inbox's `threadId`. To reply to such a person, pass `personUrl` to [`st.sendMessage`](/docs/action-st-send-message). - `threads` – every thread with this person that Linked API knows about, listed regardless of `since`. - `threadId` – identifier of the thread, or `null` as described above. - `type` – inbox type of the thread (`st` or `nv`). - `personUrn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `historyFrom` – time of the earliest message Linked API has in this thread, or `null` if it has none. - `historyComplete` – `true` only when Linked API has read this thread back to its very first message, so `historyFrom` is where the conversation starts. `false` means earlier messages may exist that Linked API has not read. If Linked API has no messages with this person, `messages` is empty; `threads` can still list a known thread with `historyFrom: null`. > 💡 If `historyComplete` is `false` and you need earlier messages, see [Loading conversation history](/docs/working-with-conversations). Messages from the inbox and from the loaded history are matched by sender, minute and text within the same thread, so two identical messages sent in the same minute stay two messages. In rare cases the two copies of one message are recorded with a different minute, and then the message appears twice. > 💡 To check whether a person answered your message, see [Did this lead reply?](/docs/messaging-overview#did-this-lead-reply) in the Messaging overview. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Loading conversation history Use this to load a person's earlier message history. To read messages, use [`/inbox/poll` with `personUrl`](/docs/monitoring-inbox#polling-one-person). :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: [Inbox monitoring](/docs/monitoring-inbox) captures every new message, but only from the moment it is enabled. When you need what was said before that – for example, before following up with a lead you wrote to months ago – load that person's history once. It involves 2 key steps: 1. **Loading**. Execute the [`st.syncConversation`](/docs/action-st-sync-conversation) action for the person. Linked API reads the conversation back to its first message and stores it. 2. **Reading**. Poll [`/inbox/poll` with `personUrl`](/docs/monitoring-inbox#polling-one-person). The loaded history comes back together with the messages the inbox captured, each message once. ## Loading Depending on the number of people whose history you need, [execute a workflow](/docs/executing-workflows) with one or multiple [`st.syncConversation`](/docs/action-st-sync-conversation) actions. Here's an example: **Workflow:** ```json tab=HTTP [ { "actionType": "st.syncConversation", "personUrl": "https://www.linkedin.com/in/person1" }, { "actionType": "st.syncConversation", "personUrl": "https://www.linkedin.com/in/person2", "days": 14 } ] ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute([ { actionType: "st.syncConversation", personUrl: "https://www.linkedin.com/in/person1" }, { actionType: "st.syncConversation", personUrl: "https://www.linkedin.com/in/person2", days: 14 } ]); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( [ { "actionType": "st.syncConversation", "personUrl": "https://www.linkedin.com/in/person1", }, { "actionType": "st.syncConversation", "personUrl": "https://www.linkedin.com/in/person2", "days": 14, }, ] ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' [ { "actionType": "st.syncConversation", "personUrl": "https://www.linkedin.com/in/person1" }, { "actionType": "st.syncConversation", "personUrl": "https://www.linkedin.com/in/person2", "days": 14 } ] EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json [ { "actionType": "st.syncConversation", "success": true, "data": { "syncUntil": "2023-01-31T00:00:00Z" } }, { "actionType": "st.syncConversation", "success": true, "data": { "syncUntil": "2023-01-15T00:00:00Z" } } ] ``` > 💡 **`days` is the syncing period, not the depth of history.** It sets how many days new messages keep being added; the action always reads the whole conversation, however old it is. `days` is 1 to 90, 30 by default, and `syncUntil` in the completion tells you when the period ends. With inbox monitoring enabled, new messages from this person keep arriving through the inbox after that moment as well. When the reading reaches the first message of a thread, that thread is reported with `historyComplete: true` in [`/inbox/poll` with `personUrl`](/docs/monitoring-inbox#polling-one-person). A very long conversation may not be read to its start within the action's time limit: the messages read are kept, and the thread stays `historyComplete: false`. Loading history does not enable inbox monitoring and does not produce [inbox webhook events](/docs/webhook-event-types#inbox-events). > 💡 For loading history in **Sales Navigator**, use [`nv.syncConversation`](/docs/action-nv-sync-conversation) action. It finds the person's Sales Navigator threads and reads each of them. ### Sync once Load a person's history once – not before every read. New messages reach you through the [inbox](/docs/monitoring-inbox), so there is no need to run the action again to check for them. If you do run [`st.syncConversation`](/docs/action-st-sync-conversation) again within 24 hours of a completed reading of the same person, it responds quickly: Linked API rereads only the messages since that reading, or responds immediately and finishes the check during the next periodic syncs. In the second case, a message the periodic sync missed can appear in polling later – usually within minutes – rather than by the time the action completes. Running the action again never shortens the period: `syncUntil` stays at the later of the current end and the new one. ## Checking connection status This page describes how to create workflows for checking connection statuses between your LinkedIn account and other people. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: > 💡 Waiting for people to accept your invitations? Do not re-check their status every day – enable [network monitoring](/docs/monitoring-network) once and receive a `connectionAccepted` event when each of them accepts. ## Single person check To check the connection status between your account and another person, you need to include [`st.checkConnectionStatus`](/docs/action-st-check-connection-status) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.checkConnectionStatus", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/person1" }); const { data, errors } = await linkedapi.checkConnectionStatus.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CheckConnectionStatusParams workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams( person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.check_connection_status.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection status https://www.linkedin.com/in/person1 ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.checkConnectionStatus", "success": true, "data": { "connectionStatus": "pending" } } ``` ## Multiple people check To check the connection statuses between your account and several people, you need to use [`st.checkConnectionStatus`](/docs/action-st-check-connection-status) multiple times. Here's an example: **Workflow:** ```json tab=HTTP [ { "actionType": "st.checkConnectionStatus", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1" }, { "actionType": "st.checkConnectionStatus", "label": "person2", "personUrl": "https://www.linkedin.com/in/person2" }, { "actionType": "st.checkConnectionStatus", "label": "person3", "personUrl": "https://www.linkedin.com/in/person3" } ] ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute([ { actionType: "st.checkConnectionStatus", label: "person1", personUrl: "https://www.linkedin.com/in/person1" }, { actionType: "st.checkConnectionStatus", label: "person2", personUrl: "https://www.linkedin.com/in/person2" }, { actionType: "st.checkConnectionStatus", label: "person3", personUrl: "https://www.linkedin.com/in/person3" } ]); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( [ { "actionType": "st.checkConnectionStatus", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", }, { "actionType": "st.checkConnectionStatus", "label": "person2", "personUrl": "https://www.linkedin.com/in/person2", }, { "actionType": "st.checkConnectionStatus", "label": "person3", "personUrl": "https://www.linkedin.com/in/person3", }, ] ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' [ { "actionType": "st.checkConnectionStatus", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1" }, { "actionType": "st.checkConnectionStatus", "label": "person2", "personUrl": "https://www.linkedin.com/in/person2" }, { "actionType": "st.checkConnectionStatus", "label": "person3", "personUrl": "https://www.linkedin.com/in/person3" } ] EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json [ { "actionType": "st.checkConnectionStatus", "label": "person1", "success": true, "data": { "connectionStatus": "connected" } }, { "actionType": "st.checkConnectionStatus", "label": "person2", "success": true, "data": { "connectionStatus": "pending" } }, { "actionType": "st.checkConnectionStatus", "label": "person3", "success": true, "data": { "connectionStatus": "notConnected" } } ] ``` > 💡 Use this approach for multiple checks as it's significantly faster than creating separate workflows for each person. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Working with invitations This page describes how to create workflows for sending and withdrawing connection requests, and retrieving, accepting, and ignoring invitations on LinkedIn. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: ## Sending connection request To send a connection request from your account to another person, you need to include [`st.sendConnectionRequest`](/docs/action-st-send-connection-request) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.sendConnectionRequest", "personUrl": "https://www.linkedin.com/in/person1", "note": "Hi, I’d like to connect with you to discuss potential collaboration.", "email": "example1@gmail.com" } ``` ```typescript tab=Node const workflow = await linkedapi.sendConnectionRequest.execute({ personUrl: "https://www.linkedin.com/in/person1", note: "Hi, I’d like to connect with you to discuss potential collaboration.", email: "example1@gmail.com" }); const { errors } = await linkedapi.sendConnectionRequest.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SendConnectionRequestParams workflow = linkedapi.send_connection_request.execute( SendConnectionRequestParams( person_url="https://www.linkedin.com/in/person1", note="Hi, I’d like to connect with you to discuss potential collaboration.", email="example1@gmail.com", ) ) result = linkedapi.send_connection_request.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection send https://www.linkedin.com/in/person1 \ --note "Hi, I’d like to connect with you to discuss potential collaboration." \ --email example1@gmail.com ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – the method returns no `data`; a failed action comes back in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints `{"success": true, "data": null}`, or the action's `error`: ::: ```json { "actionType": "st.sendConnectionRequest", "success": true } ``` ## Withdrawing connection request To withdraw the connection request sent from your account, you need to include [`st.withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.withdrawConnectionRequest", "personUrl": "https://www.linkedin.com/in/person1", "unfollow": true } ``` ```typescript tab=Node const workflow = await linkedapi.withdrawConnectionRequest.execute({ personUrl: "https://www.linkedin.com/in/person1", unfollow: true }); const { errors } = await linkedapi.withdrawConnectionRequest.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import WithdrawConnectionRequestParams workflow = linkedapi.withdraw_connection_request.execute( WithdrawConnectionRequestParams( person_url="https://www.linkedin.com/in/person1", unfollow=True, ) ) result = linkedapi.withdraw_connection_request.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection withdraw https://www.linkedin.com/in/person1 --unfollow ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – the method returns no `data`; a failed action comes back in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints `{"success": true, "data": null}`, or the action's `error`: ::: ```json { "actionType": "st.withdrawConnectionRequest", "success": true } ``` ## Retrieving pending connection requests To retrieve a list of pending connection requests sent from your account, you need to include [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.retrievePendingRequests" } ``` ```typescript tab=Node const workflow = await linkedapi.retrievePendingRequests.execute(); const { data, errors } = await linkedapi.retrievePendingRequests.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_pending_requests.execute() result = linkedapi.retrieve_pending_requests.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection pending ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.retrievePendingRequests", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company" }, { "name": "Jane Smith", "urn": "urn:li:member:234567890", "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CTO at Tech Solutions" }, { "name": "Carlos Santos", "urn": null, "publicUrl": "https://www.linkedin.com/in/carlossantos", "headline": "Product Manager at Startup Hub" } ] } ``` ## Retrieving invitations Use [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations) to read all incoming connection, company-follow, and newsletter-subscription invitations from the invitation manager. The action is root-only and does not support `then`. > 💡 To react to new connection requests as they arrive instead of polling this action on a schedule, enable [network monitoring](/docs/monitoring-network) with [`st.syncNetwork`](/docs/action-st-sync-network) and consume `connectionRequestReceived` events. **Workflow:** ```json tab=HTTP { "actionType": "st.retrieveInvitations" } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveInvitations.execute(); const { data, errors } = await linkedapi.retrieveInvitations.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_invitations.execute() result = linkedapi.retrieve_invitations.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection invitations ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.retrieveInvitations", "success": true, "data": [ { "invitationType": "connect", "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "note": "I would like to connect." }, { "invitationType": "companyFollow", "name": "Jane Smith", "urn": "urn:li:member:234567890", "publicUrl": "https://www.linkedin.com/in/janesmith", "companyUrl": "https://www.linkedin.com/company/example-company", "companyName": "Example Company" }, { "invitationType": "newsletterSubscribe", "name": "Carlos Santos", "urn": null, "publicUrl": "https://www.linkedin.com/in/carlossantos", "newsletterUrl": "https://www.linkedin.com/newsletters/example-1234567890", "newsletterName": "Example Newsletter" } ] } ``` Use the type-specific URL from each item when accepting or ignoring it: | `invitationType` | Required target | | --------------------- | ----------------------------------------------- | | `connect` | `personUrl`, using the item's `publicUrl` value | | `companyFollow` | `companyUrl` | | `newsletterSubscribe` | `newsletterUrl` | ## Accepting an invitation Use [`st.acceptInvitation`](/docs/action-st-accept-invitation) with the invitation type and exactly one matching target URL. ```json tab=HTTP { "actionType": "st.acceptInvitation", "invitationType": "connect", "personUrl": "https://www.linkedin.com/in/johndoe" } ``` ```typescript tab=Node const workflow = await linkedapi.acceptInvitation.execute({ invitationType: "connect", personUrl: "https://www.linkedin.com/in/johndoe" }); const { errors } = await linkedapi.acceptInvitation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import AcceptInvitationParams workflow = linkedapi.accept_invitation.execute( AcceptInvitationParams( invitation_type="connect", person_url="https://www.linkedin.com/in/johndoe", ) ) result = linkedapi.accept_invitation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection accept connect https://www.linkedin.com/in/johndoe ``` For a company-follow invitation: ```json tab=HTTP { "actionType": "st.acceptInvitation", "invitationType": "companyFollow", "companyUrl": "https://www.linkedin.com/company/example-company" } ``` ```typescript tab=Node const workflow = await linkedapi.acceptInvitation.execute({ invitationType: "companyFollow", companyUrl: "https://www.linkedin.com/company/example-company" }); const { errors } = await linkedapi.acceptInvitation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import AcceptInvitationParams workflow = linkedapi.accept_invitation.execute( AcceptInvitationParams( invitation_type="companyFollow", company_url="https://www.linkedin.com/company/example-company", ) ) result = linkedapi.accept_invitation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection accept \ companyFollow \ https://www.linkedin.com/company/example-company ``` For a newsletter-subscription invitation: ```json tab=HTTP { "actionType": "st.acceptInvitation", "invitationType": "newsletterSubscribe", "newsletterUrl": "https://www.linkedin.com/newsletters/example-1234567890" } ``` ```typescript tab=Node const workflow = await linkedapi.acceptInvitation.execute({ invitationType: "newsletterSubscribe", newsletterUrl: "https://www.linkedin.com/newsletters/example-1234567890" }); const { errors } = await linkedapi.acceptInvitation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import AcceptInvitationParams workflow = linkedapi.accept_invitation.execute( AcceptInvitationParams( invitation_type="newsletterSubscribe", newsletter_url="https://www.linkedin.com/newsletters/example-1234567890", ) ) result = linkedapi.accept_invitation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection accept \ newsletterSubscribe \ https://www.linkedin.com/newsletters/example-1234567890 ``` A successful completion contains `"success": true` and no data. ## Ignoring an invitation [`st.ignoreInvitation`](/docs/action-st-ignore-invitation) uses the same target contract: ```json tab=HTTP { "actionType": "st.ignoreInvitation", "invitationType": "connect", "personUrl": "https://www.linkedin.com/in/johndoe" } ``` ```typescript tab=Node const workflow = await linkedapi.ignoreInvitation.execute({ invitationType: "connect", personUrl: "https://www.linkedin.com/in/johndoe" }); const { errors } = await linkedapi.ignoreInvitation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import IgnoreInvitationParams workflow = linkedapi.ignore_invitation.execute( IgnoreInvitationParams( invitation_type="connect", person_url="https://www.linkedin.com/in/johndoe", ) ) result = linkedapi.ignore_invitation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection ignore connect https://www.linkedin.com/in/johndoe ``` Change `invitationType` and the URL field together for company-follow or newsletter-subscription invitations. A successful completion contains `"success": true` and no data. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed constraints, parameters, and results, refer to the corresponding [action documentation pages](/docs/actions-overview). ## Managing existing connections This page describes how to create workflows for retrieving and removing people from your existing LinkedIn connections. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: ## Retrieving connections To retrieve your connections, you need to include [`st.retrieveConnections`](/docs/action-st-retrieve-connections) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.retrieveConnections", "filter": { "firstName": "John", "lastName": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "currentCompanies": ["Tech Solutions", "Innovatech"], "previousCompanies": ["FutureCorp"], "schools": ["Harvard University", "MIT"] } } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveConnections.execute({ filter: { firstName: "John", lastName: "Doe", position: "CEO", locations: ["New York", "San Francisco", "London"], industries: ["Software Development", "Professional Services"], currentCompanies: ["Tech Solutions", "Innovatech"], previousCompanies: ["FutureCorp"], schools: ["Harvard University", "MIT"] } }); const { data, errors } = await linkedapi.retrieveConnections.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import RetrieveConnectionsParams workflow = linkedapi.retrieve_connections.execute( RetrieveConnectionsParams( filter={ "first_name": "John", "last_name": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "current_companies": ["Tech Solutions", "Innovatech"], "previous_companies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], }, ) ) result = linkedapi.retrieve_connections.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection list \ --first-name John \ --last-name Doe \ --position CEO \ --locations "New York,San Francisco,London" \ --industries "Software Development,Professional Services" \ --current-companies "Tech Solutions,Innovatech" \ --previous-companies FutureCorp \ --schools "Harvard University,MIT" ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.retrieveConnections", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "location": "Lisbon, Portugal", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000" }, { "name": "Jane Smith", "urn": "urn:li:member:234567890", "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CEO at Tech Solutions", "location": "San Francisco Bay Area", "avatarUrl": null }, { "name": "Carlos Santos", "urn": null, "publicUrl": "https://www.linkedin.com/in/carlossantos", "headline": "Project Manager at Startup Hub", "location": "Madrid", "avatarUrl": null } ] } ``` > 💡 If you want to perform actions on the retrieved connections, use [`st.doForPeople`](/docs/action-st-do-for-people) action. ## Removing connection To remove a person from your connections, use [`st.removeConnection`](/docs/action-st-remove-connection) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.removeConnection", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.removeConnection.execute({ personUrl: "https://www.linkedin.com/in/person1" }); const { errors } = await linkedapi.removeConnection.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import RemoveConnectionParams workflow = linkedapi.remove_connection.execute( RemoveConnectionParams( person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.remove_connection.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection remove https://www.linkedin.com/in/person1 ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – the method returns no `data`; a failed action comes back in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints `{"success": true, "data": null}`, or the action's `error`: ::: ```json { "actionType": "st.removeConnection", "success": true } ``` > 💡 If you need to remove several people from your connections, use this action multiple times within an [array workflow](/docs/building-workflows). This approach is significantly faster than creating separate workflows for each person due to API optimizations. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Monitoring network Network monitoring lets you track changes to an account's connection graph in the background, instead of repeatedly retrieving connections and pending requests and diffing them yourself. It involves 2 key steps: :::surface node In the Node SDK this is two calls: `linkedapi.syncNetwork` once per account, then `linkedapi.pollNetwork()` to read the events. ::: :::surface python In the Python SDK this is two calls: `linkedapi.sync_network` once per account, then `linkedapi.poll_network()` to read the events. ::: :::surface cli In the CLI this is two commands: `linkedin network sync` once per account, then `linkedin network events` to read the events. ::: 1. **Enabling**. Enable network sync once per account. From that point on, Linked API watches the account's network in the background and captures connection events. 2. **Polling**. After network sync is enabled, poll a single endpoint to retrieve captured events and receive new ones — **without retrieving the whole connection list each time**. > 💡 **Monitoring does not import history.** Only changes that happen **after** you enable it are captured — your existing connections and pending invitations are used as a silent baseline and never emitted. When you need the state as it is right now (a full list, a one-off audit, backfilling before monitoring was on), reach for [`st.retrieveConnections`](/docs/action-st-retrieve-connections) and [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations) instead. > 💡 **Coming soon: [signals](/docs/signals-overview).** Signals will watch an account for you in one place – its connections and invitations with the [`network` signal](/docs/signal-network), and also new posts by people, companies or keywords, comments and reactions, and profile viewers. Network monitoring keeps working as it does, separately from them. Once signals launch, if you need to keep an eye on something, a signal is the better way to do it. ## Enabling [Execute a workflow](/docs/executing-workflows) with the [`st.syncNetwork`](/docs/action-st-sync-network) action. You only need to do this once per account. ```json tab=HTTP { "actionType": "st.syncNetwork" } ``` ```typescript tab=Node const workflow = await linkedapi.syncNetwork.execute({}); const { errors } = await linkedapi.syncNetwork.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.sync_network.execute() result = linkedapi.sync_network.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin network sync ``` :::surface http **Completion:** ```json { "actionType": "st.syncNetwork", "success": true } ``` ::: ## Event types Network monitoring emits three kinds of events: - `connectionRequestReceived` – someone sent **you** a connection request. A new incoming pending invitation was observed. - `connectionAccepted` – a new connection that matches a request **you** sent. The other person accepted your outgoing invitation. - `connectionAdded` – a new connection that is **not** attributable to a request you sent. For example, you accepted someone else's incoming request, or the connection was formed outside the API. > 💡 Together, `connectionAccepted` and `connectionAdded` cover every new first-degree connection: `connectionAccepted` is the subset you initiated, `connectionAdded` is everything else. If you only care that the network grew, handle both. ## Polling :::surface http Unlike enabling, you don't execute workflows to poll the network. Instead, send a `POST` request to the following endpoint: ```text POST https://api.linkedapi.io/network/poll ``` The request body is a single object. All fields are optional: ::: :::surface node,python,cli Unlike enabling, polling is a direct request, not a workflow. All parameters are optional: ::: ```json tab=HTTP { "since": "2023-01-01T00:00:00Z", "type": "connectionAccepted" } ``` ```typescript tab=Node const { data, errors } = await linkedapi.pollNetwork({ since: "2023-01-01T00:00:00Z", // Optional: only events after this timestamp type: "connectionAccepted" // Optional: filter by a single event type }); ``` ```python tab=Python from linkedapi import NetworkPollRequest result = linkedapi.poll_network( NetworkPollRequest( since="2023-01-01T00:00:00Z", # Optional: only events after this timestamp type="connectionAccepted", # Optional: filter by a single event type ) ) ``` ```bash tab=CLI linkedin network events --since 2023-01-01T00:00:00Z --type connectionAccepted ``` :::surface node In the Node SDK this is the `pollNetwork` method; it takes the fields below and returns the `result` object below as `data`, so the events are in `data.events`. ::: :::surface python In the Python SDK this is the `poll_network` method; it takes the fields below as a `NetworkPollRequest`. It returns the events themselves: `result.data` is a list of `NetworkEvent` objects with the fields below in snake_case, so iterate `result.data` directly. ::: :::surface cli In the CLI this is `linkedin network events`: ```bash linkedin network events [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--since` | string | Only retrieve events after this ISO timestamp | | `--type` | string | Filter by event type: `connectionRequestReceived`, `connectionAccepted`, or `connectionAdded` (default: all) | > Requires network monitoring to be enabled once with `network sync`. Events are retained for 90 days. ::: - `since` (optional) – timestamp indicating the starting point for retrieving events. If not provided, all captured events are returned. - `type` (optional) – filter by a single event type. If omitted, all event types are returned: - `connectionRequestReceived` - `connectionAccepted` - `connectionAdded` :::surface http If your request is correct, you will receive the following response: ::: :::surface node **Result** – `data` is the `result` object below: ::: :::surface python **Result** – `result.data` is the `events` list below, as `NetworkEvent` objects with snake_case fields: ::: :::surface cli **Output** – with `--json` or into a pipe, the CLI prints this `result` as `data`, so the events are in `data.events`: ::: ```json { "success": true, "result": { "events": [ { "id": "f4d92946-8821-6da6-ff4c-c36f0671292a", "type": "connectionAccepted", "personUrn": "urn:li:member:123456789", "personUrl": "https://www.linkedin.com/in/person1", "detectedAt": "2023-01-02T10:35:00Z" }, { "id": "2a1b7f25-daba-5e20-70e9-c6427bb4f660", "type": "connectionRequestReceived", "personUrn": null, "personUrl": "https://www.linkedin.com/in/person2", "detectedAt": "2023-01-02T09:12:00Z" } ] } } ``` - `events` – array of connection events, ordered newest first. - `id` – unique identifier for the event. - `type` – one of `connectionRequestReceived`, `connectionAccepted`, or `connectionAdded`. - `personUrn` – [URN](/docs/data-conventions#linkedin-urns) of the other person, or `null` if LinkedIn did not expose an identifier on the row that produced the event. Events recorded before URN support was added always return `null`. - `personUrl` – LinkedIn URL of the other person. - `detectedAt` – timestamp when Linked API observed the event. **Recommended flow for integrations:** 1. Leave `since` empty on the first request to retrieve everything captured so far. 2. For subsequent requests, pass the **timestamp of the most recent event** you received as `since` to fetch only newer events. 3. Continue polling with an advancing `since`. Whenever new events arrive, update `since` to the latest `detectedAt`. > Events are retained for **90 days**. Poll (or receive webhooks) often enough to consume them within that window — older events are pruned and will no longer be returned. > 💡 For push-based delivery instead of polling, subscribe to the network [Webhook Events](/docs/webhook-event-types#network-events). They fire for accounts with network monitoring enabled and carry the same event fields. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Searching for people To search for people in LinkedIn, you need to include [`st.searchPeople`](/docs/action-st-search-people) action in your workflow. It runs the search on your own account and returns LinkedIn's people search results as structured JSON. Searching for companies instead? See [Searching for companies](/docs/searching-for-companies) – the LinkedIn company search API. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: **Workflow:** ```json tab=HTTP { "actionType": "st.searchPeople", "term": "John Doe", "limit": 2, "filter": { "firstName": "John", "lastName": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "currentCompanies": ["Tech Solutions", "Innovatech"], "previousCompanies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], "connectionDegrees": ["2nd", "3rd+"] } } ``` ```typescript tab=Node const workflow = await linkedapi.searchPeople.execute({ term: "John Doe", limit: 2, filter: { firstName: "John", lastName: "Doe", position: "CEO", locations: ["New York", "San Francisco", "London"], industries: ["Software Development", "Professional Services"], currentCompanies: ["Tech Solutions", "Innovatech"], previousCompanies: ["FutureCorp"], schools: ["Harvard University", "MIT"], connectionDegrees: ["2nd", "3rd+"] } }); const { data, errors } = await linkedapi.searchPeople.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchPeopleParams workflow = linkedapi.search_people.execute( SearchPeopleParams( term="John Doe", limit=2, filter={ "first_name": "John", "last_name": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "current_companies": ["Tech Solutions", "Innovatech"], "previous_companies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], "connection_degrees": ["2nd", "3rd+"], }, ) ) result = linkedapi.search_people.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person search \ --term "John Doe" \ --limit 2 \ --first-name John \ --last-name Doe \ --position CEO \ --locations "New York,San Francisco,London" \ --industries "Software Development,Professional Services" \ --current-companies "Tech Solutions,Innovatech" \ --previous-companies FutureCorp \ --schools "Harvard University,MIT" \ --connection-degrees 2nd,3rd+ ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.searchPeople", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "location": "London", "viewerState": { "connectionDegree": "2nd" } }, { "name": "John Doe", "urn": null, "publicUrl": "https://www.linkedin.com/in/johnyd", "headline": "Product Manager at Semsoft", "location": "New York", "viewerState": { "connectionDegree": "3rd+" } } ] } ``` > 💡 If you want to perform actions on people from search results, use [`st.doForPeople`](/docs/action-st-do-for-people) action. > 💡 If you want to search for people in **Sales Navigator**, use [`nv.searchPeople`](/docs/action-nv-search-people) action. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Retrieving person data This page describes how to create workflows for retrieving various types of LinkedIn person data, including basic information, experience, education, and more. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: ## Retrieving basic info To retrieve basic information about a person, you need to include [`st.openPersonPage`](/docs/action-st-open-person-page) action in your workflow, with `basicInfo` parameter set to `true`. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": true } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1" }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.openPersonPage", "success": true, "data": { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/person1", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "countryCode": "US", "position": "Software Engineer", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678", "followersCount": 2322, "viewerState": { "connectionDegree": "2nd" } } } ``` > 💡 If you want to retrieve basic information about a person from **Sales Navigator**, use [`nv.openPersonPage`](/docs/action-nv-open-person-page) action. ## Retrieving experience To retrieve information about a person's experience, you need to include [`st.retrievePersonExperience`](/docs/action-st-retrieve-person-experience) action as a child of [`st.openPersonPage`](/docs/action-st-open-person-page). Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": false, "then": [ { "actionType": "st.retrievePersonExperience" } ] } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveExperience: true }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_experience=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --experience ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data`, with the result of [`st.retrievePersonExperience`](/docs/action-st-retrieve-person-experience) in `data.experiences` instead of `then` and with basic information included: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data`, with the result of [`st.retrievePersonExperience`](/docs/action-st-retrieve-person-experience) in `data.experiences` instead of `then` and with basic information included: ::: ```json { "actionType": "st.openPersonPage", "success": true, "data": { "then": [ { "actionType": "st.retrievePersonExperience", "success": true, "data": [ { "position": "Software Engineer", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678", "employmentType": "fullTime", "locationType": "onSite", "description": "Developing innovative software solutions.", "duration": 24, "startTime": "2021-01-01T00:00:00Z", "endTime": null, "location": "San Francisco, USA" }, { "position": "Junior Developer", "companyName": "CodeBase Inc.", "companyHashedUrl": "https://www.linkedin.com/company/87654321", "employmentType": "internship", "locationType": "remote", "description": "Worked on front-end development tasks.", "duration": 12, "startTime": "2020-01-01T00:00:00Z", "endTime": "2020-12-31T00:00:00Z", "location": null } ] } ] } } ``` ## Retrieving education To retrieve information about a person's education, you need to include [`st.retrievePersonEducation`](/docs/action-st-retrieve-person-education) action as a child of [`st.openPersonPage`](/docs/action-st-open-person-page). Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": false, "then": [ { "actionType": "st.retrievePersonExperience" }, { "actionType": "st.retrievePersonEducation" } ] } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveExperience: true, retrieveEducation: true }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_experience=True, retrieve_education=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 \ --experience \ --education ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data`, with the result of [`st.retrievePersonExperience`](/docs/action-st-retrieve-person-experience) in `data.experiences` and of [`st.retrievePersonEducation`](/docs/action-st-retrieve-person-education) in `data.education` instead of `then` and with basic information included: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data`, with the result of [`st.retrievePersonExperience`](/docs/action-st-retrieve-person-experience) in `data.experiences` and of [`st.retrievePersonEducation`](/docs/action-st-retrieve-person-education) in `data.education` instead of `then` and with basic information included: ::: ```json { "actionType": "st.openPersonPage", "success": true, "data": { "then": [ { "actionType": "st.retrievePersonExperience", "success": true, "data": [ ... ] }, { "actionType": "st.retrievePersonEducation", "success": true, "data": [ { "schoolName": "Harvard University", "schoolHashedUrl": "https://www.linkedin.com/company/12345678", "details": "Master of Science in Computer Science, Artificial Intelligence" }, { "schoolName": "MIT", "schoolHashedUrl": "https://www.linkedin.com/company/87654321", "details": "Bachelor of Science in Electrical Engineering and Computer Science" } ] } ] } } ``` ## Retrieving other data To retrieve other types of person data, you can use the following actions as children of [`st.openPersonPage`](/docs/action-st-open-person-page) (just as in the examples above): - [`st.retrievePersonSkills`](/docs/action-st-retrieve-person-skills) – allows you to retrieve a person's skills. - [`st.retrievePersonLanguages`](/docs/action-st-retrieve-person-languages) – allows you to retrieve a person's languages. - [`st.retrievePersonPosts`](/docs/action-st-retrieve-person-posts) – allows you to retrieve posts published by a person and perform additional post-related actions if needed. - [`st.retrievePersonComments`](/docs/action-st-retrieve-person-comments) – allows you to retrieve comments left by a person. - [`st.retrievePersonReactions`](/docs/action-st-retrieve-person-reactions) – allows you to retrieve reactions made by a person. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Visiting person page To visit a person's LinkedIn page so that they see your visit in their profile views, include [`st.openPersonPage`](/docs/action-st-open-person-page) action in your workflow. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": false } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openPersonPage", personUrl: "https://www.linkedin.com/in/person1", basicInfo: false }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": False, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": false } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openPersonPage", "success": true } ``` > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Endorsing skills This page describes how to create workflows for endorsing skills on the profiles of your 1st-degree connections. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: > LinkedIn lets you endorse skills only of your **1st-degree connections**. Endorsing never removes an endorsement: a skill you have already endorsed is left as it is and reported as a success. ## Endorsing a skill To endorse one skill by its name, you need to include [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill) action in your workflow. It can also start the workflow on its own, with `personUrl` and `skillName`. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": false, "then": { "actionType": "st.endorsePersonSkill", "skillName": "Project Management" } } ``` ```typescript tab=Node const workflow = await linkedapi.endorsePersonSkill.execute({ personUrl: "https://www.linkedin.com/in/person1", skillName: "Project Management" }); const { errors } = await linkedapi.endorsePersonSkill.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import EndorsePersonSkillParams workflow = linkedapi.endorse_person_skill.execute( EndorsePersonSkillParams( person_url="https://www.linkedin.com/in/person1", skill_name="Project Management", ) ) result = linkedapi.endorse_person_skill.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person endorse https://www.linkedin.com/in/person1 \ --skill "Project Management" ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – the method returns no `data`; a failed action comes back in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints `{"success": true, "data": null}`, or the action's `error`: ::: ```json { "actionType": "st.openPersonPage", "success": true, "data": { "then": { "actionType": "st.endorsePersonSkill", "success": true } } } ``` ## Endorsing skills that match patterns To endorse every skill whose name matches one or more patterns, you need to include [`st.doForSkills`](/docs/action-st-do-for-skills) with [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill) as a child of [`st.retrievePersonSkills`](/docs/action-st-retrieve-person-skills). Omit `skillNamePatterns` to endorse every skill. Skill names are in the profile owner's language, so write patterns in that language. > Patterns use [RE2](https://github.com/google/re2/wiki/Syntax) syntax, the dialect of Go `regexp` and Google Sheets `REGEXMATCH`. Ordinary patterns read like JavaScript regular expressions, but lookaround (`(?=…)`, `(?!…)`) and backreferences (`\1`) are not supported. Matching is case-insensitive and searches anywhere in the name; anchor a pattern with `^…$` to match the whole name. See [`st.doForSkills`](/docs/action-st-do-for-skills) for the full rules. :::surface http,node,python Patterns are JSON strings – in the SDKs, ordinary string literals – so each backslash is written twice: `"project\\s*manage"` is the regular expression `project\s*manage`. ::: :::surface cli In the CLI, put each `--pattern` in single quotes so the shell passes `\`, `*`, `|`, `$` and parentheses through unchanged, and write a backslash once: `--pattern 'project\s*manage'`. Give exactly one of `--skill`, `--pattern` (repeatable, up to 20) and `--all` – `--all` endorses every skill. None, or more than one, is rejected with exit code 5 and nothing is endorsed, so the command never endorses every skill by accident. In a terminal, `--pattern` and `--all` print one line per selected skill: `Endorsed: ` or `Not endorsed: - `. With `--json`, or into a pipe, they print the selected skills as `data`, each with `skillName`, `success` and `error`. ::: Here's an example that endorses only skills related to business and project management: **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": false, "then": { "actionType": "st.retrievePersonSkills", "then": { "actionType": "st.doForSkills", "skillNamePatterns": ["business", "project\\s*manage", "^(agile|scrum)$"], "then": { "actionType": "st.endorsePersonSkill" } } } } ``` ```typescript tab=Node const workflow = await linkedapi.endorsePersonSkills.execute({ personUrl: "https://www.linkedin.com/in/person1", skillNamePatterns: ["business", "project\\s*manage", "^(agile|scrum)$"] }); const { data, errors } = await linkedapi.endorsePersonSkills.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import EndorsePersonSkillsParams workflow = linkedapi.endorse_person_skills.execute( EndorsePersonSkillsParams( person_url="https://www.linkedin.com/in/person1", skill_name_patterns=["business", "project\\s*manage", "^(agile|scrum)$"], ) ) result = linkedapi.endorse_person_skills.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person endorse https://www.linkedin.com/in/person1 \ --pattern business \ --pattern 'project\s*manage' \ --pattern '^(agile|scrum)$' ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns in `data` one item per selected skill, with `skillName`, `success` and `error`. Check every item: `errors` only covers failures to open the profile or read its skills. The workflow behind it completes like this: ::: :::surface cli **Completion** – the CLI prints one item per selected skill, with `skillName`, `success` and `error`, as `data`. Check every item. The workflow behind it completes like this: ::: ```json { "actionType": "st.openPersonPage", "success": true, "data": { "then": { "actionType": "st.retrievePersonSkills", "success": true, "data": [ { "name": "Business Strategy", "then": [ { "actionType": "st.endorsePersonSkill", "success": true } ] }, { "name": "Python" }, { "name": "Project Management", "then": [ { "actionType": "st.endorsePersonSkill", "success": true } ] } ] } } } ``` Every skill of the person is listed; only the selected ones carry a `then` array with the endorsement result. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Searching for companies – LinkedIn company search API To search for companies in LinkedIn, you need to include [`st.searchCompanies`](/docs/action-st-search-companies) action in your workflow. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: **Workflow:** ```json tab=HTTP { "actionType": "st.searchCompanies", "term": "Tech Inc", "limit": 2, "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"] } } ``` ```typescript tab=Node const workflow = await linkedapi.searchCompanies.execute({ term: "Tech Inc", limit: 2, filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York"], industries: ["Software Development", "Robotics Engineering"] } }); const { data, errors } = await linkedapi.searchCompanies.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchCompaniesParams workflow = linkedapi.search_companies.execute( SearchCompaniesParams( term="Tech Inc", limit=2, filter={ "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], }, ) ) result = linkedapi.search_companies.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin company search \ --term "Tech Inc" \ --limit 2 \ --sizes 51-200,201-500 \ --locations "San Francisco,New York" \ --industries "Software Development,Robotics Engineering" ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.searchCompanies", "success": true, "data": [ { "name": "TechCorp", "urn": "urn:li:organization:1234567", "publicUrl": "https://www.linkedin.com/company/techcorp", "industry": "Information Technology", "location": "California" }, { "name": "Techical Life", "urn": null, "publicUrl": "https://www.linkedin.com/company/techlife", "industry": "Software Development", "location": "Mountain View" } ] } ``` > 💡 If you want to perform actions on companies from search results, use [`st.doForCompanies`](/docs/action-st-do-for-companies) action. > 💡 If you want to search for companies in **Sales Navigator**, use [`nv.searchCompanies`](/docs/action-nv-search-companies) action. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Retrieving company data This page describes how to create workflows for retrieving various types of LinkedIn company data, including basic information, posts, employees, and decision-makers. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: ## Retrieving basic info To retrieve basic information about a company, you need to include [`st.openСompanyPage`](/docs/action-st-open-company-page) action in your workflow, with `basicInfo` parameter set to `true`. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": true } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1" }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin company fetch https://www.linkedin.com/company/company1 ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { "name": "TechCorp", "urn": "urn:li:organization:1234567", "publicUrl": "https://www.linkedin.com/company/company1", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "Cupertino, California", "headquarters": "US", "industry": "Information Technology", "specialties": "Cloud Computing, AI, Software Development", "website": "https://techcorp.com", "employeesCount": 500, "yearFounded": 2019, "jobsCount": 12 } } ``` > 💡 If you want to retrieve basic information about a company from **Sales Navigator**, use [`nv.openCompanyPage`](/docs/action-nv-open-company-page) action. ## Retrieving employees To retrieve company employees, you need to include [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) action as a child of [`st.openCompanyPage`](/docs/action-st-open-company-page). Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": false, "then": [ { "actionType": "st.retrieveCompanyEmployees", "limit": 100, "filter": { "position": "Manager", "locations": ["New York", "San Francisco", "London"], "schools": ["Harvard University", "MIT"] } } ] } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1", retrieveEmployees: true, employeesRetrievalConfig: { limit: 100, filter: { position: "Manager", locations: ["New York", "San Francisco", "London"], schools: ["Harvard University", "MIT"] } } }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", retrieve_employees=True, employees_retrieval_config={ "limit": 100, "filter": { "position": "Manager", "locations": ["New York", "San Francisco", "London"], "schools": ["Harvard University", "MIT"], }, }, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin company fetch \ https://www.linkedin.com/company/company1 \ --employees \ --employees-limit 100 \ --employees-position Manager \ --employees-locations "New York,San Francisco,London" \ --employees-schools "Harvard University,MIT" ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data`, with the result of [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) in `data.employees` instead of `then` and with basic information included: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data`, with the result of [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) in `data.employees` instead of `then` and with basic information included: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { "then": [ { "actionType": "st.retrieveCompanyEmployees", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Manager at TechCorp", "location": "New York, USA", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "viewerState": { "connectionDegree": "2nd" } }, { "name": "Jane Smith", "urn": null, "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "avatarUrl": null, "viewerState": { "connectionDegree": "3rd+" } } ] } ] } } ``` > 💡 If you want to perform actions on the retrieved employees, use [`st.doForPeople`](/docs/action-st-do-for-people) action. > 💡 If you want to retrieve company employees from **Sales Navigator**, use the following actions: [`nv.openCompanyPage`](/docs/action-nv-open-company-page), [`nv.retrieveCompanyEmployees`](/docs/action-nv-retrieve-company-employees). ## Retrieving decision makers To retrieve company employees, you need to include [`st.retrieveCompanyDMs`](/docs/action-st-retrieve-company-dms) action as a child of [`st.openCompanyPage`](/docs/action-st-open-company-page). Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": false, "then": [ { "actionType": "st.retrieveCompanyEmployees", "limit": 100, "filter": { "position": "Manager", "locations": ["New York", "San Francisco", "London"], "schools": ["Harvard University", "MIT"] } }, { "actionType": "st.retrieveCompanyDMs", "limit": 3 } ] } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1", retrieveEmployees: true, employeesRetrievalConfig: { limit: 100, filter: { position: "Manager", locations: ["New York", "San Francisco", "London"], schools: ["Harvard University", "MIT"] } }, retrieveDMs: true, dmsRetrievalConfig: { limit: 3 } }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", retrieve_employees=True, employees_retrieval_config={ "limit": 100, "filter": { "position": "Manager", "locations": ["New York", "San Francisco", "London"], "schools": ["Harvard University", "MIT"], }, }, retrieve_dms=True, dms_retrieval_config={"limit": 3}, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin company fetch \ https://www.linkedin.com/company/company1 \ --employees \ --dms \ --employees-limit 100 \ --employees-position Manager \ --employees-locations "New York,San Francisco,London" \ --employees-schools "Harvard University,MIT" \ --dms-limit 3 ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data`, with the result of [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) in `data.employees` and of [`st.retrieveCompanyDMs`](/docs/action-st-retrieve-company-dms) in `data.dms` instead of `then` and with basic information included: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data`, with the result of [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) in `data.employees` and of [`st.retrieveCompanyDMs`](/docs/action-st-retrieve-company-dms) in `data.dms` instead of `then` and with basic information included: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { "then": [ { "actionType": "st.retrieveCompanyEmployees", "success": true, "data": [ ... ] }, { "actionType": "st.retrieveCompanyDMs", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder of TechCorp", "location": "New York, USA", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "countryCode": "US", "viewerState": { "connectionDegree": "2nd" } }, { "name": "Jane Smith", "urn": null, "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CEO at TechCorp", "location": "San Francisco, USA", "avatarUrl": null, "countryCode": "US", "viewerState": { "connectionDegree": "3rd+" } } ] } ] } } ``` > 💡 If you want to perform actions on the retrieved decision makers, use [`st.doForPeople`](/docs/action-st-do-for-people) action. > 💡 If you want to retrieve decision makers from **Sales Navigator**, use the following actions: [`nv.openCompanyPage`](/docs/action-nv-open-company-page), [`nv.retrieveCompanyDMs`](/docs/action-nv-retrieve-company-dms). ## Retrieving posts To retrieve posts published by a company, you need to include [`st.retrieveCompanyPosts`](/docs/action-st-retrieve-company-posts) action as a child of [`st.openCompanyPage`](/docs/action-st-open-company-page). Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": false, "then": [ { "actionType": "st.retrieveCompanyEmployees", "limit": 100, "filter": { "position": "Manager", "locations": ["New York", "San Francisco", "London"], "schools": ["Harvard University", "MIT"] } }, { "actionType": "st.retrieveCompanyDMs", "limit": 3 }, { "actionType": "st.retrieveCompanyPosts", "limit": 5, "since": "2025-01-01T00:00:00Z" } ] } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1", retrieveEmployees: true, employeesRetrievalConfig: { limit: 100, filter: { position: "Manager", locations: ["New York", "San Francisco", "London"], schools: ["Harvard University", "MIT"] } }, retrieveDMs: true, dmsRetrievalConfig: { limit: 3 }, retrievePosts: true, postsRetrievalConfig: { limit: 5, since: "2025-01-01T00:00:00Z" } }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", retrieve_employees=True, employees_retrieval_config={ "limit": 100, "filter": { "position": "Manager", "locations": ["New York", "San Francisco", "London"], "schools": ["Harvard University", "MIT"], }, }, retrieve_dms=True, dms_retrieval_config={"limit": 3}, retrieve_posts=True, posts_retrieval_config={"limit": 5, "since": "2025-01-01T00:00:00Z"}, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin company fetch \ https://www.linkedin.com/company/company1 \ --employees \ --dms \ --posts \ --employees-limit 100 \ --employees-position Manager \ --employees-locations "New York,San Francisco,London" \ --employees-schools "Harvard University,MIT" \ --dms-limit 3 \ --posts-limit 5 \ --posts-since 2025-01-01T00:00:00Z ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data`, with the result of [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) in `data.employees`, of [`st.retrieveCompanyDMs`](/docs/action-st-retrieve-company-dms) in `data.dms` and of [`st.retrieveCompanyPosts`](/docs/action-st-retrieve-company-posts) in `data.posts` instead of `then` and with basic information included: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data`, with the result of [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) in `data.employees`, of [`st.retrieveCompanyDMs`](/docs/action-st-retrieve-company-dms) in `data.dms` and of [`st.retrieveCompanyPosts`](/docs/action-st-retrieve-company-posts) in `data.posts` instead of `then` and with basic information included: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { "then": [ { "actionType": "st.retrieveCompanyEmployees", "success": true, "data": [ ... ] }, { "actionType": "st.retrieveCompanyDMs", "success": true, "data": [ ... ] }, { "actionType": "st.retrieveCompanyPosts", "success": true, "data": [ { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", "companyUrl": "https://www.linkedin.com/company/example-company" }, "reposter": null, "repostText": null, "text": "Check out our latest product launch!", "hashtags": ["product", "launch"], "mentions": [], "externalLinks": ["https://example.com/launch"], "images": [ "https://static.linkedin.com/image1.jpg", "https://static.linkedin.com/image2.jpg" ], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 2, "viewerState": { "reaction": null, "isOwn": false } }, { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2023-01-01T09:15:00Z", "type": "repost", "author": { "type": "person", "name": "Example Author", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/example-author", "headline": null, "viewerState": { "connectionDegree": "2nd" } }, "reposter": { "type": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", "companyUrl": "https://www.linkedin.com/company/example-company" }, "repostText": "Thank you to everyone who joined our webinar!", "text": "Original post content about the webinar", "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": true, "videoThumbnail": "https://media.licdn.com/dms/image/video-cover.jpg", "hasPoll": false, "reactionsCount": 6, "commentsCount": 0, "repostsCount": 1, "viewerState": { "reaction": null, "isOwn": false } } ] } ] } } ``` > 💡 If you want to perform actions on the retrieved posts, use [`st.doForPosts`](/docs/action-st-do-for-posts) action. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Searching for posts To search for posts in LinkedIn, you need to include [`st.searchPosts`](/docs/action-st-search-posts) action in your workflow. It runs LinkedIn's content search on your own account and returns the results as structured JSON. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: **Workflow:** ```json tab=HTTP { "actionType": "st.searchPosts", "term": "climate tech", "limit": 2, "filter": { "sort": "latest", "datePosted": "pastWeek", "contentType": "images", "postedBy": ["peopleYouFollow"] } } ``` ```typescript tab=Node const workflow = await linkedapi.searchPosts.execute({ term: "climate tech", limit: 2, filter: { sort: "latest", datePosted: "pastWeek", contentType: "images", postedBy: ["peopleYouFollow"] } }); const { data, errors } = await linkedapi.searchPosts.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchPostsParams workflow = linkedapi.search_posts.execute( SearchPostsParams( term="climate tech", limit=2, filter={ "sort": "latest", "date_posted": "pastWeek", "content_type": "images", "posted_by": ["peopleYouFollow"], }, ) ) result = linkedapi.search_posts.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post search \ --term "climate tech" \ --limit 2 \ --sort latest \ --date-posted pastWeek \ --content-type images \ --posted-by peopleYouFollow ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.searchPosts", "success": true, "data": [ { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "profileUrl": "https://www.linkedin.com/in/example-person", "headline": "Product Marketing Lead", "viewerState": { "connectionDegree": "2nd" } }, "reposter": null, "text": "Check out our latest product launch!", "repostText": null, "hashtags": ["product", "launch"], "mentions": ["https://www.linkedin.com/company/example-company"], "externalLinks": ["https://example.com/launch"], "images": ["https://static.linkedin.com/image1.jpg"], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 12, "viewerState": { "reaction": "like", "isOwn": false } }, { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2023-01-01T09:15:00Z", "type": "repost", "author": { "type": "company", "name": "Example Company", "companyUrl": "https://www.linkedin.com/company/example-company" }, "reposter": { "type": "person", "name": "Example Reposter", "profileUrl": "https://www.linkedin.com/in/example-reposter", "headline": null }, "text": "Original post content about the webinar.", "repostText": "A useful summary for anyone planning a launch.", "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": true, "videoThumbnail": "https://media.licdn.com/dms/image/video-cover.jpg", "hasPoll": false, "reactionsCount": 6, "commentsCount": 0, "repostsCount": 1, "viewerState": { "reaction": null, "isOwn": false } } ] } ``` ## Narrowing the search by a person or a company `fromMembers` and `fromCompanies` keep only posts published by the people or companies you list, `mentioningMembers` and `mentioningCompanies` keep only posts that mention them, and `authorCompanies` keeps only posts whose author works at them: **Workflow:** ```json tab=HTTP { "actionType": "st.searchPosts", "term": "climate tech", "limit": 5, "filter": { "fromMembers": [ "Bill Gates", { "name": "Example Person", "id": "urn:li:member:123456789" }, { "name": "Another Person", "id": "https://www.linkedin.com/in/another-person" } ], "mentioningCompanies": [ "Microsoft", { "name": "Example Company", "id": "https://www.linkedin.com/company/example-company" } ], "authorIndustries": ["Software Development"] } } ``` ```typescript tab=Node const workflow = await linkedapi.searchPosts.execute({ term: "climate tech", limit: 5, filter: { fromMembers: [ "Bill Gates", { name: "Example Person", id: "urn:li:member:123456789" }, { name: "Another Person", id: "https://www.linkedin.com/in/another-person" } ], mentioningCompanies: [ "Microsoft", { name: "Example Company", id: "https://www.linkedin.com/company/example-company" } ], authorIndustries: ["Software Development"] } }); const { data, errors } = await linkedapi.searchPosts.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchPostsParams workflow = linkedapi.search_posts.execute( SearchPostsParams( term="climate tech", limit=5, filter={ "from_members": [ "Bill Gates", {"name": "Example Person", "id": "urn:li:member:123456789"}, { "name": "Another Person", "id": "https://www.linkedin.com/in/another-person", }, ], "mentioning_companies": [ "Microsoft", { "name": "Example Company", "id": "https://www.linkedin.com/company/example-company", }, ], "author_industries": ["Software Development"], }, ) ) result = linkedapi.search_posts.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post search \ --term "climate tech" \ --limit 5 \ --author-industries "Software Development" \ --from-members "Bill Gates,Example Person:urn:li:member:123456789,Another Person:https://www.linkedin.com/in/another-person" \ --mentioning-companies "Microsoft,Example Company:https://www.linkedin.com/company/example-company" ``` Each entry is a name, or a name with an `id` – any identifier of the person or company: its URN or its LinkedIn URL, public or hashed. A name alone can match a namesake; with `id` the filter applies to exactly that person or company, and when it cannot, the action fails with [`filterNotApplied`](/docs/action-st-search-posts) rather than filtering by somebody else. ## How many posts come back `limit` defaults to **10** and accepts up to **100**, or up to **20** when child actions are attached. A value above the applicable maximum is rejected when the workflow is submitted. A search may return fewer posts than `limit`, because how many results come back depends on what LinkedIn loads for that account on that search. LinkedIn widens a narrow query on its own and returns loosely related posts, so a non-empty result does not mean your `term` matched. Check the returned posts against your own criteria when an exact match matters. > 💡 If you want to perform actions on posts from search results, use [`st.doForPosts`](/docs/action-st-do-for-posts) action. Search results carry no `urn` on their `author` and `reposter` – nest [`st.openPost`](/docs/action-st-open-post) under it when you need one. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Publishing posts This page describes how to create workflows for publishing posts, reposting, and mentioning people and companies in post text. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: ## Creating a post To publish a post, you need to include [`st.createPost`](/docs/action-st-create-post) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.createPost", "text": "We shipped our new onboarding flow this week." } ``` ```typescript tab=Node const workflow = await linkedapi.createPost.execute({ text: "We shipped our new onboarding flow this week." }); const { data, errors } = await linkedapi.createPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CreatePostParams workflow = linkedapi.create_post.execute( CreatePostParams( text="We shipped our new onboarding flow this week.", ) ) result = linkedapi.create_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post create "We shipped our new onboarding flow this week." ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.createPost", "success": true, "data": { "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "postUrn": "urn:li:activity:1234567890123456789" } } ``` ## Reposting To repost an existing post, you need to include [`st.createRepost`](/docs/action-st-create-repost) action in your workflow. Without `text` the post is reposted as is; with `text` your commentary is published above it. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.createRepost", "postUrl": "https://www.linkedin.com/posts/post1", "text": "Worth reading, especially the part on onboarding." } ``` ```typescript tab=Node const workflow = await linkedapi.createRepost.execute({ postUrl: "https://www.linkedin.com/posts/post1", text: "Worth reading, especially the part on onboarding." }); const { data, errors } = await linkedapi.createRepost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CreateRepostParams workflow = linkedapi.create_repost.execute( CreateRepostParams( post_url="https://www.linkedin.com/posts/post1", text="Worth reading, especially the part on onboarding.", ) ) result = linkedapi.create_repost.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post repost https://www.linkedin.com/posts/post1 \ --text "Worth reading, especially the part on onboarding." ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.createRepost", "success": true, "data": { "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "postUrn": "urn:li:activity:2345678901234567890" } } ``` The returned identifiers belong to the repost itself, which is a post of your own account. They are what you pass to further post actions if you want to engage with the repost rather than with the original. > `postUrl` and `postUrn` are `null` when the repost was published but LinkedIn did not return its address. Do not retry: the repost is already live. > `alreadyReposted` is returned only when LinkedIn refuses an instant repost (one without `text`). LinkedIn does not always refuse a repeat repost: a repost of your own post, or a repost with commentary, can be published again. Keep track of what you have already reposted instead of relying on this error. Reposts count toward the same posting limit as [`st.createPost`](/docs/action-st-create-post). ## Mentions Both [`st.createPost`](/docs/action-st-create-post) and [`st.createRepost`](/docs/action-st-create-repost) accept a `mentions` array. Each item binds a placeholder in the text to one person or company: write `@[key]` where the mention should appear, and describe that `key` in `mentions`. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.createPost", "text": "Huge thanks to @[author] and everyone at @[company] for the launch.", "mentions": [ { "key": "author", "name": "Example Person", "personHashedUrl": "https://www.linkedin.com/in/ACwAAAxxxxxxxxx" }, { "key": "company", "name": "Example Company", "urn": "urn:li:organization:1234567" } ] } ``` ```typescript tab=Node const workflow = await linkedapi.createPost.execute({ text: "Huge thanks to @[author] and everyone at @[company] for the launch.", mentions: [ { key: "author", name: "Example Person", personHashedUrl: "https://www.linkedin.com/in/ACwAAAxxxxxxxxx" }, { key: "company", name: "Example Company", urn: "urn:li:organization:1234567" } ] }); const { data, errors } = await linkedapi.createPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CreatePostParams workflow = linkedapi.create_post.execute( CreatePostParams( text="Huge thanks to @[author] and everyone at @[company] for the launch.", mentions=[ { "key": "author", "name": "Example Person", "person_hashed_url": "https://www.linkedin.com/in/ACwAAAxxxxxxxxx", }, { "key": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", }, ], ) ) result = linkedapi.create_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post create \ "Huge thanks to @[author] and everyone at @[company] for the launch." \ --mention "author:Example Person:https://www.linkedin.com/in/ACwAAAxxxxxxxxx" \ --mention "company:Example Company:urn:li:organization:1234567" ``` Each item accepts: - `key` (required) – the placeholder name used in the text as `@[key]`. From 1 to 30 characters: letters, digits, `_` or `-`. - `name` (required) – the name to find the person or company by, from 1 to 100 characters. - `urn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the entity: `urn:li:member:` for a person, `urn:li:organization:` for a company. - `personHashedUrl` (optional) – hashed LinkedIn profile URL of the person. - `companyHashedUrl` (optional) – hashed LinkedIn company page URL. Provide at most one of `urn`, `personHashedUrl` and `companyHashedUrl`. The workflow is rejected before anything is published when: - more than **20** mentions are supplied; - the text refers to a `@[key]` that is not among the mentions; - a mention is supplied whose `@[key]` does not appear in the text; - two mentions share the same `key`. > `name` is required even when you provide an identifier, because LinkedIn resolves a mention through its own name suggestions. The identifier decides which of the offered namesakes is taken; without one, the first suggestion LinkedIn ranks is used, which is not guaranteed to be the person or company you meant. If none of the suggestions match, the action fails with `mentionNotResolved` and nothing is published. > The published mention shows LinkedIn's own name for the entity, which may differ from the `name` you sent. ## Addressing a post by URN Post actions accept `postUrn` instead of `postUrl`, so identifiers returned by one action can be passed straight into the next without building a URL. This works for [`st.openPost`](/docs/action-st-open-post), [`st.createRepost`](/docs/action-st-create-repost), [`st.reactToPost`](/docs/action-st-react-to-post), [`st.commentOnPost`](/docs/action-st-comment-on-post), [`st.retrievePostComments`](/docs/action-st-retrieve-post-comments) and [`st.retrievePostReactions`](/docs/action-st-retrieve-post-reactions). Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.reactToPost", "postUrn": "urn:li:activity:1234567890123456789", "type": "like" } ``` ```typescript tab=Node const workflow = await linkedapi.reactToPost.execute({ postUrn: "urn:li:activity:1234567890123456789", type: "like" }); const { errors } = await linkedapi.reactToPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ReactToPostParams workflow = linkedapi.react_to_post.execute( ReactToPostParams( post_urn="urn:li:activity:1234567890123456789", type="like", ) ) result = linkedapi.react_to_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post react urn:li:activity:1234567890123456789 --type like ``` Provide one of the two. If you provide both, the URL must contain that same URN, otherwise the workflow is rejected. ## Publishing and engaging in one workflow [`st.createPost`](/docs/action-st-create-post) accepts [`st.openPost`](/docs/action-st-open-post) as a child action, which lets you act on the post you have just published. Here's an example that publishes a post and leaves the first comment under it: **Workflow:** ```json tab=HTTP { "actionType": "st.createPost", "text": "We shipped our new onboarding flow this week.", "then": { "actionType": "st.openPost", "basicInfo": false, "then": { "actionType": "st.commentOnPost", "text": "Details and screenshots in the comments below." } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.createPost", text: "We shipped our new onboarding flow this week.", then: { actionType: "st.openPost", basicInfo: false, then: { actionType: "st.commentOnPost", text: "Details and screenshots in the comments below." } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.createPost", "text": "We shipped our new onboarding flow this week.", "then": { "actionType": "st.openPost", "basicInfo": False, "then": { "actionType": "st.commentOnPost", "text": "Details and screenshots in the comments below.", }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.createPost", "text": "We shipped our new onboarding flow this week.", "then": { "actionType": "st.openPost", "basicInfo": false, "then": { "actionType": "st.commentOnPost", "text": "Details and screenshots in the comments below." } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.createPost", "success": true, "data": { "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "postUrn": "urn:li:activity:1234567890123456789", "then": { "actionType": "st.openPost", "success": true, "data": { "then": { "actionType": "st.commentOnPost", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,1122334455)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(1122334455%2Curn%3Ali%3Aactivity%3A1234567890123456789)" } } } } } } ``` > `postUrl` and `postUrn` are `null` when the post was published but LinkedIn did not return its address. In that case the child actions in `then` did not run, so the comment above would not be left. Do not retry: the post is already live. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Reacting and commenting This page describes how to create workflows for reacting and commenting on LinkedIn posts. :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: ## Reacting to post To react to a post, you need to include [`st.reactToPost`](/docs/action-st-react-to-post) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.reactToPost", "postUrl": "https://www.linkedin.com/posts/post1", "type": "like" } ``` ```typescript tab=Node const workflow = await linkedapi.reactToPost.execute({ postUrl: "https://www.linkedin.com/posts/post1", type: "like" }); const { errors } = await linkedapi.reactToPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ReactToPostParams workflow = linkedapi.react_to_post.execute( ReactToPostParams( post_url="https://www.linkedin.com/posts/post1", type="like", ) ) result = linkedapi.react_to_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post react https://www.linkedin.com/posts/post1 --type like ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – the method returns no `data`; a failed action comes back in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints `{"success": true, "data": null}`, or the action's `error`: ::: ```json { "actionType": "st.reactToPost", "success": true } ``` ## Commenting on post To comment on a post, you need to include [`st.commentOnPost`](/docs/action-st-comment-on-post) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.commentOnPost", "postUrl": "https://www.linkedin.com/posts/post1", "text": "I completely agree with your point." } ``` ```typescript tab=Node const workflow = await linkedapi.commentOnPost.execute({ postUrl: "https://www.linkedin.com/posts/post1", text: "I completely agree with your point." }); const { data, errors } = await linkedapi.commentOnPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CommentOnPostParams workflow = linkedapi.comment_on_post.execute( CommentOnPostParams( post_url="https://www.linkedin.com/posts/post1", text="I completely agree with your point.", ) ) result = linkedapi.comment_on_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post comment \ https://www.linkedin.com/posts/post1 \ "I completely agree with your point." ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.commentOnPost", "success": true } ``` ## Reacting + commenting To react and comment on the same post, you need to include [`st.reactToPost`](/docs/action-st-react-to-post) and [`st.commentOnPost`](/docs/action-st-comment-on-post) as child actions of [`st.openPost`](/docs/action-st-open-post) action. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.openPost", "postUrl": "https://www.linkedin.com/posts/post1", "basicInfo": false, "then": [ { "actionType": "st.reactToPost", "type": "like" }, { "actionType": "st.commentOnPost", "text": "I completely agree with your point." } ] } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openPost", postUrl: "https://www.linkedin.com/posts/post1", basicInfo: false, then: [ { actionType: "st.reactToPost", type: "like" }, { actionType: "st.commentOnPost", text: "I completely agree with your point." } ] }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openPost", "postUrl": "https://www.linkedin.com/posts/post1", "basicInfo": False, "then": [ {"actionType": "st.reactToPost", "type": "like"}, { "actionType": "st.commentOnPost", "text": "I completely agree with your point.", }, ], } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openPost", "postUrl": "https://www.linkedin.com/posts/post1", "basicInfo": false, "then": [ { "actionType": "st.reactToPost", "type": "like" }, { "actionType": "st.commentOnPost", "text": "I completely agree with your point." } ] } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openPost", "success": true, "data": { "then": [ { "actionType": "st.reactToPost", "success": true }, { "actionType": "st.commentOnPost", "success": true } ] } } ``` ## Reacting to a comment To react to a comment, you need to include [`st.reactToComment`](/docs/action-st-react-to-comment) action in your workflow. It accepts a `commentUrl`, which you can obtain from the `commentUrl` field returned by [`st.commentOnPost`](/docs/action-st-comment-on-post), [`st.replyToComment`](/docs/action-st-reply-to-comment), or [`st.retrievePostComments`](/docs/action-st-retrieve-post-comments). Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.reactToComment", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "type": "like" } ``` ```typescript tab=Node const workflow = await linkedapi.reactToComment.execute({ commentUrl: "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", type: "like" }); const { errors } = await linkedapi.reactToComment.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ReactToCommentParams workflow = linkedapi.react_to_comment.execute( ReactToCommentParams( comment_url="https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", type="like", ) ) result = linkedapi.react_to_comment.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin comment react \ "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" \ --type like ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – the method returns no `data`; a failed action comes back in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints `{"success": true, "data": null}`, or the action's `error`: ::: ```json { "actionType": "st.reactToComment", "success": true } ``` ## Replying to a comment To reply to a comment, you need to include [`st.replyToComment`](/docs/action-st-reply-to-comment) action in your workflow. It returns the `commentUrn` and `commentUrl` of the created reply. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.replyToComment", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "text": "Thanks for sharing your thoughts!" } ``` ```typescript tab=Node const workflow = await linkedapi.replyToComment.execute({ commentUrl: "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", text: "Thanks for sharing your thoughts!" }); const { data, errors } = await linkedapi.replyToComment.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ReplyToCommentParams workflow = linkedapi.reply_to_comment.execute( ReplyToCommentParams( comment_url="https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", text="Thanks for sharing your thoughts!", ) ) result = linkedapi.reply_to_comment.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin comment reply \ "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" \ 'Thanks for sharing your thoughts!' ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.replyToComment", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,1122334455)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(1122334455%2Curn%3Ali%3Aactivity%3A1234567890123456789)" } } ``` ## Engaging with all comments on a post To react to or reply to every comment on a post without knowing their URLs upfront, retrieve the comments with [`st.retrievePostComments`](/docs/action-st-retrieve-post-comments) and iterate over them with [`st.doForComments`](/docs/action-st-do-for-comments). Each iterated comment can either react/reply directly, or open first with [`st.openComment`](/docs/action-st-open-comment) to run several comment-scoped actions. Here's an example that likes and replies to each of the 3 most relevant comments: **Workflow:** ```json tab=HTTP { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 3, "sort": "mostRelevant", "then": { "actionType": "st.doForComments", "then": { "actionType": "st.openComment", "then": [ { "actionType": "st.reactToComment", "type": "celebrate" }, { "actionType": "st.replyToComment", "text": "Great point, thanks for sharing!" } ] } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.retrievePostComments", postUrl: "https://www.linkedin.com/posts/post1", limit: 3, sort: "mostRelevant", then: { actionType: "st.doForComments", then: { actionType: "st.openComment", then: [ { actionType: "st.reactToComment", type: "celebrate" }, { actionType: "st.replyToComment", text: "Great point, thanks for sharing!" } ] } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 3, "sort": "mostRelevant", "then": { "actionType": "st.doForComments", "then": { "actionType": "st.openComment", "then": [ {"actionType": "st.reactToComment", "type": "celebrate"}, { "actionType": "st.replyToComment", "text": "Great point, thanks for sharing!", }, ], }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 3, "sort": "mostRelevant", "then": { "actionType": "st.doForComments", "then": { "actionType": "st.openComment", "then": [ { "actionType": "st.reactToComment", "type": "celebrate" }, { "actionType": "st.replyToComment", "text": "Great point, thanks for sharing!" } ] } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.retrievePostComments", "success": true, "data": [ { ... "then": { "actionType": "st.openComment", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543210)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "then": [ { "actionType": "st.reactToComment", "success": true }, { "actionType": "st.replyToComment", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,1122334455)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(1122334455%2Curn%3Ali%3Aactivity%3A1234567890123456789)" } } ] } } } ] } ``` > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Retrieving post data To retrieve LinkedIn post data, include the [`st.openPost`](/docs/action-st-open-post) action in your workflow and set `basicInfo` to `true`. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: **Workflow:** ```json tab=HTTP { "actionType": "st.openPost", "postUrl": "https://www.linkedin.com/posts/post1", "basicInfo": true } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPost.execute({ postUrl: "https://www.linkedin.com/posts/post1" }); const { data, errors } = await linkedapi.fetchPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchPostParams workflow = linkedapi.fetch_post.execute( FetchPostParams( post_url="https://www.linkedin.com/posts/post1", ) ) result = linkedapi.fetch_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post fetch https://www.linkedin.com/posts/post1 ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.openPost", "success": true, "data": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/example-person", "headline": "Product Marketing Lead", "viewerState": { "connectionDegree": "2nd" } }, "reposter": null, "text": "Check out our latest product launch!", "repostText": null, "hashtags": ["product", "launch"], "mentions": ["https://www.linkedin.com/company/example-company"], "externalLinks": ["https://example.com/launch"], "images": [ "https://static.linkedin.com/image1.jpg", "https://static.linkedin.com/image2.jpg" ], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 12, "viewerState": { "reaction": null, "isOwn": false } } } ``` > 💡 If you want to react or comment on the opened post, use the following actions as children: [st.reactToPost](/docs/action-st-react-to-post), [st.commentOnPost](/docs/action-st-comment-on-post). > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Searching for jobs To search for jobs in LinkedIn, include the [`st.searchJobs`](/docs/action-st-search-jobs) action in your workflow. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: For the bigger picture – why there is no public LinkedIn jobs API and how this account-based route compares with scraping APIs and datasets – see the [LinkedIn Jobs API guide](/guides/linkedin-jobs-scraper). **Workflow:** ```json tab=HTTP { "actionType": "st.searchJobs", "term": "product manager", "limit": 2, "location": "San Francisco, California, United States", "filter": { "datePosted": "pastWeek", "experienceLevels": ["midSeniorLevel", "director"], "employmentTypes": ["fullTime"], "workplaceTypes": ["remote", "hybrid"], "easyApply": true } } ``` ```typescript tab=Node const workflow = await linkedapi.searchJobs.execute({ term: "product manager", limit: 2, location: "San Francisco, California, United States", filter: { datePosted: "pastWeek", experienceLevels: ["midSeniorLevel", "director"], employmentTypes: ["fullTime"], workplaceTypes: ["remote", "hybrid"], easyApply: true } }); const { data, errors } = await linkedapi.searchJobs.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchJobsParams workflow = linkedapi.search_jobs.execute( SearchJobsParams( term="product manager", limit=2, location="San Francisco, California, United States", filter={ "date_posted": "pastWeek", "experience_levels": ["midSeniorLevel", "director"], "employment_types": ["fullTime"], "workplace_types": ["remote", "hybrid"], "easy_apply": True, }, ) ) result = linkedapi.search_jobs.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin jobs search \ --term "product manager" \ --limit 2 \ --location "San Francisco, California, United States" \ --date-posted pastWeek \ --experience-levels midSeniorLevel,director \ --employment-types fullTime \ --workplace-types remote,hybrid \ --easy-apply ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.searchJobs", "success": true, "data": [ { "jobId": "4416248954", "urn": "urn:li:jobPosting:4416248954", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "title": "Senior Product Manager", "companyName": "Example Company", "location": "San Francisco, CA", "workplaceType": "remote", "salary": { "currency": "usd", "minAmount": 140000, "maxAmount": 180000, "period": "yearly" }, "easyApply": true, "isPromoted": false, "isSimilarMatch": false }, { "jobId": "4427334841", "urn": "urn:li:jobPosting:4427334841", "jobUrl": "https://www.linkedin.com/jobs/view/4427334841/", "title": "Director of Product", "companyName": "Another Company", "location": "New York, NY", "workplaceType": "hybrid", "salary": null, "easyApply": false, "isPromoted": true, "isSimilarMatch": false } ] } ``` LinkedIn is rolling out an AI-powered jobs search alongside the classic one. Which version an account has is decided by LinkedIn, not by you, and it can change over time. The two versions do not offer the same refinements, so `st.searchJobs` accepts one of two objects: | | `filter` | `preferences` | | --- | --- | --- | | Search version | Classic | AI-powered | | Behavior | Every specified field is applied, or the action fails | Each specified field is applied when LinkedIn offers it for the search | | Coverage | All filters | Fewer filters, plus free-form `keywords` | Send one of them, never both. `location` and `allowSimilarResults` stay outside of them, since they work on both versions. The same request usually works whatever version the account has. When the criteria you sent cannot be applied by the version it has, the action fails with [`searchInterfaceMismatch`](/docs/action-st-search-jobs). Handle it as a normal condition rather than picking an object once and hardcoding it. Here's a search written for the AI-powered version: **Workflow:** ```json tab=HTTP { "actionType": "st.searchJobs", "term": "product manager", "limit": 2, "location": "San Francisco, California, United States", "preferences": { "datePosted": "pastWeek", "experienceLevels": ["senior", "director"], "employmentTypes": ["fullTime"], "remote": true, "keywords": ["Fintech"] } } ``` ```typescript tab=Node const workflow = await linkedapi.searchJobs.execute({ term: "product manager", limit: 2, location: "San Francisco, California, United States", preferences: { datePosted: "pastWeek", experienceLevels: ["senior", "director"], employmentTypes: ["fullTime"], remote: true, keywords: ["Fintech"] } }); const { data, errors } = await linkedapi.searchJobs.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchJobsParams workflow = linkedapi.search_jobs.execute( SearchJobsParams( term="product manager", limit=2, location="San Francisco, California, United States", preferences={ "date_posted": "pastWeek", "experience_levels": ["senior", "director"], "employment_types": ["fullTime"], "remote": True, "keywords": ["Fintech"], }, ) ) result = linkedapi.search_jobs.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin jobs search \ --term "product manager" \ --limit 2 \ --location "San Francisco, California, United States" \ --pref-date-posted pastWeek \ --pref-experience-levels senior,director \ --pref-employment-types fullTime \ --pref-remote \ --pref-keywords Fintech ``` The AI-powered version also decides how strictly to match. When it finds few exact matches, it appends near matches below them, and those come back with `isSimilarMatch` set to `true`. Set `allowSimilarResults` to `false` to exclude them. > 💡 If you want to retrieve full details for each job from the search results, use [`st.doForJobs`](/docs/action-st-do-for-jobs) with [`st.openJob`](/docs/action-st-open-job). > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Retrieving job data To retrieve LinkedIn job data, include the [`st.openJob`](/docs/action-st-open-job) action in your workflow and set `basicInfo` to `true`. Here's an example: :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: For the bigger picture – why there is no public LinkedIn jobs API and how this account-based route compares with scraping APIs and datasets – see the [LinkedIn Jobs API guide](/guides/linkedin-jobs-scraper). **Workflow:** ```json tab=HTTP { "actionType": "st.openJob", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "basicInfo": true } ``` ```typescript tab=Node const workflow = await linkedapi.fetchJob.execute({ jobUrl: "https://www.linkedin.com/jobs/view/4416248954/" }); const { data, errors } = await linkedapi.fetchJob.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchJobParams workflow = linkedapi.fetch_job.execute( FetchJobParams( job_url="https://www.linkedin.com/jobs/view/4416248954/", ) ) result = linkedapi.fetch_job.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin jobs fetch https://www.linkedin.com/jobs/view/4416248954/ ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.openJob", "success": true, "data": { "jobId": "4416248954", "urn": "urn:li:jobPosting:4416248954", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "title": "Senior Product Manager", "companyName": "Example Company", "companyUrl": "https://www.linkedin.com/company/example-company", "location": "San Francisco, CA", "postedDate": "1w", "applicantsCount": 84, "workplaceType": "remote", "employmentType": "Full-time", "salary": { "currency": "usd", "minAmount": 140000, "maxAmount": 180000, "period": "yearly" }, "description": "Example job description text.", "applyUrl": "https://www.linkedin.com/jobs/view/4416248954/apply/", "easyApply": true } } ``` > 💡 If you want to find jobs to retrieve, use [`st.searchJobs`](/docs/action-st-search-jobs) action. > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Retrieving SSI, performance This page describes how to create workflows for retrieving your current [SSI](/guides/linkedin-social-selling-index) and performance analytics from your [LinkedIn dashboard](https://www.linkedin.com/dashboard/). :::surface node Each example below has its Node SDK call in the Node tab, and the label over each completion says what that call returns. ::: :::surface python Each example below has its Python SDK call in the Python tab, and the label over each completion says what that call returns. ::: :::surface cli Each example below has its CLI command in the CLI tab, and the label over each completion says what the command prints. ::: ## Retrieving current SSI To retrieve your current SSI, you need to include [`st.retrieveSSI`](/docs/action-st-retrieve-ssi) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.retrieveSSI" } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveSSI.execute(); const { data, errors } = await linkedapi.retrieveSSI.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_ssi.execute() result = linkedapi.retrieve_ssi.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin stats ssi ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.retrieveSSI", "success": true, "data": { "ssi": 78, "industryTop": 15, "networkTop": 10 } } ``` ## Retrieving performance To retrieve performance analytics from your [LinkedIn dashboard](https://www.linkedin.com/dashboard/), you need to include [`st.retrievePerformance`](/docs/action-st-retrieve-performance) action in your workflow. Here's an example: **Workflow:** ```json tab=HTTP { "actionType": "st.retrievePerformance" } ``` ```typescript tab=Node const workflow = await linkedapi.retrievePerformance.execute(); const { data, errors } = await linkedapi.retrievePerformance.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_performance.execute() result = linkedapi.retrieve_performance.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin stats performance ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns its `data` in `data`, and a failed action in `errors`: ::: :::surface cli **Completion** – with `--json` the CLI prints its `data` as `data`, or the action's `error`: ::: ```json { "actionType": "st.retrievePerformance", "success": true, "data": { "followersCount": 1250, "postViewsLast7Days": 523, "profileViewsLast90Days": 342, "searchAppearancesPreviousWeek": 89 } } ``` > This page provides examples of [workflows](/docs/building-workflows) and their [completions](/docs/executing-workflows). For detailed documentation on constraints, parameters, and possible results of specific actions, always refer to the corresponding [action documentation pages](/docs/actions-overview). ## Checking API usage statistics This page explains how to check Linked API usage statistics. You can use this information to stay within LinkedIn limits for your account. :::surface node In the Node SDK this is `linkedapi.getApiUsage()` – the call is below. ::: :::surface python In the Python SDK this is `linkedapi.get_api_usage()` – the call is below. ::: :::surface cli In the CLI this is `linkedin stats usage` – the command is below. ::: > In addition to monitoring usage, you can configure action limits for each account on the [platform](https://app.linkedapi.io/). When a limit is reached, actions will automatically return a `limitExceeded` error instead of executing. We provide statistics as an array of all actions executed during a specific period. To access it, make a `GET` request to the following endpoint with 2 parameters: ```text tab=HTTP GET https://api.linkedapi.io/stats/actions?start={}&end={} ``` ```typescript tab=Node // Get usage stats for the last 7 days const endDate = new Date(); const startDate = new Date(endDate.getTime() - 7 * 24 * 60 * 60 * 1000); const { data } = await linkedapi.getApiUsage({ start: startDate.toISOString(), end: endDate.toISOString() }); ``` ```python tab=Python from datetime import datetime, timedelta, timezone from linkedapi import ApiUsageParams # Get usage stats for the last 7 days end_date = datetime.now(timezone.utc) start_date = end_date - timedelta(days=7) result = linkedapi.get_api_usage( ApiUsageParams( start=start_date.isoformat(), end=end_date.isoformat(), ) ) ``` ```bash tab=CLI linkedin stats usage --start 2024-01-01T00:00:00Z --end 2024-01-31T00:00:00Z --json ``` ## Parameters :::surface node,python In the SDK this is the `getApiUsage` method; it takes `start` and `end` and returns the array below in `data`. ::: :::surface cli In the CLI this is `linkedin stats usage`: ```bash linkedin stats usage --start --end [flags] ``` | Flag | Type | Required | Description | | --- | --- | --- | --- | | `--start` | string | yes | Start date in ISO timestamp | | `--end` | string | yes | End date in ISO timestamp | ::: - `start` – timestamp from which the statistics will be retrieved. - `end` – timestamp up to which the statistics will be retrieved. > The difference between `start` and `end` must not exceed 30 days. ## Result :::surface http In response, you receive an array of actions executed during the specified period: ::: :::surface node,python **Result** – `data` is the `result` array below, one item per action executed during the period: ::: :::surface cli **Output** – with `--json` or into a pipe, the CLI prints this `result` array as `data`: ::: ```json { "success": true, "result": [ { "actionType": "st.openCompanyPage", "success": true, "time": "2023-01-02T12:30:00Z" }, { "actionType": "st.sendMessage", "success": false, "time": "2023-01-03T12:30:00Z" }, { "actionType": "nv.retrieveCompanyEmployees", "success": true, "time": "2023-01-04T12:30:00Z" }, { "actionType": "st.sendConnectionRequest", "success": true, "time": "2023-01-07T12:30:00Z" } ... ] } ``` > In case of an unsuccessful request, you'll receive one of the [common errors](/docs/common-errors). ## st.sendMessage This action allows you to send a message to a person. :::surface node,python In the SDK this is the `sendMessage` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. The SDK also takes `manageConversation`: it runs [`st.manageConversation`](/docs/action-st-manage-conversation) on the same conversation right after the message is sent, so no `threadId` is needed for it. ::: :::surface cli In the CLI this is `linkedin message send` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` or `threadId` parameter is provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** [st.manageConversation](/docs/action-st-manage-conversation). ## Parameters ```json tab=HTTP { "actionType": "st.sendMessage", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "text": "Hi! I'd love to connect and discuss some ideas.", "then": { "actionType": "st.manageConversation", "operation": "archive" } } ``` ```typescript tab=Node const workflow = await linkedapi.sendMessage.execute({ personUrl: "https://www.linkedin.com/in/person1", text: "Hi! I'd love to connect and discuss some ideas.", manageConversation: { operation: "archive" } }); const { errors } = await linkedapi.sendMessage.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SendMessageParams workflow = linkedapi.send_message.execute( SendMessageParams( person_url="https://www.linkedin.com/in/person1", text="Hi! I'd love to connect and discuss some ideas.", manage_conversation={"operation": "archive"}, ) ) result = linkedapi.send_message.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin message send \ https://www.linkedin.com/in/person1 \ "Hi! I'd love to connect and discuss some ideas." \ --manage archive ``` In AI assistants, the MCP server runs this action as the `send_message` tool. :::surface cli In the CLI this is `linkedin message send`: ```bash linkedin message send ``` | Arg | Required | Description | | --- | --- | --- | | `person-url` | conditional | LinkedIn profile URL of the recipient. Optional when `--thread-id` is provided | | `text` | yes | Message text (up to 1900 characters) | | Flag | Type | Description | | --- | --- | --- | | `--thread-id` | string | Reply into an existing conversation thread instead of passing `person-url` | | `--manage` | string | Manage the conversation right after sending, acting on the same thread. One of `archive`, `unarchive`, `star`, `unstar`, `mute`, `unmute` | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start unless `threadId` is provided, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person you want to send a message to. - `threadId` (optional) – identifier of an existing conversation thread to reply into, as returned by [inbox polling](/docs/monitoring-inbox), or read from the address bar of an open conversation — the `` in `linkedin.com/messaging/thread/`. Provide either `personUrl` or `threadId`; if both are given, `threadId` takes precedence. - `text` – message text, must be up to **1900** characters. - `then` (optional) – object or array of child actions to be executed within this action. Only [st.manageConversation](/docs/action-st-manage-conversation) is allowed as a child, and it acts on the conversation this message was sent into, so its `threadId` must be omitted — only `operation` is provided. > 💡 Replying by `threadId` sends the message directly into a known conversation, which is convenient when reacting to an inbox event without resolving the person's profile URL first. ## Result options 1. **Successful message sending:** ```json { "actionType": "st.sendMessage", "label": "person1", "success": true, "data": { "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data.then` – included only when child actions are provided; contains the results of their execution. 2. **Failed message sending:** ```json { "actionType": "st.sendMessage", "label": "person1", "success": false, "error": { "type": "personNotFound", "message": "The provided URL is not an existing LinkedIn person." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `personLikelyChangedPublicUrl` – the provided URL no longer opens, but this person appeared in your account's [network events](/docs/monitoring-network) before, so they most likely changed their public URL. Retry with their [hashed URL](/faq/what-are-hashed-url-and-public-url). - `selfProfileNotAllowed` – action cannot be performed on your own profile. - `messagingNotAllowed` – sending a message to the person is not allowed. This could happen for several reasons: - You are not connected to the person. - LinkedIn has restricted your ability to send messages to the person, for example, due to reaching message limits or the person’s privacy settings. ## st.syncConversation This action loads your message history with a person. Read it, together with the new messages the inbox captures, through [`/inbox/poll` with `personUrl`](/docs/monitoring-inbox#polling-one-person). See [Loading conversation history](/docs/working-with-conversations) for when you need it. :::surface node,python In the SDK this is the `syncConversation` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin message get` – the command is below, under [Parameters](#parameters). ::: > 💡 Run it once per person – not to check for new messages. New messages reach you through [inbox monitoring](/docs/monitoring-inbox). ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.syncConversation", "label": "sync1", "personUrl": "https://www.linkedin.com/in/person1", "days": 14 } ``` ```typescript tab=Node const workflow = await linkedapi.syncConversation.execute({ personUrl: "https://www.linkedin.com/in/person1", days: 14 }); const { data, errors } = await linkedapi.syncConversation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SyncConversationParams workflow = linkedapi.sync_conversation.execute( SyncConversationParams( person_url="https://www.linkedin.com/in/person1", days=14, ) ) result = linkedapi.sync_conversation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin message get https://www.linkedin.com/in/person1 --days 14 ``` In AI assistants, the MCP server runs this action as the `sync_conversation` tool. :::surface cli The CLI has no command that only loads history: `linkedin message get` reads one conversation and, when its earlier history was never loaded, loads it with this action first. ```bash linkedin message get [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--since` | string | Only return messages since this ISO timestamp | | `--days` | integer | Syncing period for the first call, 1–90 (default 30): how many days new messages keep being added to the loaded history, not how far back it is read | ```bash linkedin message get https://www.linkedin.com/in/john-doe --since 2024-01-15T10:30:00Z --json ``` The first call for a conversation takes longer; later calls read what is already loaded. Call it once per person for the earlier history – new messages reach you through `linkedin inbox get --person ` ([`POST /inbox/poll`](/docs/monitoring-inbox?surface=cli)). ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person whose conversation you want to load. - `days` (optional) – how many days Linked API keeps adding this person's new messages to the loaded history, from 1 to 90. Defaults to 30. ## Syncing period > 💡 **`days` is the syncing period, not the depth of history.** It sets how many days new messages keep being added; the action always reads the whole conversation, however old it is. Linked API keeps adding this person's new messages to the loaded history until the `syncUntil` moment returned by this action, counted from the moment the action starts running. With [inbox monitoring](/docs/monitoring-inbox) enabled, new messages from this person keep arriving through the inbox after that moment as well. After that moment nothing is deleted: [polling](/docs/monitoring-inbox#polling-one-person) keeps returning the messages collected so far. Running this action again for the same person keeps the accumulated history and never shortens the period: `syncUntil` stays at the later of the current end and the new one. A reply from the other person does not extend the period. A thread read back to its first message is reported with `historyComplete: true` when you [poll the person](/docs/monitoring-inbox#polling-one-person). A very long conversation may not be read to its start within the action's time limit: the messages read are kept, and the thread stays `historyComplete: false`. This action does not enable inbox monitoring and does not produce [inbox webhook events](/docs/webhook-event-types#inbox-events). ## Repeating within 24 hours If you run this action again within 24 hours of a completed reading of the same person, it responds quickly instead of reading the whole conversation again: - if Linked API already knows the thread identifier, it rereads only the messages since that reading and then responds; - otherwise it responds immediately, without opening LinkedIn, and the check is finished during the next periodic syncs. In the second case, a message the periodic sync missed can appear in polling later – usually within minutes – rather than by the time the action completes. ## Result options 1. **Successful syncing:** ```json { "actionType": "st.syncConversation", "label": "sync1", "success": true, "data": { "syncUntil": "2026-09-09T09:55:31.582Z" } } ``` - `label` – included only if specified in the action parameters. - `data.syncUntil` – moment until which Linked API keeps adding this person's new messages to the loaded history. 2. **Failed syncing:** ```json { "actionType": "st.syncConversation", "label": "sync1", "success": false, "error": { "type": "personNotFound", "message": "The provided URL is not an existing LinkedIn person." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `selfProfileNotAllowed` – action cannot be performed on your own profile. ## st.syncInbox This action enables **whole-inbox monitoring** for your account so you can [poll the inbox](/docs/monitoring-inbox) for messages across every conversation. :::surface node,python In the SDK this is the `syncInbox` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin inbox sync` – the command is below, under [Parameters](#parameters). ::: > 💡 **Recommended first step for reading messages.** See the [Messaging overview](/docs/messaging-overview) for how it fits with the other messaging tools. `st.syncInbox` starts tracking **all** threads in your inbox. Run it once per account. After it succeeds, new messages become available through the [inbox polling endpoint](/docs/monitoring-inbox) — no need to sync each conversation individually. > 💡 Only messages that arrive **after** inbox sync is enabled are captured. To get a person's earlier history, load it once with [`st.syncConversation`](/docs/action-st-sync-conversation) and read it through [inbox polling with `personUrl`](/docs/monitoring-inbox#polling-one-person). This action only switches monitoring on: it records the setting and returns. It does not browse your inbox and does not open anyone's profile. The messages themselves are picked up afterwards by a sync that runs periodically, so the first ones appear on the next sync rather than the moment the action completes. > **Note:** When that sync finds new messages in a thread – including messages you send yourself – it opens the profile page of the other participant in that thread to resolve their public URL – but only when Linked API does not already know it, so a person is visited once, which is what fills `personPublicUrl` on the messages you [poll](/docs/monitoring-inbox) or receive by [webhook](/docs/webhooks). Those visits are made from your own account, and such a visit is visible to the person whose profile is opened. ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.syncInbox", "label": "enableInbox" } ``` ```typescript tab=Node const workflow = await linkedapi.syncInbox.execute({}); const { errors } = await linkedapi.syncInbox.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.sync_inbox.execute() result = linkedapi.sync_inbox.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin inbox sync ``` In AI assistants, the MCP server runs this action as the `sync_inbox` tool. :::surface cli In the CLI this is `linkedin inbox sync`: ```bash linkedin inbox sync # standard inbox linkedin inbox sync --nv # Sales Navigator inbox ``` | Flag | Type | Description | | --- | --- | --- | | `--nv` | bool | Enable monitoring for the Sales Navigator inbox instead of the standard one | > Only messages that arrive after this runs are captured. To get a person's earlier history, run `message get ` once, then read the whole conversation with `inbox get --person `. ::: - `label` (optional) – custom label for tracking this action in workflow completion. This action takes no other parameters. ## Result options **Successful enabling:** ```json { "actionType": "st.syncInbox", "label": "enableInbox", "success": true } ``` - `label` – included only if specified in the action parameters. ## st.manageConversation This action allows you to manage a conversation thread — archive, star, or mute it — by its `threadId`. :::surface node,python In the SDK this is the `manageConversation` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin message manage` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `threadId` parameter is provided. > ⬆️ **Parent Actions:** [st.sendMessage](/docs/action-st-send-message). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.manageConversation", "label": "thread1", "threadId": "2-Zjhm...", "operation": "archive" } ``` ```typescript tab=Node const workflow = await linkedapi.manageConversation.execute({ threadId: "2-Zjhm...", operation: "archive" }); const { errors } = await linkedapi.manageConversation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ManageConversationParams workflow = linkedapi.manage_conversation.execute( ManageConversationParams( thread_id="2-Zjhm...", operation="archive", ) ) result = linkedapi.manage_conversation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin message manage 2-Zjhm... archive ``` In AI assistants, the MCP server runs this action as the `manage_conversation` tool. :::surface cli In the CLI this is `linkedin message manage`: ```bash linkedin message manage ``` | Arg | Required | Description | | --- | --- | --- | | `thread-id` | yes | Conversation thread identifier, as returned by `inbox get` / `message get`, or the `` in `linkedin.com/messaging/thread/` of an open conversation | | `operation` | yes | One of `archive`, `unarchive`, `star`, `unstar`, `mute`, `unmute` | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `threadId` (required for root start, forbidden for child start) – identifier of the conversation thread to manage, as returned by [inbox polling](/docs/monitoring-inbox). You can also read it from the address bar of an open conversation — it is the `` in `linkedin.com/messaging/thread/`. When run as a child of [st.sendMessage](/docs/action-st-send-message), the thread is inherited from the parent send and this parameter must be omitted. - `operation` – operation to apply to the thread. One of: - `archive` / `unarchive` – archive or unarchive the conversation. - `star` / `unstar` – star or unstar the conversation. - `mute` / `unmute` – mute or unmute the conversation. ## Result options 1. **Successful operation:** ```json { "actionType": "st.manageConversation", "label": "thread1", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **Failed operation:** ```json { "actionType": "st.manageConversation", "label": "thread1", "success": false, "error": { "type": "threadNotFound", "message": "The provided thread identifier does not match an existing conversation." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `threadNotFound` – provided `threadId` does not match an existing conversation. ## st.checkConnectionStatus This action allows you to check the connection status between your account and another person. :::surface node,python In the SDK this is the `checkConnectionStatus` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin connection status` – the command is below, under [Parameters](#parameters). ::: > If you need to perform several connection status checks, use this action multiple times within an [array workflow](/docs/building-workflows). This approach is significantly faster than creating separate workflows for each check due to API optimizations. ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.checkConnectionStatus", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/person1" }); const { data, errors } = await linkedapi.checkConnectionStatus.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CheckConnectionStatusParams workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams( person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.check_connection_status.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection status https://www.linkedin.com/in/person1 ``` In AI assistants, the MCP server runs this action as the `check_connection_status` tool. :::surface cli In the CLI this is `linkedin connection status`: ```bash linkedin connection status ``` ::: - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person you want to check a connection status with. - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options 1. **Successful connection check:** ```json { "actionType": "st.checkConnectionStatus", "label": "person1", "success": true, "data": { "connectionStatus": "pending" } } ``` - `label` – included only if specified in the action parameters. - `data.connectionStatus` – enum with the following possible values: - `connected` – your account is connected with the person. - `notConnected` – your account is not connected with the person. - `pending` – your account has a pending connection request to the person. - `incoming` – the person has sent your account a connection request that is awaiting your response. 2. **Failed connection check:** ```json { "actionType": "st.checkConnectionStatus", "label": "person1", "success": false, "error": { "type": "personNotFound", "message": "The provided URL is not an existing LinkedIn person." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `selfProfileNotAllowed` – action cannot be performed on your own profile. ## st.sendConnectionRequest This action allows you to send a connection request to a person. :::surface node,python In the SDK this is the `sendConnectionRequest` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin connection send` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.sendConnectionRequest", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "note": "Hi, I’d like to connect with you to discuss potential collaboration.", "email": "example1@gmail.com" } ``` ```typescript tab=Node const workflow = await linkedapi.sendConnectionRequest.execute({ personUrl: "https://www.linkedin.com/in/person1", note: "Hi, I’d like to connect with you to discuss potential collaboration.", email: "example1@gmail.com" }); const { errors } = await linkedapi.sendConnectionRequest.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SendConnectionRequestParams workflow = linkedapi.send_connection_request.execute( SendConnectionRequestParams( person_url="https://www.linkedin.com/in/person1", note="Hi, I’d like to connect with you to discuss potential collaboration.", email="example1@gmail.com", ) ) result = linkedapi.send_connection_request.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection send https://www.linkedin.com/in/person1 \ --note "Hi, I’d like to connect with you to discuss potential collaboration." \ --email example1@gmail.com ``` In AI assistants, the MCP server runs this action as the `send_connection_request` tool. :::surface cli In the CLI this is `linkedin connection send`: ```bash linkedin connection send [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--note` | string | Personalized note to include with the request | | `--email` | string | Email address (required by some profiles to connect) | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person you want to send a connection request to. - `note` (optional) – note to include with the connection request, must be up to **300** characters (LinkedIn allows **200** for free accounts and **300** for Premium). LinkedIn may also limit how many personalized invitation notes a free account can send. - `email` (optional) – email address required by some people for sending connection requests to them. If it is required and not provided, the connection request will fail. ## Result options 1. **Successful connection request sending:** ```json { "actionType": "st.sendConnectionRequest", "label": "person1", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **Failed connection request sending:** ```json { "actionType": "st.sendConnectionRequest", "label": "person1", "success": false, "error": { "type": "emailRequired", "message": "The person requires an email address to send a connection request." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `selfProfileNotAllowed` – action cannot be performed on your own profile. - `alreadyPending` – connection request to this person has already been sent and is still pending. - `alreadyConnected` – your LinkedIn account is already connected with this person. - `invitationAlreadyReceived` – this person has already sent you a connection request. Accept it with [st.acceptInvitation](/docs/action-st-accept-invitation) instead of sending a new one. - `emailRequired` – person requires an email address to send a connection request. - `noteTooLong` – the note exceeds the character limit allowed by LinkedIn. Free accounts are limited to 200 characters, Premium accounts to 300. - `noteLimitExceeded` – your LinkedIn account has reached the limit for personalized invitation notes. Send the connection request without a note, or buy LinkedIn Premium to include one. - `requestNotAllowed` – LinkedIn has restricted sending a connection request to this person. This can happen for the following reasons: - The person has disabled connection requests in their privacy settings. - You recently sent and withdrew a connection request to this person. - You have reached LinkedIn's daily, weekly, or monthly limits for sending connection requests. ## st.withdrawConnectionRequest This action allows you to withdraw the connection request sent to a person. :::surface node,python In the SDK this is the `withdrawConnectionRequest` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin connection withdraw` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.withdrawConnectionRequest", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "unfollow": true } ``` ```typescript tab=Node const workflow = await linkedapi.withdrawConnectionRequest.execute({ personUrl: "https://www.linkedin.com/in/person1", unfollow: true }); const { errors } = await linkedapi.withdrawConnectionRequest.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import WithdrawConnectionRequestParams workflow = linkedapi.withdraw_connection_request.execute( WithdrawConnectionRequestParams( person_url="https://www.linkedin.com/in/person1", unfollow=True, ) ) result = linkedapi.withdraw_connection_request.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection withdraw https://www.linkedin.com/in/person1 --unfollow ``` In AI assistants, the MCP server runs this action as the `withdraw_connection_request` tool. :::surface cli In the CLI this is `linkedin connection withdraw`: ```bash linkedin connection withdraw [flags] ``` | Flag | Type | Default | Description | | --- | --- | --- | --- | | `--unfollow` / `--no-unfollow` | boolean | `true` | Also unfollow the person | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person you want to withdraw the connection request from. - `unfollow` (optional) – boolean indicating whether you want to unfollow the person when withdrawing the request. The default value is `false`: if you omit it, the request is withdrawn and you keep following the person. The CLI is the exception – `linkedin connection withdraw` sends `true` unless you pass `--no-unfollow`. ## Result options 1. **Successful connection request withdrawal:** ```json { "actionType": "st.withdrawConnectionRequest", "success": true, "label": "person1" } ``` - `label` – included only if specified in the action parameters. 2. **Failed connection request withdrawal:** ```json { "actionType": "st.withdrawConnectionRequest", "label": "person1", "success": false, "error": { "type": "notPending", "message": "There is no pending connection request to this person." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `selfProfileNotAllowed` – action cannot be performed on your own profile. - `notPending` – there is no pending connection request to this person. ## st.acceptInvitation This action accepts an incoming invitation from the LinkedIn invitation manager. It supports connection requests, company-follow invitations, and newsletter-subscription invitations. :::surface node,python In the SDK this is the `acceptInvitation` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin connection accept ` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed when `invitationType` and its matching target URL are provided. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.acceptInvitation", "label": "invitation1", "invitationType": "connect", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.acceptInvitation.execute({ invitationType: "connect", personUrl: "https://www.linkedin.com/in/person1" }); const { errors } = await linkedapi.acceptInvitation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import AcceptInvitationParams workflow = linkedapi.accept_invitation.execute( AcceptInvitationParams( invitation_type="connect", person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.accept_invitation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection accept connect https://www.linkedin.com/in/person1 ``` In AI assistants, the MCP server runs this action as the `accept_invitation` tool. :::surface cli In the CLI this is `linkedin connection accept`: ```bash linkedin connection accept ``` ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `invitationType` (required) – one of: - `connect` – requires `personUrl`. - `companyFollow` – requires `companyUrl`. - `newsletterSubscribe` – requires `newsletterUrl`. - `personUrl` – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn person URL. Allowed only for `connect`. - `companyUrl` – LinkedIn company page URL. Allowed only for `companyFollow`. - `newsletterUrl` – LinkedIn newsletter URL. Allowed only for `newsletterSubscribe`. Provide exactly one target URL: the one required by `invitationType`. ## Result options 1. **Successful invitation acceptance:** ```json { "actionType": "st.acceptInvitation", "label": "invitation1", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **No matching invitation:** ```json { "actionType": "st.acceptInvitation", "label": "invitation1", "success": false, "error": { "type": "noPendingRequest", "message": "There is no matching pending incoming invitation to accept." } } ``` - `error.type` is `noPendingRequest` when no invitation matches the supplied type and target URL. ## st.ignoreInvitation This action ignores an incoming invitation from the LinkedIn invitation manager. It supports connection requests, company-follow invitations, and newsletter-subscription invitations. :::surface node,python In the SDK this is the `ignoreInvitation` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin connection ignore ` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed when `invitationType` and its matching target URL are provided. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.ignoreInvitation", "label": "invitation1", "invitationType": "connect", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.ignoreInvitation.execute({ invitationType: "connect", personUrl: "https://www.linkedin.com/in/person1" }); const { errors } = await linkedapi.ignoreInvitation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import IgnoreInvitationParams workflow = linkedapi.ignore_invitation.execute( IgnoreInvitationParams( invitation_type="connect", person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.ignore_invitation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection ignore connect https://www.linkedin.com/in/person1 ``` In AI assistants, the MCP server runs this action as the `ignore_invitation` tool. :::surface cli In the CLI this is `linkedin connection ignore`: ```bash linkedin connection ignore ``` ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `invitationType` (required) – one of: - `connect` – requires `personUrl`. - `companyFollow` – requires `companyUrl`. - `newsletterSubscribe` – requires `newsletterUrl`. - `personUrl` – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn person URL. Allowed only for `connect`. - `companyUrl` – LinkedIn company page URL. Allowed only for `companyFollow`. - `newsletterUrl` – LinkedIn newsletter URL. Allowed only for `newsletterSubscribe`. Provide exactly one target URL: the one required by `invitationType`. ## Result options 1. **Successful invitation dismissal:** ```json { "actionType": "st.ignoreInvitation", "label": "invitation1", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **No matching invitation:** ```json { "actionType": "st.ignoreInvitation", "label": "invitation1", "success": false, "error": { "type": "noPendingRequest", "message": "There is no matching pending incoming invitation to ignore." } } ``` - `error.type` is `noPendingRequest` when no invitation matches the supplied type and target URL. ## st.retrievePendingRequests This action allows you to retrieve pending connection requests sent from your account. :::surface node,python In the SDK this is the `retrievePendingRequests` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin connection pending` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePendingRequests", "label": "listPending1" } ``` ```typescript tab=Node const workflow = await linkedapi.retrievePendingRequests.execute(); const { data, errors } = await linkedapi.retrievePendingRequests.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_pending_requests.execute() result = linkedapi.retrieve_pending_requests.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection pending ``` In AI assistants, the MCP server runs this action as the `retrieve_pending_requests` tool. :::surface cli In the CLI this is `linkedin connection pending`: ```bash linkedin connection pending --json ``` ::: - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options This action always completes successfully. Even if you have no pending connection requests, it returns an empty array. ```json { "actionType": "st.retrievePendingRequests", "label": "listPending1", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "sentTime": "2 weeks ago" }, { "name": "Jane Smith", "urn": "urn:li:member:234567890", "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CTO at Tech Solutions", "sentTime": "1 month ago" }, { "name": "Carlos Santos", "urn": null, "publicUrl": "https://www.linkedin.com/in/carlossantos", "headline": "Product Manager at Startup Hub", "sentTime": "3 months ago" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of pending connection requests. - `name` – full name of the person. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `headline` – headline of the person. - `sentTime` – time since the connection request was sent (e.g., "1 month ago"). ## st.retrieveInvitations This action retrieves all incoming invitations currently shown in the LinkedIn invitation manager. :::surface node,python In the SDK this is the `retrieveInvitations` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin connection invitations` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrieveInvitations", "label": "invitations1" } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveInvitations.execute(); const { data, errors } = await linkedapi.retrieveInvitations.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_invitations.execute() result = linkedapi.retrieve_invitations.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection invitations ``` In AI assistants, the MCP server runs this action as the `retrieve_invitations` tool. :::surface cli In the CLI this is `linkedin connection invitations`: ```bash linkedin connection invitations --json ``` ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `then` is not supported. ## Result options This action always completes successfully. If there are no incoming invitations, it returns an empty array. ```json { "actionType": "st.retrieveInvitations", "label": "invitations1", "success": true, "data": [ { "invitationType": "connect", "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "note": "I would like to connect." }, { "invitationType": "companyFollow", "name": "Jane Smith", "urn": "urn:li:member:234567890", "publicUrl": "https://www.linkedin.com/in/janesmith", "companyUrl": "https://www.linkedin.com/company/example-company", "companyName": "Example Company" }, { "invitationType": "newsletterSubscribe", "name": "Carlos Santos", "urn": null, "publicUrl": "https://www.linkedin.com/in/carlossantos", "newsletterUrl": "https://www.linkedin.com/newsletters/example-1234567890", "newsletterName": "Example Newsletter" } ] } ``` Every item contains: - `invitationType` – `connect`, `companyFollow`, or `newsletterSubscribe`. - `name` – name of the person who sent the invitation. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of that person, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of that person. Type-specific fields: - `connect`: `headline` and `note` (each can be `null`). - `companyFollow`: `companyUrl` and `companyName` (`companyName` can be `null`). - `newsletterSubscribe`: `newsletterUrl` and `newsletterName` (`newsletterName` can be `null`). ## st.retrieveConnections This action allows you to retrieve your connections and perform additional person-related actions if needed. :::surface node,python In the SDK this is the `retrieveConnections` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin connection list` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrieveConnections", "label": "listConnections1", "limit": 100, "since": "2022-01-01", "filter": { "firstName": "John", "lastName": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "currentCompanies": ["Tech Solutions", "Innovatech"], "previousCompanies": ["FutureCorp"], "schools": ["Harvard University", "MIT"] } } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveConnections.execute({ limit: 100, since: "2022-01-01", filter: { firstName: "John", lastName: "Doe", position: "CEO", locations: ["New York", "San Francisco", "London"], industries: ["Software Development", "Professional Services"], currentCompanies: ["Tech Solutions", "Innovatech"], previousCompanies: ["FutureCorp"], schools: ["Harvard University", "MIT"] } }); const { data, errors } = await linkedapi.retrieveConnections.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import RetrieveConnectionsParams workflow = linkedapi.retrieve_connections.execute( RetrieveConnectionsParams( limit=100, since="2022-01-01", filter={ "first_name": "John", "last_name": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "current_companies": ["Tech Solutions", "Innovatech"], "previous_companies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], }, ) ) result = linkedapi.retrieve_connections.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection list \ --limit 100 \ --since 2022-01-01 \ --first-name John \ --last-name Doe \ --position CEO \ --locations "New York,San Francisco,London" \ --industries "Software Development,Professional Services" \ --current-companies "Tech Solutions,Innovatech" \ --previous-companies FutureCorp \ --schools "Harvard University,MIT" ``` In AI assistants, the MCP server runs this action as the `retrieve_connections` tool. :::surface cli In the CLI this is `linkedin connection list`: ```bash linkedin connection list [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--limit` | integer | Max connections to return | | `--since` | string | Only connections made since this ISO timestamp | | `--first-name` | string | Filter by first name | | `--last-name` | string | Filter by last name | | `--position` | string | Filter by job position | | `--locations` | string | Comma-separated locations | | `--industries` | string | Comma-separated industries | | `--current-companies` | string | Comma-separated current company names | | `--previous-companies` | string | Comma-separated previous company names | | `--schools` | string | Comma-separated school names | > `--since` only works when no filter flags are used. ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – number of connections to return. Returns all connections if not provided. - `since` (optional) – ISO date string that filters connections to only include those made on or after the specified date. Only works when `filter` is not provided. - `filter` (optional) – object that specifies filtering criteria for people. When multiple filter fields are specified, they are combined using `AND` logic. - `firstName` (optional) – first name of person. - `lastName` (optional) – last name of person. - `position` (optional) – job position of person. - `locations` (optional) – array of free-form strings representing locations. Matches if person is located in any of the listed locations. - `industries` (optional) – array of enums representing industries. Matches if person works in any of the listed industries. Takes specific values available in the LinkedIn interface. - `currentCompanies` (optional) – array of company names. Matches if person currently works at any of the listed companies. - `previousCompanies` (optional) – array of company names. Matches if person previously worked at any of the listed companies. - `schools` (optional) – array of institution names. Matches if person currently attends or previously attended any of the listed institutions. ## Result options 1. **Successful retrieval:** ```json { "actionType": "st.retrieveConnections", "label": "listConnections1", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "connectedAt": "2025-01-02T00:00:00Z" }, { "name": "Jane Smith", "urn": "urn:li:member:234567890", "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CEO at Tech Solutions", "connectedAt": "2024-11-22T00:00:00Z" }, { "name": "Carlos Santos", "urn": null, "publicUrl": "https://www.linkedin.com/in/carlossantos", "headline": "Project Manager at Startup Hub", "connectedAt": "2022-07-06T00:00:00Z" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of your connections with results of child actions execution. - `name` – full name of the person. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `headline` – headline of the person. - `connectedAt` – date when connection was established. Returned only when `filter` is not provided. 2. **Successful retrieval (with filter):** ```json { "actionType": "st.retrieveConnections", "label": "listConnections1", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "location": "Lisbon, Portugal", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000" }, { "name": "Jane Smith", "urn": "urn:li:member:234567890", "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CEO at Tech Solutions", "location": "San Francisco Bay Area", "avatarUrl": null }, { "name": "Carlos Santos", "urn": null, "publicUrl": "https://www.linkedin.com/in/carlossantos", "headline": "Project Manager at Startup Hub", "location": "Madrid", "avatarUrl": null } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of your connections with results of child actions execution. - `name` – full name of the person. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `headline` – headline of the person. - `location` – free-form string indicating the person's location. Returned only when `filter` is provided. - `avatarUrl` – URL of the person's profile photo, or `null` if the person has no photo. Returned only when `filter` is provided. 3. **Failed retrieval:** ```json { "actionType": "st.retrieveConnections", "label": "listConnections1", "success": false, "error": { "type": "retrievingNotAllowed", "message": "LinkedIn has blocked performing the retrieval." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – LinkedIn offered no matching option for one or more filter values. The message lists each field and value, so correct or remove them rather than retrying. - `retrievingNotAllowed` – LinkedIn has blocked performing the retrieval due to exceeding limits or other restrictions. ## st.removeConnection This action allows you to remove a person from your connections. :::surface node,python In the SDK this is the `removeConnection` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin connection remove` – the command is below, under [Parameters](#parameters). ::: > If you need to remove several people from your connections, use this action multiple times within an [array workflow](/docs/building-workflows). This approach is significantly faster than creating separate workflows for each person due to API optimizations. ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.removeConnection", "label": "remove1", "personUrl": "https://www.linkedin.com/in/person1" } ``` ```typescript tab=Node const workflow = await linkedapi.removeConnection.execute({ personUrl: "https://www.linkedin.com/in/person1" }); const { errors } = await linkedapi.removeConnection.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import RemoveConnectionParams workflow = linkedapi.remove_connection.execute( RemoveConnectionParams( person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.remove_connection.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin connection remove https://www.linkedin.com/in/person1 ``` In AI assistants, the MCP server runs this action as the `remove_connection` tool. :::surface cli In the CLI this is `linkedin connection remove`: ```bash linkedin connection remove ``` ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person you want to remove from your connections. ## Result options 1. **Successful connection removal:** ```json { "actionType": "st.removeConnection", "label": "remove1", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **Failed connection removal:** ```json { "actionType": "st.removeConnection", "label": "remove1", "success": false, "error": { "type": "connectionNotFound", "message": "This person is not in your connections." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `connectionNotFound` – person is not in your connections. ## st.syncNetwork This action enables **network monitoring** for your account so you can [poll the network](/docs/monitoring-network) for connection events as they happen. :::surface node,python In the SDK this is the `syncNetwork` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin network sync`: ```bash linkedin network sync ``` > Only changes that happen after this runs are captured. ::: Instead of repeatedly retrieving connections and pending requests and comparing the results, `st.syncNetwork` starts watching the account's network in the background. Run it once per account. After it succeeds, connection events become available through the [network polling endpoint](/docs/monitoring-network) and the network [webhook events](/docs/webhooks). > 💡 Only changes that happen **after** network sync is enabled are captured. The current state predating the moment you enable it is used as a baseline and does not produce events. > 💡 Coming soon: the [`network` signal](/docs/signal-network) will report the same changes, alongside the other [signals](/docs/signals-overview) that watch posts, comments and profile viewers. Network monitoring keeps working separately from it; once signals launch, a signal is the better way to keep an eye on the account. ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.syncNetwork", "label": "enableNetwork" } ``` ```typescript tab=Node const workflow = await linkedapi.syncNetwork.execute({}); const { errors } = await linkedapi.syncNetwork.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.sync_network.execute() result = linkedapi.sync_network.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin network sync ``` In AI assistants, the MCP server runs this action as the `sync_network` tool. - `label` (optional) – custom label for tracking this action in workflow completion. This action takes no other parameters. ## Result options **Successful enabling:** ```json { "actionType": "st.syncNetwork", "label": "enableNetwork", "success": true } ``` - `label` – included only if specified in the action parameters. ## st.searchPeople This action allows you to search for people applying various filtering criteria. :::surface node,python In the SDK this is the `searchPeople` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin person search` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** [st.doForPeople](/docs/action-st-do-for-people). ## Parameters ```json tab=HTTP { "actionType": "st.searchPeople", "label": "johnDoeSearch1", "term": "John Doe", "limit": 2, "filter": { "firstName": "John", "lastName": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "currentCompanies": ["Tech Solutions", "Innovatech"], "previousCompanies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], "connectionDegrees": ["2nd", "3rd+"] }, "customSearchUrl": "https://www.linkedin.com/search/results/people/?geoUrn=%5B%22103644278%22%5D&keywords=Bill%20Gates", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.searchPeople.execute({ term: "John Doe", limit: 2, filter: { firstName: "John", lastName: "Doe", position: "CEO", locations: ["New York", "San Francisco", "London"], industries: ["Software Development", "Professional Services"], currentCompanies: ["Tech Solutions", "Innovatech"], previousCompanies: ["FutureCorp"], schools: ["Harvard University", "MIT"], connectionDegrees: ["2nd", "3rd+"] }, customSearchUrl: "https://www.linkedin.com/search/results/people/?geoUrn=%5B%22103644278%22%5D&keywords=Bill%20Gates" }); const { data, errors } = await linkedapi.searchPeople.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchPeopleParams workflow = linkedapi.search_people.execute( SearchPeopleParams( term="John Doe", limit=2, filter={ "first_name": "John", "last_name": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "current_companies": ["Tech Solutions", "Innovatech"], "previous_companies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], "connection_degrees": ["2nd", "3rd+"], }, custom_search_url="https://www.linkedin.com/search/results/people/?geoUrn=%5B%22103644278%22%5D&keywords=Bill%20Gates", ) ) result = linkedapi.search_people.result(workflow.workflow_id) ``` ```bash tab=CLI # Not available in the CLI: customSearchUrl linkedin person search \ --term "John Doe" \ --limit 2 \ --first-name John \ --last-name Doe \ --position CEO \ --locations "New York,San Francisco,London" \ --industries "Software Development,Professional Services" \ --current-companies "Tech Solutions,Innovatech" \ --previous-companies FutureCorp \ --schools "Harvard University,MIT" \ --connection-degrees 2nd,3rd+ ``` In AI assistants, the MCP server runs this action as the `search_people` tool. :::surface cli In the CLI this is `linkedin person search`: ```bash linkedin person search [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--term` | string | Search keyword or phrase | | `--limit` | integer | Max results to return | | `--first-name` | string | Filter by first name | | `--last-name` | string | Filter by last name | | `--position` | string | Filter by job position | | `--locations` | string | Comma-separated locations | | `--industries` | string | Comma-separated industries | | `--current-companies` | string | Comma-separated current company names | | `--previous-companies` | string | Comma-separated previous company names | | `--schools` | string | Comma-separated school names | | `--connection-degrees` | string | Comma-separated connection degrees: `1st`, `2nd`, `3rd+` | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `term` (optional) – keyword or phrase to search, from 1 to 50 characters. Either `term` or `customSearchUrl` must be provided; a request with neither (for example, with `filter` only) is rejected. - `limit` (optional) – number of search results to return. Defaults to **10**, with a maximum value of **1000**. - `filter` (optional) – object that specifies filtering criteria for people. When multiple filter fields are specified, they are combined using `AND` logic. - `firstName` (optional) – first name of person. - `lastName` (optional) – last name of person. - `position` (optional) – job position of person. - `locations` (optional) – array of free-form strings representing locations. Matches if person is located in any of the listed locations. - `industries` (optional) – array of enums representing industries. Matches if person works in any of the listed industries. Takes specific values available in the LinkedIn interface. - `currentCompanies` (optional) – array of company names. Matches if person currently works at any of the listed companies. - `previousCompanies` (optional) – array of company names. Matches if person previously worked at any of the listed companies. - `schools` (optional) – array of institution names. Matches if person currently attends or previously attended any of the listed institutions. - `connectionDegrees` (optional) – array of enums representing your connection degree to the person. Matches if the person is at any of the listed degrees. Options: - `1st` – your 1st-degree connections. - `2nd` – your 2nd-degree connections. - `3rd+` – 3rd-degree connections and beyond. - `customSearchUrl` (optional) – URL copied from LinkedIn search results page after configuring desired filters. When specified, overrides term and filter parameters. Allows using any search configuration available in the LinkedIn interface. Either `term` or `customSearchUrl` must be provided. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful search:** ```json { "actionType": "st.searchPeople", "label": "johnDoeSearch1", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder & CEO at Example Company", "location": "London", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "viewerState": { "connectionDegree": "2nd" }, "then": { ... } }, { "name": "John Doe", "urn": null, "publicUrl": "https://www.linkedin.com/in/johnyd", "headline": "Product Manager at Semsoft", "location": "New York", "avatarUrl": null, "viewerState": { "connectionDegree": "3rd+" }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of search outputs with results of child actions execution. - `name` – full name of the person. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `headline` – headline of the person. - `location` – free-form string indicating the person's location. - `avatarUrl` – URL of the person's profile photo, or `null` if the person has no photo. - `viewerState` – how your account relates to the person, see [viewer state](/docs/data-conventions#viewer-state). - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` if LinkedIn does not show it. - `then` – results of child actions execution. 2. **Failed search:** ```json { "actionType": "st.searchPeople", "label": "johnDoeSearch1", "success": false, "error": { "type": "searchingNotAllowed", "message": "LinkedIn has blocked performing the search." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – LinkedIn offered no matching option for one or more filter values. The message lists each field and value, so correct or remove them rather than retrying. - `searchingNotAllowed` – LinkedIn has blocked performing the search due to exceeding limits or other restrictions. ## st.openPersonPage This action allows you to open a person page to retrieve their basic information and perform additional person-related actions if needed. :::surface node In the Node SDK this is `linkedapi.fetchPerson` – the call is below, under [Parameters](#parameters). ::: :::surface python In the Python SDK this is `linkedapi.fetch_person` – the call is below, under [Parameters](#parameters). ::: :::surface cli In the CLI this is `linkedin person fetch` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.doForPeople](/docs/action-st-do-for-people), [nv.openPersonPage](/docs/action-nv-open-person-page). > ⬇️ **Child Actions:** [st.sendMessage](/docs/action-st-send-message), [st.syncConversation](/docs/action-st-sync-conversation), [nv.syncConversation](/docs/action-nv-sync-conversation), [st.checkConnectionStatus](/docs/action-st-check-connection-status), [st.sendConnectionRequest](/docs/action-st-send-connection-request), [st.withdrawConnectionRequest](/docs/action-st-withdraw-connection-request), [st.removeConnection](/docs/action-st-remove-connection), [st.retrievePersonExperience](/docs/action-st-retrieve-person-experience), [st.retrievePersonEducation](/docs/action-st-retrieve-person-education), [st.retrievePersonSkills](/docs/action-st-retrieve-person-skills), [st.endorsePersonSkill](/docs/action-st-endorse-person-skill), [st.retrievePersonLanguages](/docs/action-st-retrieve-person-languages), [st.retrievePersonPosts](/docs/action-st-retrieve-person-posts), [st.retrievePersonComments](/docs/action-st-retrieve-person-comments), [st.retrievePersonReactions](/docs/action-st-retrieve-person-reactions), [nv.openPersonPage](/docs/action-nv-open-person-page). ## Parameters ```json tab=HTTP { "actionType": "st.openPersonPage", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": true, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1" }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 ``` In AI assistants, the MCP server runs this action as the `fetch_person` tool. :::surface node,python In the SDK this is the `fetchPerson` method. It always returns basic information: `basicInfo`, like `label` and `then`, exists only in workflow JSON. It takes the other parameters below and returns the result below in `data`. Each `retrieve…` flag of `fetchPerson` adds one child action, and its data comes back under its own property of `data`: | Flag | Child action | Configuration | Property of `data` | | --- | --- | --- | --- | | `retrieveExperience` | [`st.retrievePersonExperience`](/docs/action-st-retrieve-person-experience) | – | `experiences` | | `retrieveEducation` | [`st.retrievePersonEducation`](/docs/action-st-retrieve-person-education) | – | `education` | | `retrieveSkills` | [`st.retrievePersonSkills`](/docs/action-st-retrieve-person-skills) | – | `skills` | | `retrieveLanguages` | [`st.retrievePersonLanguages`](/docs/action-st-retrieve-person-languages) | – | `languages` | | `retrievePosts` | [`st.retrievePersonPosts`](/docs/action-st-retrieve-person-posts) | `postsRetrievalConfig`: `limit`, `since` | `posts` | | `retrieveComments` | [`st.retrievePersonComments`](/docs/action-st-retrieve-person-comments) | `commentsRetrievalConfig`: `limit`, `since` | `comments` | | `retrieveReactions` | [`st.retrievePersonReactions`](/docs/action-st-retrieve-person-reactions) | `reactionsRetrievalConfig`: `limit`, `since` | `reactions` | A configuration object takes effect only with its flag set to `true`, and takes the parameters of its child action – their defaults and limits are on the child's page. For example, the 50 most recent posts since a date: ::: :::surface node ```typescript const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrievePosts: true, postsRetrievalConfig: { limit: 50, since: "2026-01-01T00:00:00Z" } }); ``` ::: :::surface python ```python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_posts=True, posts_retrieval_config={"limit": 50, "since": "2026-01-01T00:00:00Z"}, ) ) ``` ::: :::surface node,python When a child action fails, its error comes back in `errors` and the rest of `data` is still returned. ::: :::surface cli In the CLI this is `linkedin person fetch`: ```bash linkedin person fetch [flags] ``` | Arg | Required | Description | | --- | --- | --- | | `url` | yes | LinkedIn profile URL | | Flag | Type | Description | | --- | --- | --- | | `--experience` | boolean | Include work experience | | `--education` | boolean | Include education history | | `--skills` | boolean | Include skills | | `--languages` | boolean | Include languages | | `--posts` | boolean | Include recent posts | | `--comments` | boolean | Include recent comments | | `--reactions` | boolean | Include recent reactions | | `--posts-limit` | integer | Max posts to retrieve (requires `--posts`) | | `--posts-since` | string | Posts since ISO timestamp (requires `--posts`) | | `--comments-limit` | integer | Max comments to retrieve (requires `--comments`) | | `--comments-since` | string | Comments since ISO timestamp (requires `--comments`) | | `--reactions-limit` | integer | Max reactions to retrieve (requires `--reactions`) | | `--reactions-since` | string | Reactions since ISO timestamp (requires `--reactions`) | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `basicInfo` (optional, default: `false`) – when set to `true`, the action includes basic person information in the results. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful page opening with `basicInfo` set to `true`:** ```json { "actionType": "st.openPersonPage", "label": "person1", "success": true, "data": { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/person1", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "countryCode": "US", "position": "Software Engineer", "about": "Software Engineer with 8+ years of experience building scalable web applications.", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678", "followersCount": 37, "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "viewerState": { "connectionDegree": "2nd" }, "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data` – basic information about the person and results of child actions execution. - `name` – full name of the person. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `hashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `headline` – headline of the person. - `location` – free-form string indicating the person's location. - `countryCode` – two-character code of the person's country. - `position` – current job position of the person. - `about` – "About" section text from the person's profile. - `companyName` – name of the person's current company. - `companyHashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person's current company. - `followersCount` – number of followers the person has. - `avatarUrl` – URL of the person's profile photo, or `null` if the person has no photo. - `viewerState` – how your account relates to the person, see [viewer state](/docs/data-conventions#viewer-state). - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` if LinkedIn does not show it. - `then` – results of child actions execution. 2. **Successful page opening with `basicInfo` set to `false`:** ```json { "actionType": "st.openPersonPage", "label": "person1", "success": true, "data": { "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data.then` – results of child actions execution. 3. **Failed page opening:** ```json { "actionType": "st.openPersonPage", "label": "person1", "success": false, "error": { "type": "personNotFound", "message": "The provided URL is not an existing LinkedIn person." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `personLikelyChangedPublicUrl` – the provided URL no longer opens, but this person appeared in your account's [network events](/docs/monitoring-network) before, so they most likely changed their public URL. Retry with their [hashed URL](/faq/what-are-hashed-url-and-public-url). - `selfProfileNotAllowed` – action cannot be performed on your own profile. ## st.retrievePersonExperience This action allows you to retrieve information about a person's experience. :::surface node,python In the SDK this action runs inside `fetchPerson` with `retrieveExperience` enabled; its result comes back in `data.experiences`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin person fetch` with `--experience`; that command's other options are on [`st.openPersonPage`](/docs/action-st-open-person-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePersonExperience", "label": "person1Experience" } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveExperience: true, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.experiences); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_experience=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: print(result.data.experiences) print(result.errors) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --experience --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options This action always completes successfully. Even if the person has no experience records, it returns an empty array. ```json { "actionType": "st.retrievePersonExperience", "label": "person1Experience", "success": true, "data": [ { "position": "Software Engineer", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678", "employmentType": "fullTime", "locationType": "onSite", "description": "Developing innovative software solutions.", "duration": 24, "startTime": "2021-01-01T00:00:00Z", "endTime": null, "location": "San Francisco, USA" }, { "position": "Junior Developer", "companyName": "CodeBase Inc.", "companyHashedUrl": "https://www.linkedin.com/company/87654321", "employmentType": "internship", "locationType": "remote", "description": "Worked on front-end development tasks.", "duration": 12, "startTime": "2020-01-01T00:00:00Z", "endTime": "2020-12-31T00:00:00Z", "location": null } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of person experience records. - `position` – job position held by the person. - `companyName` – name of the company where the person worked. - `companyHashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the company where the person worked. - `employmentType` – type of employment. Enum with the following values: - `fullTime` – full-time employment. - `partTime` – part-time employment. - `selfEmployed` – self-employed work. - `freelance` – freelance work. - `contract` – contract-based employment. - `internship` – internship position. - `apprenticeship` – apprenticeship program. - `seasonal` – seasonal employment. - `locationType` – type of location. Enum with the following values: - `remote` – position is fully remote. - `onSite` – position requires on-site work. - `hybrid` – position is a mix of remote and on-site work. - `description` – description of the job or responsibilities. - `duration` – number of months the person worked in the position. - `startTime` – timestamp of **the first day of the month** when the person started the position. - `endTime` – timestamp **of the last day of the month** when the person ended the position. Returns `null` if the person is still working in this position. - `location` – free-form string indicating the location of the position. ## st.retrievePersonEducation This action allows you to retrieve information about a person's education. :::surface node,python In the SDK this action runs inside `fetchPerson` with `retrieveEducation` enabled; its result comes back in `data.education`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin person fetch` with `--education`; that command's other options are on [`st.openPersonPage`](/docs/action-st-open-person-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePersonEducation", "label": "person1Education" } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveEducation: true, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.education); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_education=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: print(result.data.education) print(result.errors) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --education --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options This action always completes successfully. Even if the person has no education records, it returns an empty array. ```json { "actionType": "st.retrievePersonEducation", "label": "person1Education", "success": true, "data": [ { "schoolName": "Harvard University", "schoolHashedUrl": "https://www.linkedin.com/company/12345678", "details": "Master of Science in Computer Science, Artificial Intelligence" }, { "schoolName": "MIT", "schoolHashedUrl": "https://www.linkedin.com/company/87654321", "details": "Bachelor of Science in Electrical Engineering and Computer Science" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of person education records. - `schoolName` – name of the institution. - `schoolHashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the institution. - `details` – information about the person's education, such as the degree, major, field of study, and other related details. ## st.retrievePersonSkills This action allows you to retrieve information about a person's skills. :::surface node,python In the SDK this action runs inside `fetchPerson` with `retrieveSkills` enabled; its result comes back in `data.skills`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin person fetch` with `--skills`; that command's other options are on [`st.openPersonPage`](/docs/action-st-open-person-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** [st.doForSkills](/docs/action-st-do-for-skills). ## Parameters ```json tab=HTTP { "actionType": "st.retrievePersonSkills", "label": "person1Skills", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveSkills: true, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.skills); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_skills=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: print(result.data.skills) print(result.errors) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --skills --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `then` (optional) – [st.doForSkills](/docs/action-st-do-for-skills) action to [endorse](/docs/endorsing-skills) all of the person's skills or only those whose names match the patterns you specify. ## Result options This action always completes successfully. Even if the person has no mentioned skills, it returns an empty array. ```json { "actionType": "st.retrievePersonSkills", "label": "person1Skills", "success": true, "data": [ { "name": "Computer Science" }, { "name": "Java" }, { "name": "Project Management" }, { "name": "Marketing" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of person skills. - `name` – name of the skill, as the profile owner entered it, in the owner's language. - `then` – array with the results of child actions. Present only on the skills that [st.doForSkills](/docs/action-st-do-for-skills) selected. ## st.endorsePersonSkill This action allows you to endorse a skill on the profile of a person who is your 1st-degree connection. :::surface node,python In the SDK this is the `endorsePersonSkill` method: it endorses one skill by name and returns no `data`, with a failed endorsement in `errors`. To endorse every skill whose name matches `skillNamePatterns`, or all of them, use `endorsePersonSkills`: it returns in `data` one item per selected skill, with `skillName`, `success` and `error`, so check every item – see [Endorsing skills](/docs/endorsing-skills). ::: :::surface cli In the CLI this is `linkedin person endorse --skill `. `--pattern ` (repeatable) or `--all` endorse every matching skill instead and print one item per selected skill, with `skillName`, `success` and `error`. ::: > LinkedIn lets you endorse skills only of your **1st-degree connections**. For anyone else, the action endorses nothing and returns `connectionNotFound`. > The action never removes an endorsement. If you have already endorsed the skill, the action leaves it as it is and returns success. > To endorse every skill whose name matches one or more patterns, use [st.doForSkills](/docs/action-st-do-for-skills) under [st.retrievePersonSkills](/docs/action-st-retrieve-person-skills). ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` and `skillName` parameters are provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page), [st.doForSkills](/docs/action-st-do-for-skills). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.endorsePersonSkill", "personUrl": "https://www.linkedin.com/in/person1", "skillName": "Project Management", "label": "person1Skill" } ``` ```typescript tab=Node const workflow = await linkedapi.endorsePersonSkill.execute({ personUrl: "https://www.linkedin.com/in/person1", skillName: "Project Management" }); const { errors } = await linkedapi.endorsePersonSkill.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import EndorsePersonSkillParams workflow = linkedapi.endorse_person_skill.execute( EndorsePersonSkillParams( person_url="https://www.linkedin.com/in/person1", skill_name="Project Management", ) ) result = linkedapi.endorse_person_skill.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person endorse https://www.linkedin.com/in/person1 \ --skill "Project Management" ``` - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person whose skill you want to endorse. - `skillName` (required for root start and under [st.openPersonPage](/docs/action-st-open-person-page), forbidden under [st.doForSkills](/docs/action-st-do-for-skills)) – name of the skill to endorse, up to 200 characters. It must equal the skill's name on the person's profile; letter case and extra spaces are ignored. It is not a pattern. Skill names are shown as the profile owner entered them, in the owner's language, whatever your own LinkedIn language: a German profile lists `Projektmanagement`, not `Project Management`. Under [st.doForSkills](/docs/action-st-do-for-skills), the skill comes from the iteration. - `label` (optional) – custom label for tracking this action in workflow completion. The action searches the person's full skills list, not only the top skills shown on their profile page. Each successful execution counts toward the `stEndorsements` [limit](/docs/admin-limits#endorsement-limits), including one where the skill was already endorsed. ## Result options 1. **Successful skill endorsement:** ```json { "actionType": "st.endorsePersonSkill", "success": true, "label": "person1Skill" } ``` - `label` – included only if specified in the action parameters. 2. **Failed skill endorsement:** ```json { "actionType": "st.endorsePersonSkill", "success": false, "label": "person1Skill", "error": { "type": "skillNotFound", "message": "The person has no skill with this name." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `connectionNotFound` – person is not your 1st-degree connection, so LinkedIn doesn't let you endorse their skills. - `skillNotFound` – person's profile has no skill with this name. - `endorsementUnavailable` – person has the skill, but LinkedIn offers no way to endorse it, for example because they turned endorsements off. ## st.retrievePersonLanguages This action allows you to retrieve information about a person's languages. :::surface node,python In the SDK this action runs inside `fetchPerson` with `retrieveLanguages` enabled; its result comes back in `data.languages`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin person fetch` with `--languages`; that command's other options are on [`st.openPersonPage`](/docs/action-st-open-person-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePersonLanguages", "label": "person1Languages" } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveLanguages: true, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.languages); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_languages=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: print(result.data.languages) print(result.errors) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --languages --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options This action always completes successfully. Even if the person has no mentioned languages, it returns an empty array. ```json { "actionType": "st.retrievePersonLanguages", "label": "person1Languages", "success": true, "data": [ { "name": "English", "proficiency": "nativeOrBilingual" }, { "name": "Spanish", "proficiency": "professionalWorking" }, { "name": "French", "proficiency": "limitedWorking" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of person languages. - `name` – name of the language. - `proficiency` – proficiency level in the language. Enum with the following possible values: - `elementary` – basic understanding. - `limitedWorking` – limited ability for routine tasks. - `professionalWorking` – effective in professional settings. - `fullProfessional` – near-native proficiency. - `nativeOrBilingual` – fluent, like a native speaker. ## st.retrievePersonPosts This action allows you to retrieve posts published by a person and perform additional post-related actions if needed. :::surface node,python In the SDK this action runs inside `fetchPerson` with `retrievePosts` enabled; its result comes back in `data.posts`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin person fetch` with `--posts`; that command's other options are on [`st.openPersonPage`](/docs/action-st-open-person-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** [st.doForPosts](/docs/action-st-do-for-posts). ## Parameters ```json tab=HTTP { "actionType": "st.retrievePersonPosts", "label": "person1Posts", "limit": 10, "since": "2023-01-01T00:00:00Z", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrievePosts: true, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.posts); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_posts=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: print(result.data.posts) print(result.errors) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --posts --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – number of posts to retrieve. Defaults to **20**, with a maximum value of **100**. - `since` (optional) – timestamp to filter posts published after the specified time. - `then` (optional) – object or array of child actions to be executed within this action. **Note**: both `limit` and `since` conditions are applied simultaneously. For example: - If `limit` is 10, but there are more than 10 posts since the specified timestamp, only 10 posts will be returned. - If `limit` is 15, but only 5 posts exist after the specified timestamp, only those 5 posts will be returned. ## Result options This action always completes successfully. Even if the person has no posts, it returns an empty array. ```json { "actionType": "st.retrievePersonPosts", "label": "person1Posts", "success": true, "data": [ { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/example-person", "headline": "Product Marketing Lead", "viewerState": { "connectionDegree": "2nd" } }, "reposter": null, "text": "Check out our latest product launch!", "repostText": null, "hashtags": ["product", "launch"], "mentions": ["https://www.linkedin.com/company/example-company"], "externalLinks": ["https://example.com/launch"], "images": [ "https://static.linkedin.com/image1.jpg", "https://static.linkedin.com/image2.jpg" ], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 12, "viewerState": { "reaction": "like", "isOwn": false }, "then": { ... } }, { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2023-01-01T09:15:00Z", "type": "repost", "author": { "type": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", "companyUrl": "https://www.linkedin.com/company/example-company" }, "reposter": { "type": "person", "name": "Example Reposter", "urn": "urn:li:member:234567890", "profileUrl": "https://www.linkedin.com/in/example-reposter", "headline": null }, "text": "Original post content about the webinar.", "repostText": "A useful summary for anyone planning a launch.", "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": true, "videoThumbnail": "https://media.licdn.com/dms/image/video-cover.jpg", "hasPoll": false, "reactionsCount": 6, "commentsCount": 0, "repostsCount": 1, "viewerState": { "reaction": null, "isOwn": false }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of person posts with results of child actions execution. - `url` – URL of the post. - `activityUrn` – LinkedIn activity or UGC URN of the post, if available. - `time` – timestamp when the post was published. - `type` – type of the post. Enum with possible values: - `original` – for original posts. - `repost` – for reposts. - `author` – original content creator. Can be `null` if the actor cannot be parsed. - For person authors: `type`, `name`, `urn`, `profileUrl`, `headline`, `viewerState` with `connectionDegree` – `1st`, `2nd`, `3rd+`, or `null` if LinkedIn does not show it (see [viewer state](/docs/data-conventions#viewer-state)). - For company authors: `type`, `name`, `urn`, `companyUrl`. - `reposter` – person or company that reshared the post. Non-null only when `type` is `repost`. - For person reposters: `type`, `name`, `urn`, `profileUrl`, `headline`. - For company reposters: `type`, `name`, `urn`, `companyUrl`. - `urn` on an actor – [URN](/docs/data-conventions#linkedin-urns) of that person or company, or `null` when LinkedIn does not expose an identifier for the actor. - `text` – original author's post text, if available. - `repostText` – text added by the reposter on a repost with comment, if available. - `hashtags` – array of hashtags found in the post text, without leading `#`. - `mentions` – array of person and company profile URLs found in the post text. - `externalLinks` – array of outbound URLs found in the post text. - `images` – array of up to 3 preview image URLs, if available. - `documentSlides` – array of carousel or document slide image URLs, if available. - `hasVideo` – boolean indicating if the post contains a video. - `videoThumbnail` – URL of the video thumbnail, if available. - `hasPoll` – boolean indicating if the post contains a poll. - `reactionsCount` – number of reactions on the post. - `commentsCount` – number of comments on the post. - `repostsCount` – number of reposts on the post. - `viewerState` – what your account has done with the post, see [viewer state](/docs/data-conventions#viewer-state). - `reaction` – your reaction to the post (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`), or `null` if you have not reacted or it could not be read. - `isOwn` – `true` if your account is the post author, `false` if someone else is, or `null` if this cannot be told. - `then` – results of child actions execution. ## st.retrievePersonComments This action allows you to retrieve comments left by a person. :::surface node,python In the SDK this action runs inside `fetchPerson` with `retrieveComments` enabled; its result comes back in `data.comments`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin person fetch` with `--comments`; that command's other options are on [`st.openPersonPage`](/docs/action-st-open-person-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePersonComments", "label": "person1Comments", "limit": 10, "since": "2023-01-01T00:00:00Z" } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveComments: true, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.comments); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_comments=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: print(result.data.comments) print(result.errors) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --comments --json ``` - `limit` (optional) – number of comments to retrieve. Defaults to **20**, with a maximum value of **100**. - `since` (optional) – timestamp to filter comments made after the specified time. - `label` (optional) – custom label for tracking this action in workflow completion. **Note**: both `limit` and `since` conditions are applied simultaneously. For example: - If `limit` is 10, but there are more than 10 comments since the specified timestamp, only 10 comments will be returned. - If `limit` is 15, but only 5 comments exist after the specified timestamp, only those 5 reactions will be returned. ## Result options This action always completes successfully. Even if the person has no comments, it returns an empty array. ```json { "actionType": "st.retrievePersonComments", "label": "person1Comments", "success": true, "data": [ { "postUrl": "https://www.linkedin.com/posts/example1", "time": "2023-01-02T10:30:00Z", "text": "Great work!", "image": null, "reactionsCount": 3 }, { "postUrl": "https://www.linkedin.com/posts/example2", "time": "2023-01-03T10:30:00Z", "text": "Feeling the same :)", "image": "https://static.linkedin.com/image1.jpg", "reactionsCount": 0 } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of person comments. - `postUrl` – URL of the post the comment belongs to. - `time` – timestamp when the comment was left. - `text` – text content of the comment, if available. - `image` – URL of the comment's image, if available. - `reactionsCount` – number of reactions on the comment. ## st.retrievePersonReactions This action allows you to retrieve reactions made by a person. :::surface node In the Node SDK this action runs inside `fetchPerson` with `retrieveReactions` enabled; its result comes back in `data.reactions`, and its errors in `errors`. > Up to `@linkedapi/node` 2.3.19 the `TReaction` type declares the reaction kind as `reactionType`, while the API returns it as `type`, described below. At runtime the value is in `type`; `reactionType` is always undefined. ::: :::surface python In the Python SDK this action runs inside `fetch_person` with `retrieve_reactions` enabled; its result comes back in `result.data.reactions`, and its errors in `result.errors`. > Up to `linkedapi` 1.4.7 the `Reaction` model drops the reaction kind: the API returns it as `type`, described below, but `reaction_type` is always `None` and the model has no `type`. To read it, run the same workflow with [`custom_workflow`](/sdks/custom-workflow), whose completion keeps every field as the API returns it. ::: :::surface cli In the CLI this action runs inside `linkedin person fetch` with `--reactions`; that command's other options are on [`st.openPersonPage`](/docs/action-st-open-person-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePersonReactions", "label": "person1Reactions", "limit": 10, "since": "2023-01-01T00:00:00Z" } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPerson.execute({ personUrl: "https://www.linkedin.com/in/person1", retrieveReactions: true, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.reactions); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/person1", retrieve_reactions=True, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: print(result.data.reactions) print(result.errors) ``` ```bash tab=CLI linkedin person fetch https://www.linkedin.com/in/person1 --reactions --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – number of reactions to retrieve. Defaults to **20**, with a maximum value of **100**. - `since` (optional) – timestamp to filter reactions made after the specified time. **Note**: both `limit` and `since` conditions are applied simultaneously. For example: - If `limit` is 10, but there are more than 10 reactions since the specified timestamp, only 10 reactions will be returned. - If `limit` is 15, but only 5 reactions exist after the specified timestamp, only those 5 reactions will be returned. ## Result options This action always completes successfully. Even if the person has no reactions, it returns an empty array. ```json { "actionType": "st.retrievePersonReactions", "label": "person1Reactions", "success": true, "data": [ { "postUrl": "https://www.linkedin.com/posts/example1", "time": "2023-01-02T12:30:00Z", "type": "like" }, { "postUrl": "https://www.linkedin.com/posts/example2", "time": "2023-01-03T12:30:00Z", "type": "celebrate" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of person reactions. - `postUrl` – URL of the post the reaction belongs to. - `time` – timestamp when the reaction was made. - `type` – enum describing the reaction type. May take one of the following values: - `like` – standard "like". - `celebrate` – celebrates an achievement. - `support` – shows support. - `love` – expresses love or admiration. - `insightful` – appreciates insightful content. - `funny` – reacts to something humorous. ## st.searchCompanies This action allows you to search for companies applying various filtering criteria. :::surface node,python In the SDK this is the `searchCompanies` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin company search` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** [st.doForCompanies](/docs/action-st-do-for-companies). ## Parameters ```json tab=HTTP { "actionType": "st.searchCompanies", "label": "techIncSearch1", "term": "Tech Inc", "limit": 2, "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"] }, "customSearchUrl": "https://www.linkedin.com/search/results/companies/?companySize=%5B%22B%22%5D&keywords=Linked%20API", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.searchCompanies.execute({ term: "Tech Inc", limit: 2, filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York"], industries: ["Software Development", "Robotics Engineering"] }, customSearchUrl: "https://www.linkedin.com/search/results/companies/?companySize=%5B%22B%22%5D&keywords=Linked%20API" }); const { data, errors } = await linkedapi.searchCompanies.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchCompaniesParams workflow = linkedapi.search_companies.execute( SearchCompaniesParams( term="Tech Inc", limit=2, filter={ "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], }, custom_search_url="https://www.linkedin.com/search/results/companies/?companySize=%5B%22B%22%5D&keywords=Linked%20API", ) ) result = linkedapi.search_companies.result(workflow.workflow_id) ``` ```bash tab=CLI # Not available in the CLI: customSearchUrl linkedin company search \ --term "Tech Inc" \ --limit 2 \ --sizes 51-200,201-500 \ --locations "San Francisco,New York" \ --industries "Software Development,Robotics Engineering" ``` In AI assistants, the MCP server runs this action as the `search_companies` tool. :::surface cli In the CLI this is `linkedin company search`: ```bash linkedin company search [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--term` | string | Search keyword or phrase | | `--limit` | integer | Max results to return | | `--sizes` | string | Company sizes, comma-separated (`1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+`) | | `--locations` | string | Comma-separated locations | | `--industries` | string | Comma-separated industries | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `term` (optional) – keyword or phrase to search, from 1 to 50 characters. Either `term` or `customSearchUrl` must be provided; a request with neither (for example, with `filter` only) is rejected. - `limit` (optional) – number of search results to return. Defaults to **10**, with a maximum value of **1000**. - `filter` (optional) – object that specifies filtering criteria for companies. When multiple filter fields are specified, they are combined using `AND` logic. - `sizes` (optional) – array of enums representing employee count ranges. Matches if company’s size falls within any of the listed ranges. Options: - `1-10`. - `11-50`. - `51-200`. - `201-500`. - `501-1000`. - `1001-5000`. - `5001-10000`. - `10001+`. - `locations` (optional) – array of free-form strings representing locations. Matches if company is headquartered in any of the listed locations. - `industries` (optional) – array of enums representing industries. Matches if company operates in any of the listed industries. Takes specific values available in the LinkedIn interface. - `customSearchUrl` (optional) – URL copied from LinkedIn search results page after configuring desired filters. When specified, overrides term and filter parameters. Allows using any search configuration available in the LinkedIn interface. Either `term` or `customSearchUrl` must be provided. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful search:** ```json { "actionType": "st.searchCompanies", "label": "techIncSearch1", "success": true, "data": [ { "name": "TechCorp", "urn": "urn:li:organization:1234567", "publicUrl": "https://www.linkedin.com/company/techcorp", "industry": "Information Technology", "location": "California", "logoUrl": "https://media.licdn.com/dms/image/v2/C4D0BAQE/company-logo_100_100/0/1700000000000", "then": { ... } }, { "name": "Techical Life", "urn": null, "publicUrl": "https://www.linkedin.com/company/techlife", "industry": "Software Development", "location": "Mountain View", "logoUrl": null, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of search outputs with results of child actions execution. - `name` – name of the company. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the company, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the company. - `industry` – enum representing the company industry. Takes specific values available in the LinkedIn interface. - `location` – free-form string representing the company headquarters location. - `logoUrl` – URL of the company's logo, or `null` if the company has no logo. - `then` – results of child actions execution. 2. **Failed search:** ```json { "actionType": "st.searchCompanies", "label": "techIncSearch1", "success": false, "error": { "type": "searchingNotAllowed", "message": "LinkedIn has blocked performing the search." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – LinkedIn offered no matching option for one or more filter values. The message lists each field and value, so correct or remove them rather than retrying. - `searchingNotAllowed` – LinkedIn has blocked performing the search due to exceeding limits or other restrictions. ## st.openCompanyPage This action allows you to open a company page to retrieve its basic information and perform additional company-related actions if needed. :::surface node In the Node SDK this is `linkedapi.fetchCompany` – the call is below, under [Parameters](#parameters). ::: :::surface python In the Python SDK this is `linkedapi.fetch_company` – the call is below, under [Parameters](#parameters). ::: :::surface cli In the CLI this is `linkedin company fetch` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `companyUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.doForCompanies](/docs/action-st-do-for-companies), [nv.openCompanyPage](/docs/action-nv-open-company-page). > ⬇️ **Child Actions:** [st.retrieveCompanyEmployees](/docs/action-st-retrieve-company-employees), [st.retrieveCompanyDMs](/docs/action-st-retrieve-company-dms), [st.retrieveCompanyPosts](/docs/action-st-retrieve-company-posts), [nv.openCompanyPage](/docs/action-nv-open-company-page). ## Parameters ```json tab=HTTP { "actionType": "st.openCompanyPage", "label": "company1", "companyUrl": "https://www.linkedin.com/company/company1", "basicInfo": true, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1" }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin company fetch https://www.linkedin.com/company/company1 ``` In AI assistants, the MCP server runs this action as the `fetch_company` tool. :::surface node,python In the SDK this is the `fetchCompany` method. It always returns basic information: `basicInfo`, like `label` and `then`, exists only in workflow JSON. It takes the other parameters below and returns the result below in `data`. Each `retrieve…` flag of `fetchCompany` adds one child action, and its data comes back under its own property of `data`: | Flag | Child action | Configuration | Property of `data` | | --- | --- | --- | --- | | `retrieveEmployees` | [`st.retrieveCompanyEmployees`](/docs/action-st-retrieve-company-employees) | `employeesRetrievalConfig`: `limit`, `filter` | `employees` | | `retrieveDMs` | [`st.retrieveCompanyDMs`](/docs/action-st-retrieve-company-dms) | `dmsRetrievalConfig`: `limit` | `dms` | | `retrievePosts` | [`st.retrieveCompanyPosts`](/docs/action-st-retrieve-company-posts) | `postsRetrievalConfig`: `limit`, `since` | `posts` | A configuration object takes effect only with its flag set to `true`, and takes the parameters of its child action – their defaults, limits and the employee `filter` fields are on the child's page. For example, up to 5 decision makers: ::: :::surface node ```typescript const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1", retrieveDMs: true, dmsRetrievalConfig: { limit: 5 } }); ``` ::: :::surface python ```python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", retrieve_dms=True, dms_retrieval_config={"limit": 5}, ) ) ``` ::: :::surface node,python When a child action fails, its error comes back in `errors` and the rest of `data` is still returned. ::: :::surface cli In the CLI this is `linkedin company fetch`: ```bash linkedin company fetch [flags] ``` | Arg | Required | Description | | --- | --- | --- | | `url` | yes | LinkedIn company URL | | Flag | Type | Description | | --- | --- | --- | | `--employees` | boolean | Include employee data | | `--dms` | boolean | Include decision makers | | `--posts` | boolean | Include company posts | | `--employees-limit` | integer | Max employees to retrieve | | `--employees-first-name` | string | Filter employees by first name | | `--employees-last-name` | string | Filter employees by last name | | `--employees-position` | string | Filter employees by position | | `--employees-locations` | string | Filter employees by locations, comma-separated | | `--employees-industries` | string | Filter employees by industries, comma-separated | | `--employees-schools` | string | Filter employees by schools, comma-separated | | `--employees-connection-degrees` | string | Filter employees by connection degrees (`1st`, `2nd`, `3rd+`), comma-separated | | `--dms-limit` | integer | Max decision makers to retrieve | | `--posts-limit` | integer | Max posts to retrieve | | `--posts-since` | string | Posts since ISO timestamp | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `companyUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the company. - `basicInfo` (optional, default: `false`) – when set to `true`, the action includes basic company information in the results. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful page opening with `basicInfo` set to `true`:** ```json { "actionType": "st.openCompanyPage", "label": "company1", "success": true, "data": { "name": "TechCorp", "urn": "urn:li:organization:1234567", "publicUrl": "https://www.linkedin.com/company/company1", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "Cupertino, California", "headquarters": "US", "industry": "Information Technology", "specialties": "Cloud Computing, AI, Software Development", "website": "https://techcorp.com", "employeesCount": 500, "yearFounded": 2019, "jobsCount": 12, "logoUrl": "https://media.licdn.com/dms/image/v2/C4D0BAQE/company-logo_200_200/0/1700000000000", "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data` – basic information about the company and results of child actions execution. - `name` – name of the company. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the company, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the company. - `description` – description of the company. - `location` – free-form string representing the company headquarters location. - `headquarters` – two-character country code (e.g., "US", "UK") representing headquarters location. - `industry` – enum representing the company industry. Takes specific values available in the LinkedIn interface. - `specialties` – comma-separated list of company's specialties. - `website` – company's official website URL. - `employeesCount` – total number of employees associated with the company. - `yearFounded` – year the company was established, if available. - `jobsCount` – number of current job vacancies posted by the company. - `logoUrl` – URL of the company's logo, or `null` if the company has no logo. - `then` – results of child actions execution. 2. **Successful page opening with `basicInfo` set to `false`:** ```json { "actionType": "st.openCompanyPage", "label": "company1", "success": true, "data": { "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data.then` – results of child actions execution. 3. **Failed page opening:** ```json { "actionType": "st.openCompanyPage", "label": "company1", "success": false, "error": { "type": "companyNotFound", "message": "The provided URL is not an existing LinkedIn company." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `companyNotFound` – provided URL is not an existing LinkedIn company. ## st.retrieveCompanyEmployees This action allows you to retrieve company employees and perform additional person-related actions if needed. :::surface node,python In the SDK this action runs inside `fetchCompany` with `retrieveEmployees` enabled; its result comes back in `data.employees`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin company fetch` with `--employees`; that command's other options are on [`st.openCompanyPage`](/docs/action-st-open-company-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openCompanyPage](/docs/action-st-open-company-page). > ⬇️ **Child Actions:** [st.doForPeople](/docs/action-st-do-for-people). ## Parameters ```json tab=HTTP { "actionType": "st.retrieveCompanyEmployees", "label": "company1Employees", "limit": 300, "filter": { "firstName": "John", "lastName": "Doe", "position": "Manager", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "schools": ["Harvard University", "MIT"], "connectionDegrees": ["2nd"] }, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1", retrieveEmployees: true, }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); console.log(data?.employees); console.log(errors); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", retrieve_employees=True, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) if result.data: print(result.data.employees) print(result.errors) ``` ```bash tab=CLI linkedin company fetch https://www.linkedin.com/company/company1 --employees --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` – (optional) maximum number of employees to retrieve. Defaults to **10**, with a maximum value of **1000**. - `filter` (optional) – object that specifies filtering criteria for employees. When multiple filter fields are specified, they are combined using `AND` logic. - `firstName` (optional) – first name of employee. - `lastName` (optional) – last name of employee. - `position` (optional) – job position of employee. - `locations` (optional) – array of free-form strings representing locations. Matches if employee is located in any of the listed locations. - `industries` (optional) – array of enums representing industries. Matches if employee works in any of the listed industries. Takes specific values available in the LinkedIn interface. - `schools` (optional) – array of institution names. Matches if employee currently attends or previously attended any of the listed institutions. - `connectionDegrees` (optional) – array of enums representing your connection degree to the employee. Matches if the employee is at any of the listed degrees. Options: - `1st` – your 1st-degree connections. - `2nd` – your 2nd-degree connections. - `3rd+` – 3rd-degree connections and beyond. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful retrieval:** ```json { "actionType": "st.retrieveCompanyEmployees", "label": "company1Employees", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Manager at TechCorp", "location": "New York, USA", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "viewerState": { "connectionDegree": "2nd" }, "then": { ... } }, { "name": "Jane Smith", "urn": null, "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "avatarUrl": null, "viewerState": { "connectionDegree": "2nd" }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of company employees with results of child actions execution. - `name` – full name of the employee. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the employee, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the employee. - `headline` – headline of the employee. - `location` – free-form string indicating the employee's location. - `avatarUrl` – URL of the employee's profile photo, or `null` if the employee has no photo. - `viewerState` – how your account relates to the employee, see [viewer state](/docs/data-conventions#viewer-state). - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` if LinkedIn does not show it. 2. **Failed retrieval:** ```json { "actionType": "st.retrieveCompanyEmployees", "label": "company1Employees", "success": false, "error": { "type": "retrievingNotAllowed", "message": "LinkedIn has blocked performing the retrieval." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – LinkedIn offered no matching option for one or more filter values. The message lists each field and value, so correct or remove them rather than retrying. - `retrievingNotAllowed` – LinkedIn has blocked performing the retrieval due to exceeding limits or other restrictions. ## st.retrieveCompanyDMs This action allows you to retrieve company decision makers and perform additional person-related actions if needed. :::surface node,python In the SDK this action runs inside `fetchCompany` with `retrieveDMs` enabled; its result comes back in `data.dms`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin company fetch` with `--dms`; that command's other options are on [`st.openCompanyPage`](/docs/action-st-open-company-page). ::: > Decision makers are ranked by seniority, starting with the highest-level positions. For example, founders and C-level executives are returned first, followed by VPs, directors, and so on. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openCompanyPage](/docs/action-st-open-company-page). > ⬇️ **Child Actions:** [st.doForPeople](/docs/action-st-do-for-people). ## Parameters ```json tab=HTTP { "actionType": "st.retrieveCompanyDMs", "label": "company1DMs", "limit": 3, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1", retrieveDMs: true, }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); console.log(data?.dms); console.log(errors); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", retrieve_dms=True, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) if result.data: print(result.data.dms) print(result.errors) ``` ```bash tab=CLI linkedin company fetch https://www.linkedin.com/company/company1 --dms --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – number of decision makers to retrieve. Defaults to **20**, with a maximum value of **20**. If a company has fewer decision makers than specified, only the available ones will be returned. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful retrieval:** ```json { "actionType": "st.retrieveCompanyDMs", "label": "company1DMs", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder of TechCorp", "location": "New York, USA", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "countryCode": "US", "viewerState": { "connectionDegree": "2nd" }, "then": { ... } }, { "name": "Jane Smith", "urn": null, "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CEO at TechCorp", "location": "San Francisco, USA", "avatarUrl": null, "countryCode": "US", "viewerState": { "connectionDegree": "3rd+" }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of decision makers with results of child actions execution. - `name` – full name of the decision-maker. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the decision-maker, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the decision-maker. - `headline` – headline of the decision-maker. - `location` – free-form string indicating the decision-maker's location. - `avatarUrl` – URL of the decision-maker's profile photo, or `null` if the decision-maker has no photo. - `countryCode` – two-character code of the decision-maker's country. - `viewerState` – how your account relates to the decision-maker, see [viewer state](/docs/data-conventions#viewer-state). - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` if LinkedIn does not show it. - `then` – results of child actions execution. 2. **Failed retrieval:** ```json { "actionType": "st.retrieveCompanyDMs", "label": "company1DMs", "success": false, "error": { "type": "retrievingNotAllowed", "message": "LinkedIn has blocked performing the retrieval." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `retrievingNotAllowed` – LinkedIn has blocked performing the retrieval due to exceeding limits or other restrictions. ## st.retrieveCompanyPosts This action allows you to retrieve posts published by a company and perform additional post-related actions if needed. :::surface node,python In the SDK this action runs inside `fetchCompany` with `retrievePosts` enabled; its result comes back in `data.posts`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin company fetch` with `--posts`; that command's other options are on [`st.openCompanyPage`](/docs/action-st-open-company-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.openCompanyPage](/docs/action-st-open-company-page). > ⬇️ **Child Actions:** [st.doForPosts](/docs/action-st-do-for-posts). ## Parameters ```json tab=HTTP { "actionType": "st.retrieveCompanyPosts", "label": "company1Posts", "limit": 10, "since": "2023-01-01T00:00:00Z", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchCompany.execute({ companyUrl: "https://www.linkedin.com/company/company1", retrievePosts: true, }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); console.log(data?.posts); console.log(errors); ``` ```python tab=Python from linkedapi import FetchCompanyParams workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/company1", retrieve_posts=True, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) if result.data: print(result.data.posts) print(result.errors) ``` ```bash tab=CLI linkedin company fetch https://www.linkedin.com/company/company1 --posts --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – number of posts to retrieve. Defaults to **20**, with a maximum value of **20**. - `since` (optional) – timestamp to filter posts published after the specified time. - `then` (optional) – object or array of child actions to be executed within this action. **Note**: both `limit` and `since` conditions are applied simultaneously. For example: - If `limit` is 10, but there are more than 10 posts since the specified timestamp, only 10 posts will be returned. - If `limit` is 15, but only 5 posts exist after the specified timestamp, only those 5 posts will be returned. ## Result options This action always completes successfully. Even if the company has no posts, it returns an empty array. ```json { "actionType": "st.retrieveCompanyPosts", "label": "company1Posts", "success": true, "data": [ { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", "companyUrl": "https://www.linkedin.com/company/example-company" }, "reposter": null, "text": "Check out our latest product launch!", "repostText": null, "hashtags": ["product", "launch"], "mentions": [], "externalLinks": ["https://example.com/launch"], "images": [ "https://static.linkedin.com/image1.jpg", "https://static.linkedin.com/image2.jpg" ], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 2, "viewerState": { "reaction": "like", "isOwn": false }, "then": { ... } }, { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2023-01-01T09:15:00Z", "type": "repost", "author": { "type": "person", "name": "Example Author", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/example-author", "headline": null, "viewerState": { "connectionDegree": "2nd" } }, "reposter": { "type": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", "companyUrl": "https://www.linkedin.com/company/example-company" }, "text": "Original post content about the webinar.", "repostText": "Thank you to everyone who joined our webinar.", "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": true, "videoThumbnail": "https://media.licdn.com/dms/image/video-cover.jpg", "hasPoll": false, "reactionsCount": 6, "commentsCount": 0, "repostsCount": 1, "viewerState": { "reaction": null, "isOwn": false }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of company posts with results of child actions execution. - `url` – URL of the post. - `activityUrn` – LinkedIn activity or UGC URN of the post, if available. - `time` – timestamp when the post was published. - `type` – type of the post. Enum with possible values: - `original` – for original posts. - `repost` – for reposts. - `author` – original content creator. Can be `null` if the actor cannot be parsed. - For person authors: `type`, `name`, `urn`, `profileUrl`, `headline`, `viewerState` with `connectionDegree` – `1st`, `2nd`, `3rd+`, or `null` if LinkedIn does not show it (see [viewer state](/docs/data-conventions#viewer-state)). - For company authors: `type`, `name`, `urn`, `companyUrl`. - `reposter` – person or company that reshared the post. Non-null only when `type` is `repost`. - For person reposters: `type`, `name`, `urn`, `profileUrl`, `headline`. - For company reposters: `type`, `name`, `urn`, `companyUrl`. - `urn` on an actor – [URN](/docs/data-conventions#linkedin-urns) of that person or company, or `null` when LinkedIn does not expose an identifier for the actor. - `text` – original author's post text, if available. - `repostText` – text added by the reposter on a repost with comment, if available. - `hashtags` – array of hashtags found in the post text, without leading `#`. - `mentions` – array of person and company profile URLs found in the post text. - `externalLinks` – array of outbound URLs found in the post text. - `images` – array of up to 3 preview image URLs, if available. - `documentSlides` – array of carousel or document slide image URLs, if available. - `hasVideo` – boolean indicating if the post contains a video. - `videoThumbnail` – URL of the video thumbnail, if available. - `hasPoll` – boolean indicating if the post contains a poll. - `reactionsCount` – number of reactions on the post. - `commentsCount` – number of comments on the post. - `repostsCount` – number of reposts on the post. - `viewerState` – what your account has done with the post, see [viewer state](/docs/data-conventions#viewer-state). - `reaction` – your reaction to the post (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`), or `null` if you have not reacted or it could not be read. - `isOwn` – `true` if your account is the post author, `false` if someone else is, or `null` if this cannot be told. - `then` – results of child actions execution. ## st.searchPosts This action allows you to search for posts applying various filtering criteria. :::surface node,python In the SDK this is the `searchPosts` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin post search` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** [st.doForPosts](/docs/action-st-do-for-posts). ## Parameters ```json tab=HTTP { "actionType": "st.searchPosts", "label": "climateTechSearch1", "term": "climate tech", "limit": 20, "filter": { "sort": "latest", "datePosted": "pastWeek", "contentType": "images", "postedBy": ["firstConnections", "peopleYouFollow"], "fromMembers": [ { "name": "Bill Gates", "id": "urn:li:member:251749025" } ], "fromCompanies": ["Example Company"], "mentioningMembers": [ { "name": "Example Person", "id": "https://www.linkedin.com/in/example-person" } ], "mentioningCompanies": [ { "name": "Another Company", "id": "https://www.linkedin.com/company/another-company" } ], "authorCompanies": [ { "name": "Example Company", "id": "urn:li:organization:1234567" } ], "authorIndustries": ["Software Development"] }, "customSearchUrl": "https://www.linkedin.com/search/results/content/?keywords=climate%20tech", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.searchPosts.execute({ term: "climate tech", limit: 20, filter: { sort: "latest", datePosted: "pastWeek", contentType: "images", postedBy: ["firstConnections", "peopleYouFollow"], fromMembers: [ { name: "Bill Gates", id: "urn:li:member:251749025" } ], fromCompanies: ["Example Company"], mentioningMembers: [ { name: "Example Person", id: "https://www.linkedin.com/in/example-person" } ], mentioningCompanies: [ { name: "Another Company", id: "https://www.linkedin.com/company/another-company" } ], authorCompanies: [ { name: "Example Company", id: "urn:li:organization:1234567" } ], authorIndustries: ["Software Development"] }, customSearchUrl: "https://www.linkedin.com/search/results/content/?keywords=climate%20tech" }); const { data, errors } = await linkedapi.searchPosts.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchPostsParams workflow = linkedapi.search_posts.execute( SearchPostsParams( term="climate tech", limit=20, filter={ "sort": "latest", "date_posted": "pastWeek", "content_type": "images", "posted_by": ["firstConnections", "peopleYouFollow"], "from_members": [ {"name": "Bill Gates", "id": "urn:li:member:251749025"}, ], "from_companies": ["Example Company"], "mentioning_members": [ { "name": "Example Person", "id": "https://www.linkedin.com/in/example-person", }, ], "mentioning_companies": [ { "name": "Another Company", "id": "https://www.linkedin.com/company/another-company", }, ], "author_companies": [ { "name": "Example Company", "id": "urn:li:organization:1234567", }, ], "author_industries": ["Software Development"], }, custom_search_url="https://www.linkedin.com/search/results/content/?keywords=climate%20tech", ) ) result = linkedapi.search_posts.result(workflow.workflow_id) ``` ```bash tab=CLI # Not available in the CLI: customSearchUrl linkedin post search \ --term "climate tech" \ --limit 20 \ --sort latest \ --date-posted pastWeek \ --content-type images \ --posted-by firstConnections,peopleYouFollow \ --author-industries "Software Development" \ --from-members "Bill Gates:urn:li:member:251749025" \ --from-companies "Example Company" \ --mentioning-members "Example Person:https://www.linkedin.com/in/example-person" \ --mentioning-companies "Another Company:https://www.linkedin.com/company/another-company" \ --author-companies "Example Company:urn:li:organization:1234567" ``` In AI assistants, the MCP server runs this action as the `search_posts` tool. :::surface cli In the CLI this is `linkedin post search`: ```bash linkedin post search [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--term` | string | Search keyword or phrase, up to 50 characters | | `--limit` | integer | Max results to return, up to 100 | | `--sort` | string | One of `topMatch`, `latest` | | `--date-posted` | string | One of `past24Hours`, `pastWeek`, `pastMonth` | | `--content-type` | string | One of `videos`, `images`, `jobPosts`, `liveVideos`, `documents` | | `--posted-by` | string | Comma-separated: `me`, `firstConnections`, `peopleYouFollow` | | `--from-members` | string | Comma-separated people whose posts to keep | | `--from-companies` | string | Comma-separated companies whose posts to keep | | `--mentioning-members` | string | Comma-separated people the post must mention | | `--mentioning-companies` | string | Comma-separated companies the post must mention | | `--author-companies` | string | Comma-separated companies the author works at | | `--author-industries` | string | Comma-separated industries the author works in | Each element of the member and company flags is `name` or `name:identifier`. The identifier is a URN or a LinkedIn URL of that person or company, and pins the filter to exactly them; a name alone may match a namesake. When no match is found, the action fails with `filterNotApplied`. ```bash linkedin post search --term "ai" --from-members "Bill Gates,Example Person:urn:li:member:123456789" --json ``` > A Sales Navigator URL that contains a comma cannot be used inside the comma-separated list. A search may return fewer posts than `--limit`, because how many results come back depends on what LinkedIn loads for that account on that search. LinkedIn also widens a narrow query on its own, so a non-empty result does not mean your term matched. See [searching for posts](/docs/searching-for-posts). > Search results carry no `urn` on their `author` and `reposter` – run `post fetch` on a result's `url` when you need one. ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `term` (optional) – keyword or phrase to search, from 1 to 50 characters. Either `term` or `customSearchUrl` must be provided; a request with neither (for example, with `filter` only) is rejected. - `limit` (optional) – number of search results to return. Defaults to **10**, with a maximum value of **100**, or **20** when child actions are used. A value above the applicable maximum is rejected when the workflow is submitted. A search may return fewer posts than `limit`, because how many results come back depends on what LinkedIn loads for that account on that search. - `filter` (optional) – object that specifies filtering criteria for posts. Every specified field is applied, or the action fails. When multiple filter fields are specified, they are combined using `AND` logic. Ignored entirely when `customSearchUrl` is specified. - `sort` (optional) – enum with possible values: - `topMatch`. - `latest`. - `datePosted` (optional) – enum with possible values: - `past24Hours`. - `pastWeek`. - `pastMonth`. - `contentType` (optional) – enum with possible values: - `videos`. - `images`. - `jobPosts`. - `liveVideos`. - `documents`. - `postedBy` (optional) – array of enums with possible values: - `me`. - `firstConnections`. - `peopleYouFollow`. - `fromMembers` (optional) – array of people whose posts to keep. Matches if the post was published by any of the listed people. - `fromCompanies` (optional) – array of companies whose posts to keep. Matches if the post was published by any of the listed companies. - `mentioningMembers` (optional) – array of people to look for in post text. Matches if the post mentions any of the listed people. - `mentioningCompanies` (optional) – array of companies to look for in post text. Matches if the post mentions any of the listed companies. - `authorCompanies` (optional) – array of companies the author works at. Matches if the post's author works at any of the listed companies. - `authorIndustries` (optional) – array of industry names the author works in. Matches if the post's author works in any of the listed industries. Takes specific values available in the LinkedIn interface. An industry is a taxonomy value rather than an entity, so it is matched by name only. - `customSearchUrl` (optional) – URL copied from a LinkedIn content search page after configuring desired filters. When specified, `filter` is ignored entirely and the facets already encoded in the URL are the only ones applied. Either `term` or `customSearchUrl` must be provided. - `then` (optional) – object or array of child actions to be executed within this action. LinkedIn widens a narrow query on its own and returns loosely related posts, so a non-empty result does not mean your `term` matched. Check the returned posts against your own criteria when an exact match matters. ### Identifying people and companies in filters Each entry of `fromMembers`, `fromCompanies`, `mentioningMembers`, `mentioningCompanies`, and `authorCompanies` is a name, or a name with an `id`: ```json { "fromMembers": [ "Bill Gates", { "name": "Satya Nadella", "id": "https://www.linkedin.com/in/satyanadella" } ], "fromCompanies": [ "Microsoft", { "name": "Example Company", "id": "urn:li:organization:1234567" } ] } ``` - `name` (required) – name of the person or company as typed into LinkedIn's filter panel, from 1 to 100 characters. When `id` is a member URN or a Sales Navigator URL, the person must appear among LinkedIn's suggestions for this name. - `id` (optional) – [URN](/docs/data-conventions#linkedin-urns), [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL (Sales Navigator URLs included), or, for a company, numeric ID. Pins the filter to exactly that person or company. A person's public URL is opened once from your account to read their identifier, so they may see one profile visit; a company's public URL is opened on every search, so prefer its URN or numeric ID. > A name alone may match a namesake: Linked API takes the LinkedIn suggestion whose name matches yours, ignoring case, punctuation and a trailing legal form such as `Inc` or `GmbH`, or else the first one that starts with it as a whole word, so `Anta` can become `Anta International` but never `Antal`. If no suggestion matches, the action fails with `filterNotApplied`. ## Result options 1. **Successful search:** ```json { "actionType": "st.searchPosts", "label": "climateTechSearch1", "success": true, "data": [ { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "profileUrl": "https://www.linkedin.com/in/example-person", "headline": "Product Marketing Lead", "viewerState": { "connectionDegree": "2nd" } }, "reposter": null, "text": "Check out our latest product launch!", "repostText": null, "hashtags": ["product", "launch"], "mentions": ["https://www.linkedin.com/company/example-company"], "externalLinks": ["https://example.com/launch"], "images": [ "https://static.linkedin.com/image1.jpg", "https://static.linkedin.com/image2.jpg" ], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 12, "viewerState": { "reaction": "like", "isOwn": false }, "then": { ... } }, { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2023-01-01T09:15:00Z", "type": "repost", "author": { "type": "company", "name": "Example Company", "companyUrl": "https://www.linkedin.com/company/example-company" }, "reposter": { "type": "person", "name": "Example Reposter", "profileUrl": "https://www.linkedin.com/in/example-reposter", "headline": null }, "text": "Original post content about the webinar.", "repostText": "A useful summary for anyone planning a launch.", "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": true, "videoThumbnail": "https://media.licdn.com/dms/image/video-cover.jpg", "hasPoll": false, "reactionsCount": 6, "commentsCount": 0, "repostsCount": 1, "viewerState": { "reaction": null, "isOwn": false }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of search outputs with results of child actions execution. - `url` – URL of the post. - `activityUrn` – LinkedIn activity or UGC URN of the post, if available. - `time` – timestamp when the post was published. - `type` – type of the post. Enum with possible values: - `original` – for original posts. - `repost` – for reposts. - `author` – original content creator. Can be `null` if the actor cannot be parsed. - For person authors: `type`, `name`, `profileUrl`, `headline`, `viewerState` with `connectionDegree` – `1st`, `2nd`, `3rd+`, or `null` if LinkedIn does not show it (see [viewer state](/docs/data-conventions#viewer-state)). - For company authors: `type`, `name`, `companyUrl`. - `reposter` – person or company that reshared the post. Non-null only when `type` is `repost`. - For person reposters: `type`, `name`, `profileUrl`, `headline`. - For company reposters: `type`, `name`, `companyUrl`. - `text` – original author's post text, if available. - `repostText` – text added by the reposter on a repost with comment, if available. - `hashtags` – array of hashtags found in the post text, without leading `#`. - `mentions` – array of person and company profile URLs found in the post text. - `externalLinks` – array of outbound URLs found in the post text. - `images` – array of up to 3 preview image URLs, if available. - `documentSlides` – array of carousel or document slide image URLs, if available. - `hasVideo` – boolean indicating if the post contains a video. - `videoThumbnail` – URL of the video thumbnail, if available. - `hasPoll` – boolean indicating if the post contains a poll. - `reactionsCount` – number of reactions on the post. - `commentsCount` – number of comments on the post. - `repostsCount` – number of reposts on the post. - `viewerState` – what your account has done with the post, see [viewer state](/docs/data-conventions#viewer-state). - `reaction` – your reaction to the post (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`), or `null` if you have not reacted or it could not be read. - `isOwn` – `true` if your account is the post author, `false` if someone else is, or `null` if this cannot be told. - `then` – results of child actions execution. > Unlike posts returned by [st.openPost](/docs/action-st-open-post), the `author` and `reposter` of a search result carry **no** `urn` field. The URN is read from the post's own page, so open the post with [st.doForPosts](/docs/action-st-do-for-posts) and [st.openPost](/docs/action-st-open-post) when you need it. 2. **Failed search:** ```json { "actionType": "st.searchPosts", "label": "climateTechSearch1", "success": false, "error": { "type": "searchingNotAllowed", "message": "LinkedIn has blocked performing the search." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – one or more filter values could not be applied: no LinkedIn option matched a name, or the person or company an `id` names could not be found or is not among LinkedIn's suggestions for `name`. It does not mean the person or company is gone; the message lists each field, value, and reason, so correct or remove the entry rather than retrying. - `searchingNotAllowed` – LinkedIn has blocked performing the search due to exceeding limits or other restrictions. ## st.openPost This action allows you to open a post to retrieve its data and perform additional post-related actions if needed. :::surface node In the Node SDK this is `linkedapi.fetchPost` – the call is below, under [Parameters](#parameters). ::: :::surface python In the Python SDK this is `linkedapi.fetch_post` – the call is below, under [Parameters](#parameters). ::: :::surface cli In the CLI this is `linkedin post fetch` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `postUrl` or `postUrn` parameter is provided. > ⬆️ **Parent Actions:** [st.createPost](/docs/action-st-create-post), [st.doForPosts](/docs/action-st-do-for-posts). > ⬇️ **Child Actions:** [st.reactToPost](/docs/action-st-react-to-post), [st.createRepost](/docs/action-st-create-repost), [st.commentOnPost](/docs/action-st-comment-on-post), [st.retrievePostComments](/docs/action-st-retrieve-post-comments), [st.retrievePostReactions](/docs/action-st-retrieve-post-reactions). ## Parameters ```json tab=HTTP { "actionType": "st.openPost", "label": "post1", "postUrl": "https://www.linkedin.com/posts/post1", "basicInfo": true, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPost.execute({ postUrl: "https://www.linkedin.com/posts/post1" }); const { data, errors } = await linkedapi.fetchPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchPostParams workflow = linkedapi.fetch_post.execute( FetchPostParams( post_url="https://www.linkedin.com/posts/post1", ) ) result = linkedapi.fetch_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post fetch https://www.linkedin.com/posts/post1 ``` In AI assistants, the MCP server runs this action as the `fetch_post` tool. :::surface node,python In the SDK this is the `fetchPost` method. It always returns basic information: `basicInfo`, like `label` and `then`, exists only in workflow JSON. It takes the other parameters below and returns the result below in `data`. Each `retrieve…` flag of `fetchPost` adds one child action, and its data comes back under its own property of `data`: | Flag | Child action | Configuration | Property of `data` | | --- | --- | --- | --- | | `retrieveComments` | [`st.retrievePostComments`](/docs/action-st-retrieve-post-comments) | `commentsRetrievalConfig`: `limit`, `sort`, `replies` | `comments` | | `retrieveReactions` | [`st.retrievePostReactions`](/docs/action-st-retrieve-post-reactions) | `reactionsRetrievalConfig`: `limit` | `reactions` | A configuration object takes effect only with its flag set to `true`, and takes the parameters of its child action – their defaults and limits are on the child's page. For example, the 20 most recent comments with their replies: ::: :::surface node ```typescript const workflow = await linkedapi.fetchPost.execute({ postUrl: "https://www.linkedin.com/posts/post1", retrieveComments: true, commentsRetrievalConfig: { limit: 20, sort: "mostRecent", replies: true } }); ``` ::: :::surface python ```python from linkedapi import FetchPostParams workflow = linkedapi.fetch_post.execute( FetchPostParams( post_url="https://www.linkedin.com/posts/post1", retrieve_comments=True, comments_retrieval_config={"limit": 20, "sort": "mostRecent", "replies": True}, ) ) ``` ::: :::surface node,python When a child action fails, its error comes back in `errors` and the rest of `data` is still returned. ::: :::surface cli In the CLI this is `linkedin post fetch`: ```bash linkedin post fetch [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--comments` | boolean | Include comments | | `--reactions` | boolean | Include reactions | | `--comments-limit` | integer | Max comments to retrieve | | `--comments-sort` | string | Sort order: `mostRelevant` or `mostRecent` | | `--comments-replies` | boolean | Include replies to comments | | `--reactions-limit` | integer | Max reactions to retrieve | > With `--comments`, each returned comment includes `commentUrn` and `commentUrl`. Pass a `commentUrl` to `comment react` / `comment reply` to engage with that comment. ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `postUrl` (required for root start unless `postUrn` is provided, forbidden for parent start) – LinkedIn URL of the post. - `postUrn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the post, accepted instead of `postUrl`. When both are provided, the URL must contain that same URN. - `basicInfo` (optional, default: `false`) – when set to `true`, the action includes basic post information in the results. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful post opening with `basicInfo` set to `true`:** ```json { "actionType": "st.openPost", "label": "post1", "success": true, "data": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/example-person", "headline": "Product Marketing Lead", "viewerState": { "connectionDegree": "2nd" } }, "reposter": null, "text": "Check out our latest product launch!", "repostText": null, "hashtags": ["product", "launch"], "mentions": ["https://www.linkedin.com/company/example-company"], "externalLinks": ["https://example.com/launch"], "images": [ "https://static.linkedin.com/image1.jpg", "https://static.linkedin.com/image2.jpg" ], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 12, "viewerState": { "reaction": "like", "isOwn": false }, "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data` – basic information about the post and results of child actions execution. - `url` – URL of the post. - `activityUrn` – LinkedIn activity or UGC URN of the post, if available. - `time` – timestamp when the post was published. - `type` – type of the post. Enum with possible values: - `original` – for original posts. - `repost` – for reposts. - `author` – original content creator. Can be `null` if the actor cannot be parsed. - For person authors: `type`, `name`, `urn`, `profileUrl`, `headline`, `viewerState` with `connectionDegree` – `1st`, `2nd`, `3rd+`, or `null` if LinkedIn does not show it (see [viewer state](/docs/data-conventions#viewer-state)). - For company authors: `type`, `name`, `urn`, `companyUrl`. - `reposter` – person or company that reshared the post. Non-null only when `type` is `repost`. - For person reposters: `type`, `name`, `urn`, `profileUrl`, `headline`. - For company reposters: `type`, `name`, `urn`, `companyUrl`. - `urn` on an actor – [URN](/docs/data-conventions#linkedin-urns) of that person or company, or `null` when LinkedIn does not expose an identifier for the actor. - `text` – original author's post text, if available. - `repostText` – text added by the reposter on a repost with comment, if available. - `hashtags` – array of hashtags found in the post text, without leading `#`. - `mentions` – array of person and company profile URLs found in the post text. - `externalLinks` – array of outbound URLs found in the post text. - `images` – array of up to 3 preview image URLs, if available. - `documentSlides` – array of carousel or document slide image URLs, if available. - `hasVideo` – boolean indicating if the post contains a video. - `videoThumbnail` – URL of the video thumbnail, if available. - `hasPoll` – boolean indicating if the post contains a poll. - `reactionsCount` – number of reactions on the post. - `commentsCount` – number of comments on the post. - `repostsCount` – number of reposts on the post. - `viewerState` – what your account has done with the post, see [viewer state](/docs/data-conventions#viewer-state). - `reaction` – your reaction to the post (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`), or `null` if you have not reacted or it could not be read. - `isOwn` – `true` if your account is the post author, `false` if someone else is, or `null` if this cannot be told. - `then` – results of child actions execution. > This action reads actor identifiers from the post page itself, so it resolves `urn` for actors that post listings such as [st.retrieveFeed](/docs/action-st-retrieve-feed) or [st.retrievePersonPosts](/docs/action-st-retrieve-person-posts) cannot. If you need URNs for the authors of many posts, open the posts you care about rather than relying on the listing alone. 2. **Successful repost opening with `basicInfo` set to `true`:** ```json { "actionType": "st.openPost", "label": "post1", "success": true, "data": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2023-01-03T09:15:00Z", "type": "repost", "author": { "type": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", "companyUrl": "https://www.linkedin.com/company/example-company" }, "reposter": { "type": "person", "name": "Example Reposter", "urn": "urn:li:member:234567890", "profileUrl": "https://www.linkedin.com/in/example-reposter", "headline": null }, "text": "Original post content about the webinar.", "repostText": "A useful summary for anyone planning a launch.", "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": true, "videoThumbnail": "https://media.licdn.com/dms/image/video-cover.jpg", "hasPoll": false, "reactionsCount": 6, "commentsCount": 0, "repostsCount": 1, "viewerState": { "reaction": null, "isOwn": false }, "then": { ... } } } ``` 3. **Successful post opening with `basicInfo` set to `false`:** ```json { "actionType": "st.openPost", "label": "post1", "success": true, "data": { "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data.then` – results of child actions execution. 4. **Failed post opening:** ```json { "actionType": "st.openPost", "label": "post1", "success": false, "error": { "type": "postNotFound", "message": "The provided URL is not an existing LinkedIn post." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `postNotFound` – provided URL is not an existing LinkedIn post. ## st.createPost This action allows you to create a post on LinkedIn. You can post as yourself or on behalf of a company page you have admin access to. The action supports text content with optional mentions of people and companies, and media attachments including images, videos, and documents. :::surface node,python In the SDK this is the `createPost` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin post create` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** [st.openPost](/docs/action-st-open-post). ## Parameters ```json tab=HTTP { "actionType": "st.createPost", "label": "post1", "text": "Excited to share my latest insights on building scalable APIs, together with @[teammate]!", "mentions": [ { "key": "teammate", "name": "Example Person", "personHashedUrl": "https://www.linkedin.com/in/ACwAAAxxxxxxxxx" } ], "attachments": [ { "url": "https://example.com/images/team-photo.jpg", "type": "image" } ], "companyUrl": "https://www.linkedin.com/company/microsoft" } ``` ```typescript tab=Node const workflow = await linkedapi.createPost.execute({ text: "Excited to share my latest insights on building scalable APIs, together with @[teammate]!", mentions: [ { key: "teammate", name: "Example Person", personHashedUrl: "https://www.linkedin.com/in/ACwAAAxxxxxxxxx" } ], attachments: [ { url: "https://example.com/images/team-photo.jpg", type: "image" } ], companyUrl: "https://www.linkedin.com/company/microsoft" }); const { data, errors } = await linkedapi.createPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CreatePostParams workflow = linkedapi.create_post.execute( CreatePostParams( text="Excited to share my latest insights on building scalable APIs, together with @[teammate]!", mentions=[ { "key": "teammate", "name": "Example Person", "person_hashed_url": "https://www.linkedin.com/in/ACwAAAxxxxxxxxx", }, ], attachments=[ { "url": "https://example.com/images/team-photo.jpg", "type": "image", }, ], company_url="https://www.linkedin.com/company/microsoft", ) ) result = linkedapi.create_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post create \ 'Excited to share my latest insights on building scalable APIs, together with @[teammate]!' \ --company-url https://www.linkedin.com/company/microsoft \ --mention "teammate:Example Person:https://www.linkedin.com/in/ACwAAAxxxxxxxxx" \ --attachments https://example.com/images/team-photo.jpg:image ``` In AI assistants, the MCP server runs this action as the `create_post` tool. :::surface cli In the CLI this is `linkedin post create`: ```bash linkedin post create [flags] ``` | Arg | Required | Description | | --- | --- | --- | | `text` | yes | Post text (up to 3000 characters) | | Flag | Type | Description | | --- | --- | --- | | `--company-url` | string | Post on behalf of a company page (requires admin access) | | `--attachments` | string | Attachments as `url:type` or `url:type:name`. Types: `image`, `video`, `document`. Can be specified multiple times. | | `--mention` | string | One mention, bound to `@[key]` in the text. Can be specified multiple times, up to 20. | > Attachment limits: up to 9 images, or 1 video, or 1 document. Cannot mix types. **Passing mentions** Write `@[key]` in the post text where the mention should appear, then describe that key with `--mention`. The value takes one of two forms: ``` key:name key:name:identifier ``` - `key` – the placeholder name used in the text as `@[key]`. Letters, digits, `_` or `-`. - `name` – the name to find the person or company by. Always required. - `identifier` – optional. One of a member URN, an organization URN, a hashed profile URL, or a hashed company URL. Everything after the second `:` is the identifier, so its own colons and `://` need no escaping. Quote the whole value, because names contain spaces. ```bash # Name only – LinkedIn's first suggestion for that name is used linkedin post create 'Thanks @[author]!' \ --mention "author:Example Person" # Pinned by member URN linkedin post create 'Thanks @[author]!' \ --mention "author:Example Person:urn:li:member:123456789" # Pinned by hashed profile URL linkedin post create 'Thanks @[author]!' \ --mention "author:Example Person:https://www.linkedin.com/in/ACwAAAxxxxxxxxx" # A company, and two mentions at once linkedin post create 'Great work @[author] and everyone at @[company]!' \ --mention "author:Example Person:urn:li:member:123456789" \ --mention "company:Example Company:urn:li:organization:1234567" ``` > Without an identifier, LinkedIn's first suggestion for `name` is used, which may not be the entity you meant. The published mention shows LinkedIn's own name for the entity, which can differ from the `name` you passed. Every `@[key]` in the text must have a matching `--mention` and vice versa, and keys cannot repeat – otherwise the command fails before anything is published. The post's `postUrl` and `postUrn` are printed on success (use `--json` for the full object). ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `text` (required) – post text, must be up to **3000** characters. - `mentions` (optional) – people and companies to mention in `text`. Write `@[key]` where the mention should appear, and describe that `key` here. Maximum **20** items. - `key` (required) – placeholder name used in the text as `@[key]`. From **1** to **30** characters: letters, digits, `_` or `-`. - `name` (required) – name to find the person or company by, from **1** to **100** characters. - `urn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the entity: `urn:li:member:` for a person, `urn:li:organization:` for a company. - `personHashedUrl` (optional) – hashed LinkedIn profile URL of the person. - `companyHashedUrl` (optional) – hashed LinkedIn company page URL. - `attachments` (optional) – array of media attachments. Maximum **9** items. - `url` (required) – publicly accessible URL of the file to attach. - `type` (required) – type of attachment: `image`, `video`, or `document`. - `name` (required when type is `document`) – display name for the document. - `companyUrl` (optional) – LinkedIn company page URL. When provided, the post will be published on behalf of the company. Requires content admin access to the company page. - `then` (optional) – object or array of child actions to be executed on the created post. ### Mention rules - Provide at most one of `urn`, `personHashedUrl` and `companyHashedUrl` per mention. - Every `@[key]` in the text must have a matching mention, and every mention must appear in the text. - Two mentions cannot share the same `key`. - `name` is required even when an identifier is given, because LinkedIn resolves a mention through its own name suggestions. The identifier only decides which of the offered namesakes is taken. See [publishing posts](/docs/publishing-posts) for the full explanation. ### Attachment limits - Maximum **9 images** per post (JPEG, PNG, GIF, WebP, max 8mb). - Maximum **1 video** per post (MP4, MOV, WebM, max 200 mb). - Maximum **1 document** per post (PDF, max 100 mb). - Cannot mix different attachment types (e.g., images and video together). ## Result options 1. **Successful post creation:** ```json { "actionType": "st.createPost", "label": "post1", "success": true, "data": { "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "postUrn": "urn:li:activity:1234567890123456789", "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data.postUrl` – URL of the created post. - `data.postUrn` – [URN](/docs/data-conventions#linkedin-urns) of the created post. - `data.then` – results of child actions execution. > `postUrl` and `postUrn` are `null` when the post was published but LinkedIn did not return its address. In that case the child actions in `then` did not run. Do not retry: the post is already live. 2. **Failed post creation:** ```json { "actionType": "st.createPost", "label": "weeklyUpdate1", "success": false, "error": { "type": "companyNotFound", "message": "Provided URL is not an existing LinkedIn company." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `textTooLong` – post text exceeds 3000 characters limit. - `companyNotFound` – specified company page was not found on LinkedIn. - `noPostingPermission` – no permission to post on behalf of this company. - `mentionNotResolved` – a person or company the post text mentions was not among the options LinkedIn offered for that name. Nothing was published. - `unsupportedAttachmentType` – attachment type is not supported. Use `image`, `video`, or `document`. - `missingDocumentName` – document attachment requires a `name` field. - `urlNotAccessible` – attachment URL is not publicly accessible or returned an error. - `fileTooLarge` – attachment file exceeds the maximum allowed size. - `unsupportedMimeType` – file's MIME type is not supported by LinkedIn. - `tooManyAttachments` – more than 9 attachments were provided. - `mixingNotAllowed` – cannot mix different attachment types (e.g., images with video). ## st.createRepost This action allows you to repost someone else's post or your own. You can repost it as is, or add your own commentary with optional mentions of people and companies. :::surface node,python In the SDK this is the `createRepost` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin post repost` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `postUrl` or `postUrn` parameter is provided. > ⬆️ **Parent Actions:** [st.openPost](/docs/action-st-open-post), [st.doForPosts](/docs/action-st-do-for-posts). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.createRepost", "label": "repost1", "postUrl": "https://www.linkedin.com/posts/post1", "text": "Worth reading, especially the part on onboarding. Great work @[author]!", "mentions": [ { "key": "author", "name": "Example Person", "personHashedUrl": "https://www.linkedin.com/in/ACwAAAxxxxxxxxx" } ] } ``` ```typescript tab=Node const workflow = await linkedapi.createRepost.execute({ postUrl: "https://www.linkedin.com/posts/post1", text: "Worth reading, especially the part on onboarding. Great work @[author]!", mentions: [ { key: "author", name: "Example Person", personHashedUrl: "https://www.linkedin.com/in/ACwAAAxxxxxxxxx" } ] }); const { data, errors } = await linkedapi.createRepost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CreateRepostParams workflow = linkedapi.create_repost.execute( CreateRepostParams( post_url="https://www.linkedin.com/posts/post1", text="Worth reading, especially the part on onboarding. Great work @[author]!", mentions=[ { "key": "author", "name": "Example Person", "person_hashed_url": "https://www.linkedin.com/in/ACwAAAxxxxxxxxx", }, ], ) ) result = linkedapi.create_repost.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post repost https://www.linkedin.com/posts/post1 \ --text 'Worth reading, especially the part on onboarding. Great work @[author]!' \ --mention "author:Example Person:https://www.linkedin.com/in/ACwAAAxxxxxxxxx" ``` In AI assistants, the MCP server runs this action as the `create_repost` tool. :::surface cli In the CLI this is `linkedin post repost`: ```bash linkedin post repost [flags] ``` | Arg | Required | Description | | --- | --- | --- | | `url` | yes | URL or URN of the post to repost | | Flag | Type | Description | | --- | --- | --- | | `--text` | string | Your own commentary (up to 3000 characters). Without it the post is reposted as is. | | `--mention` | string | One mention, bound to `@[key]` in `--text`. Same format as for `post create` – see [passing mentions](/docs/action-st-create-post?surface=cli#parameters). Can be specified multiple times, up to 20. | The repost's own `postUrl` and `postUrn` are printed on success – not those of the post you reposted. They are empty when the repost was published but LinkedIn did not return its address; do not retry, the repost is already live. > `alreadyReposted` is returned only when LinkedIn refuses an instant repost (one without `--text`). LinkedIn does not always refuse a repeat repost: a repost of your own post, or a repost with commentary, can be published again. Keep track of what you have already reposted instead of relying on this error. > Reposts count toward the same posting limit as `post create`. ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `postUrl` (required for root start unless `postUrn` is provided, forbidden for parent start) – LinkedIn URL of the post to repost. - `postUrn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the post, accepted instead of `postUrl`. When both are provided, the URL must contain that same URN. - `text` (optional) – your own commentary, up to **3000** characters. Without it the post is reposted as is. - `mentions` (optional) – people and companies to mention in `text`. Write `@[key]` where the mention should appear, and describe that `key` here. Maximum **20** items. - `key` (required) – placeholder name used in the text as `@[key]`. From **1** to **30** characters: letters, digits, `_` or `-`. - `name` (required) – name to find the person or company by, from **1** to **100** characters. - `urn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the entity: `urn:li:member:` for a person, `urn:li:organization:` for a company. - `personHashedUrl` (optional) – hashed LinkedIn profile URL of the person. - `companyHashedUrl` (optional) – hashed LinkedIn company page URL. ### Mention rules - Provide at most one of `urn`, `personHashedUrl` and `companyHashedUrl` per mention. - Every `@[key]` in the text must have a matching mention, and every mention must appear in the text. - Two mentions cannot share the same `key`. - `name` is required even when an identifier is given, because LinkedIn resolves a mention through its own name suggestions. The identifier only decides which of the offered namesakes is taken. See [publishing posts](/docs/publishing-posts) for the full explanation. ## Result options 1. **Successful repost:** ```json { "actionType": "st.createRepost", "label": "repost1", "success": true, "data": { "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "postUrn": "urn:li:activity:1234567890123456789" } } ``` - `label` – included only if specified in the action parameters. - `data.postUrl` – URL of the repost. A repost is a post of your own account, so this is not the URL of the post you reposted. - `data.postUrn` – [URN](/docs/data-conventions#linkedin-urns) of the repost. > `postUrl` and `postUrn` are `null` when the repost was published but LinkedIn did not return its address. Do not retry: the repost is already live. 2. **Failed repost:** ```json { "actionType": "st.createRepost", "label": "repost1", "success": false, "error": { "type": "alreadyReposted", "message": "LinkedIn declined this repost because the account has already reposted this post." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `postNotFound` – provided URL is not an existing LinkedIn post. - `repostNotAllowed` – LinkedIn offers no repost control for this post. - `alreadyReposted` – the account has already reposted this post and LinkedIn declined to repost it again. - `mentionNotResolved` – a person or company the text mentions was not among the options LinkedIn offered for that name. ## st.reactToPost This action allows you to react to a post using any available reaction type. :::surface node,python In the SDK this is the `reactToPost` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin post react` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `postUrl` or `postUrn` parameter is provided. > ⬆️ **Parent Actions:** [st.openPost](/docs/action-st-open-post), [st.doForPosts](/docs/action-st-do-for-posts). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.reactToPost", "postUrl": "https://www.linkedin.com/posts/post1", "companyUrl": "https://www.linkedin.com/company/company1", "type": "like", "label": "post1" } ``` ```typescript tab=Node const workflow = await linkedapi.reactToPost.execute({ postUrl: "https://www.linkedin.com/posts/post1", companyUrl: "https://www.linkedin.com/company/company1", type: "like" }); const { errors } = await linkedapi.reactToPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ReactToPostParams workflow = linkedapi.react_to_post.execute( ReactToPostParams( post_url="https://www.linkedin.com/posts/post1", company_url="https://www.linkedin.com/company/company1", type="like", ) ) result = linkedapi.react_to_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post react https://www.linkedin.com/posts/post1 \ --type like \ --company-url https://www.linkedin.com/company/company1 ``` In AI assistants, the MCP server runs this action as the `react_to_post` tool. :::surface cli In the CLI this is `linkedin post react`: ```bash linkedin post react --type [flags] ``` | Flag | Type | Required | Description | | --- | --- | --- | --- | | `--type` | string | yes | Reaction type: `like`, `love`, `support`, `celebrate`, `insightful`, `funny` | | `--company-url` | string | no | React on behalf of a company page | ::: - `postUrl` (required for root start unless `postUrn` is provided, forbidden for parent start) – LinkedIn URL of the post. - `postUrn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the post, accepted instead of `postUrl`. When both are provided, the URL must contain that same URN. - `type` – enum describing the reaction type. - `like` – standard "like". - `celebrate` – to celebrate an achievement. - `support` – to show support. - `love` – to express love or admiration. - `insightful` – to appreciate insightful content. - `funny` – to react to something humorous. - `companyUrl` (optional) – LinkedIn company page URL. When provided, the reaction will be posted on behalf of the company. Requires content admin access to the company page. - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options 1. **Successful post reaction:** ```json { "actionType": "st.reactToPost", "success": true, "label": "post1" } ``` - `label` – included only if specified in the action parameters. 2. **Failed post reaction:** ```json { "actionType": "st.reactToPost", "success": false, "label": "post1", "error": { "type": "postNotFound", "message": "The provided URL is not an existing LinkedIn post." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `postNotFound` – provided URL is not an existing LinkedIn post. - `noPostingPermission` – no permission to react on behalf of this company. ## st.commentOnPost This action allows you to leave a comment on a post. :::surface node,python In the SDK this is the `commentOnPost` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin post comment` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `postUrl` or `postUrn` parameter is provided. > ⬆️ **Parent Actions:** [st.openPost](/docs/action-st-open-post), [st.doForPosts](/docs/action-st-do-for-posts). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.commentOnPost", "label": "post1", "postUrl": "https://www.linkedin.com/posts/post1", "companyUrl": "https://www.linkedin.com/company/company1", "text": "I completely agree with your point." } ``` ```typescript tab=Node const workflow = await linkedapi.commentOnPost.execute({ postUrl: "https://www.linkedin.com/posts/post1", companyUrl: "https://www.linkedin.com/company/company1", text: "I completely agree with your point." }); const { data, errors } = await linkedapi.commentOnPost.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import CommentOnPostParams workflow = linkedapi.comment_on_post.execute( CommentOnPostParams( post_url="https://www.linkedin.com/posts/post1", company_url="https://www.linkedin.com/company/company1", text="I completely agree with your point.", ) ) result = linkedapi.comment_on_post.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin post comment \ https://www.linkedin.com/posts/post1 \ "I completely agree with your point." \ --company-url https://www.linkedin.com/company/company1 ``` In AI assistants, the MCP server runs this action as the `comment_on_post` tool. :::surface cli In the CLI this is `linkedin post comment`: ```bash linkedin post comment [flags] ``` | Arg | Required | Description | | --- | --- | --- | | `url` | yes | LinkedIn post URL | | `text` | yes | Comment text (up to 1000 characters) | | Flag | Type | Description | | --- | --- | --- | | `--company-url` | string | Comment on behalf of a company page | > The same text on the same post, already published through Linked API within 24 hours, is not published again – the command fails with `duplicateComment` and links the earlier comment when it is known. A comment published with `--company-url` is counted separately from a personal one. ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `postUrl` (required for root start unless `postUrn` is provided, forbidden for parent start) – LinkedIn URL of the post. - `postUrn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the post, accepted instead of `postUrl`. When both are provided, the URL must contain that same URN. - `text` – comment text, must be up to **1000** characters. - `companyUrl` (optional) – LinkedIn company page URL. When provided, the comment will be posted on behalf of the company. Requires content admin access to the company page. ## Result options 1. **Successful post commenting:** ```json { "actionType": "st.commentOnPost", "label": "post1", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543210)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" } } ``` - `label` – included only if specified in the action parameters. - `data` – information about the created comment. - `commentUrn` – LinkedIn URN of the created comment, if available. - `commentUrl` – canonical deep-link URL of the created comment, if available. It can be used as the `commentUrl` input for [st.openComment](/docs/action-st-open-comment), [st.reactToComment](/docs/action-st-react-to-comment), or [st.replyToComment](/docs/action-st-reply-to-comment). 2. **Failed post commenting:** ```json { "actionType": "st.commentOnPost", "label": "post1", "success": false, "error": { "type": "commentingNotAllowed", "message": "Commenting is not allowed on this post." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `postNotFound` – provided URL is not an existing LinkedIn post. - `duplicateComment` – this account already published the same text on this post through Linked API within the last 24 hours, so it was not published again. Text is compared after trimming and collapsing whitespace, and a comment published as a company page is counted separately from a personal one. The message links the earlier comment when it is known. - `commentingNotAllowed` – commenting is not allowed on this post. This could be due to the post author's privacy settings, LinkedIn restrictions on commenting, or because the post type does not support comments. - `noPostingPermission` – no permission to comment on behalf of this company. ## st.retrievePostComments This action allows you to retrieve comments for a certain post. :::surface node,python In the SDK this action runs inside `fetchPost` with `retrieveComments` enabled; its result comes back in `data.comments`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin post fetch` with `--comments`; that command's other options are on [`st.openPost`](/docs/action-st-open-post). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `postUrl` or `postUrn` parameter is provided. > ⬆️ **Parent Actions:** [st.openPost](/docs/action-st-open-post), [st.doForPosts](/docs/action-st-do-for-posts). > ⬇️ **Child Actions:** [st.doForComments](/docs/action-st-do-for-comments). ## Parameters ```json tab=HTTP { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "replies": true, "limit": 10, "sort": "mostRelevant", "label": "post1Comments", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPost.execute({ postUrl: "https://www.linkedin.com/posts/post1", retrieveComments: true, }); const { data, errors } = await linkedapi.fetchPost.result(workflow.workflowId); console.log(data?.comments); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPostParams workflow = linkedapi.fetch_post.execute( FetchPostParams( post_url="https://www.linkedin.com/posts/post1", retrieve_comments=True, ) ) result = linkedapi.fetch_post.result(workflow.workflow_id) if result.data: print(result.data.comments) print(result.errors) ``` ```bash tab=CLI linkedin post fetch https://www.linkedin.com/posts/post1 --comments --json ``` - `postUrl` (required for root start unless `postUrn` is provided, forbidden for parent start) – LinkedIn URL of the post. - `postUrn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the post, accepted instead of `postUrl`. When both are provided, the URL must contain that same URN. - `replies` (optional, default: `true`) – when set to `true`, the action includes replies to the comments in the results. - `limit` (optional) – number of comments to retrieve. Also applies to the replies if `replies` set to `true`. Defaults to **10**, with a maximum value of **500**. - `sort` (optional, default: `mostRelevant`) – enum representing comments sorting. Options: - `mostRelevant` – show most relevant comments first. - `mostRecent` – show most recent comments first. - `label` (optional) – custom label for tracking this action in workflow completion. - `then` (optional) – object or array of child actions to be executed for each retrieved comment (see [st.doForComments](/docs/action-st-do-for-comments)). ## Result options 1. **Successful comments retrieval:** ```json { "actionType": "st.retrievePostComments", "label": "post1Comments", "success": true, "data": [ { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543210)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "author": { "type": "person", "name": "John Doe", "profileUrl": "https://www.linkedin.com/in/john-doe", "headline": "Product Manager", "viewerState": { "connectionDegree": "2nd" } }, "time": "3d", "text": "Great work!", "image": null, "isReply": false, "reactionsCount": 3, "repliesCount": 2, "viewerState": { "reaction": "like", "isOwn": false } }, { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543211)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543211%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "author": { "type": "person", "name": "Jane Doe", "profileUrl": "https://www.linkedin.com/in/person1", "headline": "Helping companies to use AI", "viewerState": { "connectionDegree": "3rd+" } }, "time": "3d", "text": "Agree with you!", "image": null, "isReply": true, "reactionsCount": 0, "repliesCount": 0, "viewerState": { "reaction": null, "isOwn": false } }, { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543212)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543212%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "author": { "type": "company", "name": "CloseAI", "companyUrl": "https://www.linkedin.com/company/company1" }, "time": "1w", "text": null, "image": "https://static.linkedin.com/image1.jpg", "isReply": false, "reactionsCount": 8, "repliesCount": 4, "viewerState": { "reaction": null, "isOwn": false } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of comments. - `commentUrn` – LinkedIn URN of the comment, if available. It can be used to build a `commentUrl` for comment-scoped actions. - `commentUrl` – canonical deep-link URL of the comment, if available. It can be used as the `commentUrl` input for [st.openComment](/docs/action-st-open-comment), [st.reactToComment](/docs/action-st-react-to-comment), or [st.replyToComment](/docs/action-st-reply-to-comment). - `author` – person or company who wrote the comment. - For person authors: `type`, `name`, `profileUrl`, `headline`, `viewerState` with `connectionDegree` – `1st`, `2nd`, `3rd+`, or `null` if LinkedIn does not show it, which is always the case for comments read in the feed or from LinkedIn's classic post layout (see [viewer state](/docs/data-conventions#viewer-state)). - For company authors: `type`, `name`, `companyUrl`. - `time` – relative time when the comment was posted, as shown by LinkedIn (e.g. `3d`, `1w`). - `text` – text of the comment, if available. - `image` – URL of an image attached to the comment, if available. - `isReply` – boolean indicating whether the comment is a reply to another comment. - `reactionsCount` – number of reactions on the comment. - `repliesCount` – number of replies to the comment. - `viewerState` – what your account has done with the comment, see [viewer state](/docs/data-conventions#viewer-state). - `reaction` – your reaction to the comment (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`), or `null` if you have not reacted or the page does not show it. Comments read in the feed or from LinkedIn's classic post layout always report `null`. - `isOwn` – `true` if your account wrote the comment, `false` if someone else did, or `null` if this cannot be told. - `then` – results of child actions execution, present only when child actions are applied via [st.doForComments](/docs/action-st-do-for-comments). 2. **Failed comments retrieval:** ```json { "actionType": "st.retrievePostComments", "success": false, "label": "post1", "error": { "type": "postNotFound", "message": "The provided URL is not an existing LinkedIn post." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `postNotFound` – provided URL is not an existing LinkedIn post. ## st.retrievePostReactions This action allows you to retrieve reactions for a certain post. :::surface node,python In the SDK this action runs inside `fetchPost` with `retrieveReactions` enabled; its result comes back in `data.reactions`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin post fetch` with `--reactions`; that command's other options are on [`st.openPost`](/docs/action-st-open-post). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `postUrl` or `postUrn` parameter is provided. > ⬆️ **Parent Actions:** [st.openPost](/docs/action-st-open-post), [st.doForPosts](/docs/action-st-do-for-posts). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePostReactions", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 100, "label": "post1Reactions" } ``` ```typescript tab=Node const workflow = await linkedapi.fetchPost.execute({ postUrl: "https://www.linkedin.com/posts/post1", retrieveReactions: true, }); const { data, errors } = await linkedapi.fetchPost.result(workflow.workflowId); console.log(data?.reactions); console.log(errors); ``` ```python tab=Python from linkedapi import FetchPostParams workflow = linkedapi.fetch_post.execute( FetchPostParams( post_url="https://www.linkedin.com/posts/post1", retrieve_reactions=True, ) ) result = linkedapi.fetch_post.result(workflow.workflow_id) if result.data: print(result.data.reactions) print(result.errors) ``` ```bash tab=CLI linkedin post fetch https://www.linkedin.com/posts/post1 --reactions --json ``` - `postUrl` (required for root start unless `postUrn` is provided, forbidden for parent start) – LinkedIn URL of the post. - `postUrn` (optional) – [URN](/docs/data-conventions#linkedin-urns) of the post, accepted instead of `postUrl`. When both are provided, the URL must contain that same URN. - `limit` (optional) – number of reactions to retrieve. Defaults to **10**, with a maximum value of **500**. - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options 1. **Successful reactions retrieval:** ```json { "actionType": "st.retrievePostReactions", "label": "post1Reactions", "success": true, "data": [ { "actor": { "type": "person", "name": "John Doe", "urn": null, "profileUrl": "https://www.linkedin.com/in/john-doe", "headline": "Product Manager", "viewerState": { "connectionDegree": "2nd" } }, "type": "like" }, { "actor": { "type": "person", "name": "Jane Doe", "urn": null, "profileUrl": "https://www.linkedin.com/in/person1", "headline": "Helping companies to use AI", "viewerState": { "connectionDegree": "3rd+" } }, "type": "support" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of reactions. - `actor` – person or company who reacted. - For person actors: `type`, `name`, `urn`, `profileUrl`, `headline`, `viewerState` with `connectionDegree` – `1st`, `2nd`, `3rd+`, or `null` if LinkedIn does not show it (see [viewer state](/docs/data-conventions#viewer-state)). - For company actors: `type`, `name`, `urn`, `companyUrl`. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person or company, or `null` if LinkedIn does not expose it. - `type` – enum describing the reaction type. Possible values: - `like` – standard "like". - `celebrate` – to celebrate an achievement. - `support` – to show support. - `love` – to express love or admiration. - `insightful` – to appreciate insightful content. - `funny` – to react to something humorous. 2. **Failed reactions retrieval:** ```json { "actionType": "st.retrievePostReactions", "success": false, "label": "post1", "error": { "type": "postNotFound", "message": "The provided URL is not an existing LinkedIn post." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `postNotFound` – provided URL is not an existing LinkedIn post. ## st.openComment This action allows you to open a comment to react to it or reply to it. :::surface node,python The SDK has no dedicated method for this action – run it with [`customWorkflow`](/sdks/custom-workflow), as in the example. ::: :::surface cli The CLI has no dedicated command for this action – run it with [`linkedin workflow run`](/cli/workflows), as in the example. ::: ## Constraints > ⏺️ **Root Start:** allowed, when `commentUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.doForComments](/docs/action-st-do-for-comments). > ⬇️ **Child Actions:** [st.reactToComment](/docs/action-st-react-to-comment), [st.replyToComment](/docs/action-st-reply-to-comment). ## Parameters ```json tab=HTTP { "actionType": "st.openComment", "label": "comment1", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ "actionType": "st.openComment", "label": "comment1", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute({ "actionType": "st.openComment", "label": "comment1", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" }) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'JSON' { "actionType": "st.openComment", "label": "comment1", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" } JSON ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `commentUrl` (required for root start, forbidden for parent start) – LinkedIn URL of the comment. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful comment opening:** ```json { "actionType": "st.openComment", "label": "comment1", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543210)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data` – information about the opened comment and results of child actions execution. - `commentUrn` – LinkedIn URN of the comment, if available. - `commentUrl` – canonical deep-link URL of the comment, if available. - `then` – results of child actions execution. 2. **Failed comment opening:** ```json { "actionType": "st.openComment", "label": "comment1", "success": false, "error": { "type": "commentNotFound", "message": "The comment could not be opened or no longer exists." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `commentNotFound` – the comment could not be opened or no longer exists. ## st.reactToComment This action allows you to react to a comment using any available reaction type. :::surface node,python In the SDK this is the `reactToComment` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin comment react` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `commentUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.doForComments](/docs/action-st-do-for-comments), [st.openComment](/docs/action-st-open-comment). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.reactToComment", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "type": "like", "label": "comment1" } ``` ```typescript tab=Node const workflow = await linkedapi.reactToComment.execute({ commentUrl: "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", type: "like" }); const { errors } = await linkedapi.reactToComment.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ReactToCommentParams workflow = linkedapi.react_to_comment.execute( ReactToCommentParams( comment_url="https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", type="like", ) ) result = linkedapi.react_to_comment.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin comment react \ "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" \ --type like ``` In AI assistants, the MCP server runs this action as the `react_to_comment` tool. :::surface cli In the CLI this is `linkedin comment react`: ```bash linkedin comment react --type ``` | Arg | Required | Description | | --- | --- | --- | | `commentUrl` | yes | Deep-link URL of the comment, from `post fetch --comments` or a `comment reply` result | | Flag | Type | Required | Description | | --- | --- | --- | --- | | `--type` | string | yes | Reaction type: `like`, `love`, `support`, `celebrate`, `insightful`, `funny` | ::: - `commentUrl` (required for root start, forbidden for parent start) – LinkedIn URL of the comment. - `type` (optional, default: `like`) – enum describing the reaction type. - `like` – standard "like". - `celebrate` – to celebrate an achievement. - `support` – to show support. - `love` – to express love or admiration. - `insightful` – to appreciate insightful content. - `funny` – to react to something humorous. - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options 1. **Successful comment reaction:** ```json { "actionType": "st.reactToComment", "success": true, "label": "comment1" } ``` - `label` – included only if specified in the action parameters. 2. **Failed comment reaction:** ```json { "actionType": "st.reactToComment", "success": false, "label": "comment1", "error": { "type": "commentNotFound", "message": "The comment could not be opened or no longer exists." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `commentNotFound` – the comment could not be opened or no longer exists. ## st.replyToComment This action allows you to reply to a comment. :::surface node,python In the SDK this is the `replyToComment` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin comment reply` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `commentUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.doForComments](/docs/action-st-do-for-comments), [st.openComment](/docs/action-st-open-comment). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.replyToComment", "label": "comment1", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "text": "Thanks for sharing your thoughts!" } ``` ```typescript tab=Node const workflow = await linkedapi.replyToComment.execute({ commentUrl: "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", text: "Thanks for sharing your thoughts!" }); const { data, errors } = await linkedapi.replyToComment.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import ReplyToCommentParams workflow = linkedapi.reply_to_comment.execute( ReplyToCommentParams( comment_url="https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", text="Thanks for sharing your thoughts!", ) ) result = linkedapi.reply_to_comment.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin comment reply \ "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)" \ 'Thanks for sharing your thoughts!' ``` In AI assistants, the MCP server runs this action as the `reply_to_comment` tool. :::surface cli In the CLI this is `linkedin comment reply`: ```bash linkedin comment reply [flags] ``` | Arg | Required | Description | | --- | --- | --- | | `commentUrl` | yes | Deep-link URL of the comment to reply to | | `text` | yes | Reply text | The reply's `commentUrn` and `commentUrl` are printed on success (use `--json` for the full object). The same reply text to the same comment, already published through Linked API within 24 hours, is not published again – the command fails with `duplicateComment`. ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `commentUrl` (required for root start, forbidden for parent start) – LinkedIn URL of the comment. - `text` – reply text. ## Result options 1. **Successful comment reply:** ```json { "actionType": "st.replyToComment", "label": "comment1", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,1122334455)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(1122334455%2Curn%3Ali%3Aactivity%3A1234567890123456789)" } } ``` - `label` – included only if specified in the action parameters. - `data` – information about the created reply. - `commentUrn` – LinkedIn URN of the created reply, if available. - `commentUrl` – canonical deep-link URL of the created reply, if available. 2. **Failed comment reply:** ```json { "actionType": "st.replyToComment", "label": "comment1", "success": false, "error": { "type": "commentNotFound", "message": "The comment could not be opened or no longer exists." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `commentNotFound` – the comment could not be opened or no longer exists. - `replyingNotAllowed` – replying is not allowed on this comment. - `duplicateComment` – this account already replied with the same text to this comment through Linked API within the last 24 hours, so the reply was not published again. Text is compared after trimming and collapsing whitespace. The message links the earlier reply when it is known. ## st.searchJobs This action allows you to search for jobs applying various filtering criteria. :::surface node,python In the SDK this is the `searchJobs` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin jobs search` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** [st.doForJobs](/docs/action-st-do-for-jobs). ## Parameters ```json tab=HTTP { "actionType": "st.searchJobs", "label": "productManagerSearch1", "term": "product manager", "limit": 2, "location": "San Francisco, California, United States", "allowSimilarResults": true, "filter": { "datePosted": "pastWeek", "experienceLevels": ["midSeniorLevel", "director"], "employmentTypes": ["fullTime"], "workplaceTypes": ["remote", "hybrid"], "companies": ["Example Company"], "industries": ["Software Development"], "jobFunctions": ["Product Management"], "easyApply": true, "hasVerifications": true }, "customSearchUrl": "https://www.linkedin.com/jobs/search/?keywords=product%20manager" } ``` ```typescript tab=Node const workflow = await linkedapi.searchJobs.execute({ term: "product manager", limit: 2, location: "San Francisco, California, United States", allowSimilarResults: true, filter: { datePosted: "pastWeek", experienceLevels: ["midSeniorLevel", "director"], employmentTypes: ["fullTime"], workplaceTypes: ["remote", "hybrid"], companies: ["Example Company"], industries: ["Software Development"], jobFunctions: ["Product Management"], easyApply: true, hasVerifications: true }, customSearchUrl: "https://www.linkedin.com/jobs/search/?keywords=product%20manager" }); const { data, errors } = await linkedapi.searchJobs.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import SearchJobsParams workflow = linkedapi.search_jobs.execute( SearchJobsParams( term="product manager", limit=2, location="San Francisco, California, United States", allow_similar_results=True, filter={ "date_posted": "pastWeek", "experience_levels": ["midSeniorLevel", "director"], "employment_types": ["fullTime"], "workplace_types": ["remote", "hybrid"], "companies": ["Example Company"], "industries": ["Software Development"], "job_functions": ["Product Management"], "easy_apply": True, "has_verifications": True, }, custom_search_url="https://www.linkedin.com/jobs/search/?keywords=product%20manager", ) ) result = linkedapi.search_jobs.result(workflow.workflow_id) ``` ```bash tab=CLI # Not available in the CLI: customSearchUrl linkedin jobs search \ --term "product manager" \ --limit 2 \ --location "San Francisco, California, United States" \ --date-posted pastWeek \ --experience-levels midSeniorLevel,director \ --employment-types fullTime \ --workplace-types remote,hybrid \ --companies "Example Company" \ --industries "Software Development" \ --job-functions "Product Management" \ --easy-apply \ --has-verifications ``` In AI assistants, the MCP server runs this action as the `search_jobs` tool. :::surface cli In the CLI this is `linkedin jobs search`: ```bash linkedin jobs search [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--term` | string | Search keyword or phrase | | `--limit` | integer | Max results to return | | `--location` | string | Location, applied on both versions of LinkedIn jobs search | | `--exclude-similar-results` | boolean | Exclude near matches, which only the AI-powered search returns | | `--date-posted` | string | One of `anyTime`, `past24Hours`, `pastWeek`, `pastMonth` | | `--experience-levels` | string | Comma-separated: `internship`, `entryLevel`, `associate`, `midSeniorLevel`, `director`, `executive` | | `--employment-types` | string | Comma-separated: `fullTime`, `partTime`, `contract`, `temporary`, `volunteer`, `internship`, `other` | | `--workplace-types` | string | Comma-separated: `onSite`, `remote`, `hybrid` | | `--companies` | string | Comma-separated company names | | `--industries` | string | Comma-separated industries | | `--job-functions` | string | Comma-separated job functions | | `--easy-apply` | boolean | Only jobs with Easy Apply | | `--has-verifications` | boolean | Only jobs with verification signals | | `--under-10-applicants` | boolean | Only jobs with fewer than 10 applicants | | `--in-your-network` | boolean | Only jobs from your network | | `--fair-chance-employer` | boolean | Only fair chance employer jobs | ```bash linkedin jobs search --term "product manager" --location "San Francisco" --limit 20 linkedin jobs search --term "engineer" --workplace-types "remote,hybrid" --easy-apply --json ``` **AI-powered search** LinkedIn is rolling out an AI-powered jobs search alongside the classic one, and the two do not offer the same refinements. The flags above target the classic version. To target the AI-powered one, use the `--pref-*` flags instead: | Flag | Type | Description | | --- | --- | --- | | `--pref-date-posted` | string | One of `anyTime`, `past24Hours`, `pastWeek`, `pastMonth` | | `--pref-experience-levels` | string | Comma-separated: `entryLevel`, `senior`, `manager`, `director`, `executive` | | `--pref-employment-types` | string | Comma-separated: `fullTime`, `partTime`, `contract`, `internship`, `volunteer` | | `--pref-companies` | string | Comma-separated company names | | `--pref-remote` | boolean | Only remote jobs | | `--pref-easy-apply` | boolean | Only jobs with Easy Apply | | `--pref-under-10-applicants` | boolean | Only jobs with fewer than 10 applicants | | `--pref-in-your-network` | boolean | Only jobs from your network | | `--pref-keywords` | string | Comma-separated skills, technologies, or topics, such as `AWS` or `Fintech` | The `--pref-*` flags cannot be combined with the classic filter flags. Which version an account has is decided by LinkedIn and can change over time, so a search whose criteria the account's version cannot apply fails with `searchInterfaceMismatch`. See [two versions of LinkedIn jobs search](/docs/searching-for-jobs). ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `term` (optional) – keyword or phrase to search, from 1 to 200 characters. If omitted, Linked API starts from a broad jobs search and applies the provided criteria. - `limit` (optional) – number of search results to return. Defaults to **10**, with a maximum value of **1000**. - `location` (optional) – free-form location string selected through LinkedIn jobs location search. Applied on both [versions of LinkedIn jobs search](/docs/searching-for-jobs). - `allowSimilarResults` (optional) – defaults to **true**. When set to `false`, near matches are excluded from the results, and since LinkedIn places them after the exact ones, the search stops at the first near match and may return fewer results than `limit`. Only relevant to the AI-powered LinkedIn jobs search. - `filter` (optional) – object that specifies filtering criteria for the **classic** LinkedIn jobs search. Every specified field is applied, or the action fails. When multiple filter fields are specified, they are combined using `AND` logic. - `location` (optional) – use the top-level `location` instead. Still accepted here, but the top-level value takes precedence. - `datePosted` (optional) – enum with possible values: - `anyTime`. - `past24Hours`. - `pastWeek`. - `pastMonth`. - `experienceLevels` (optional) – array of enums with possible values: - `internship`. - `entryLevel`. - `associate`. - `midSeniorLevel`. - `director`. - `executive`. - `employmentTypes` (optional) – array of enums with possible values: - `fullTime`. - `partTime`. - `contract`. - `temporary`. - `volunteer`. - `internship`. - `other`. - `workplaceTypes` (optional) – array of enums with possible values: - `onSite`. - `remote`. - `hybrid`. - `companies` (optional) – array of company names selected through LinkedIn jobs company search. - `industries` (optional) – array of industry names selected through LinkedIn jobs industry search. - `jobFunctions` (optional) – array of job function names selected through LinkedIn jobs job-function search. - `easyApply` (optional) – when `true`, filters to jobs with Easy Apply. - `hasVerifications` (optional) – when `true`, filters to jobs with verification signals. - `under10Applicants` (optional) – when `true`, filters to jobs with fewer than 10 applicants. - `inYourNetwork` (optional) – when `true`, filters to jobs from your network. - `fairChanceEmployer` (optional) – when `true`, filters to fair chance employer jobs. - `preferences` (optional) – object that specifies filtering criteria for the **AI-powered** LinkedIn jobs search. LinkedIn decides which of them it offers for a given search, and the ones it does not offer are skipped instead of failing the action. When multiple preference fields are specified, they are combined using `AND` logic. - `datePosted` (optional) – enum with possible values: - `anyTime`. - `past24Hours`. - `pastWeek`. - `pastMonth`. - `experienceLevels` (optional) – array of enums with possible values, where `senior` corresponds to `midSeniorLevel` in `filter`: - `entryLevel`. - `senior`. - `manager`. - `director`. - `executive`. - `employmentTypes` (optional) – array of enums with possible values: - `fullTime`. - `partTime`. - `contract`. - `internship`. - `volunteer`. - `companies` (optional) – array of company names selected through LinkedIn jobs company search. - `remote` (optional) – when `true`, filters to remote jobs. The AI-powered search has no on-site or hybrid equivalent. - `easyApply` (optional) – when `true`, filters to jobs with Easy Apply. - `under10Applicants` (optional) – when `true`, filters to jobs with fewer than 10 applicants. - `inYourNetwork` (optional) – when `true`, filters to jobs from your network. - `keywords` (optional) – array of free-form skills, technologies, or topics to narrow the search by, such as `AWS` or `Fintech`. LinkedIn suggests a different set for every search. - `customSearchUrl` (optional) – URL copied from a LinkedIn jobs search page after configuring desired filters. When specified, it overrides `term`, `location`, `filter`, and `preferences`. The URL has to come from the same version of LinkedIn jobs search as the account has, otherwise the action fails with `searchInterfaceMismatch`. - `then` (optional) – object or array of child actions to be executed within this action. > `filter` and `preferences` are mutually exclusive, and sending both is rejected when the workflow is submitted. See [two versions of LinkedIn jobs search](/docs/searching-for-jobs) to learn which one to send. ## Result options 1. **Successful search:** ```json { "actionType": "st.searchJobs", "label": "productManagerSearch1", "success": true, "data": [ { "jobId": "4416248954", "urn": "urn:li:jobPosting:4416248954", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "title": "Senior Product Manager", "companyName": "Example Company", "location": "San Francisco, CA", "workplaceType": "remote", "salary": { "currency": "usd", "minAmount": 140000, "maxAmount": 180000, "period": "yearly" }, "easyApply": true, "isPromoted": false, "isSimilarMatch": false, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of search outputs with results of child actions execution. - `jobId` – LinkedIn job identifier, when it can be extracted. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the job posting, when `jobId` can be extracted. - `jobUrl` – LinkedIn job URL, when `jobId` can be extracted. - `title` – job title. - `companyName` – company name, if available. - `location` – free-form job location, if available. - `workplaceType` – workplace type label as shown by LinkedIn (such as `remote`, `hybrid`, or `on-site`), if available. Not one of the `workplaceTypes` filter values. - `salary` – parsed salary range, if LinkedIn shows one. - `currency` – lowercase currency code, such as `usd`, `eur`, or `gbp`. - `minAmount` – minimum amount in the parsed range. - `maxAmount` – maximum amount in the parsed range. - `period` – salary period. Possible values are `yearly`, `monthly`, and `hourly`. - `easyApply` – boolean indicating whether the search card mentions Easy Apply. - `isPromoted` – boolean indicating whether the search card is promoted. - `isSimilarMatch` – boolean indicating whether LinkedIn returned the job as a near match rather than an exact one. Always `false` on the classic LinkedIn jobs search. - `then` – results of child actions execution. 2. **Failed search:** ```json { "actionType": "st.searchJobs", "label": "productManagerSearch1", "success": false, "error": { "type": "searchingNotAllowed", "message": "LinkedIn has blocked performing the search." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `searchInterfaceMismatch` – the version of LinkedIn jobs search the account has cannot apply the criteria that were sent. Criteria that exist in only one of the two objects are the usual cause. Which version an account has is decided by LinkedIn and can change over time, so handle this as a normal condition rather than as a permanent setting. - `searchingNotAllowed` – LinkedIn has blocked performing the search due to exceeding limits or other restrictions. ## st.openJob This action allows you to open a LinkedIn job and optionally retrieve its details. :::surface node,python In the SDK this is the `fetchJob` method. It always returns basic information: `basicInfo`, like `label` and `then`, exists only in workflow JSON. It takes the other parameters below and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin jobs fetch` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `jobUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.doForJobs](/docs/action-st-do-for-jobs). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.openJob", "label": "job1", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "basicInfo": true } ``` ```typescript tab=Node const workflow = await linkedapi.fetchJob.execute({ jobUrl: "https://www.linkedin.com/jobs/view/4416248954/" }); const { data, errors } = await linkedapi.fetchJob.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import FetchJobParams workflow = linkedapi.fetch_job.execute( FetchJobParams( job_url="https://www.linkedin.com/jobs/view/4416248954/", ) ) result = linkedapi.fetch_job.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin jobs fetch https://www.linkedin.com/jobs/view/4416248954/ ``` In AI assistants, the MCP server runs this action as the `fetch_job` tool. :::surface cli In the CLI this is `linkedin jobs fetch`: ```bash linkedin jobs fetch ``` | Arg | Required | Description | | --- | --- | --- | | `url` | yes | LinkedIn job URL | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `jobUrl` (required for root start, omitted for parent start) – LinkedIn job URL. When `st.openJob` is used under [`st.doForJobs`](/docs/action-st-do-for-jobs), the URL is injected from the parent search result. - `basicInfo` (optional, default: `false`) – when set to `true`, the action includes job details in the result. ## Result options 1. **Successful job opening with `basicInfo` set to `true`:** ```json { "actionType": "st.openJob", "label": "job1", "success": true, "data": { "jobId": "4416248954", "urn": "urn:li:jobPosting:4416248954", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "title": "Senior Product Manager", "companyName": "Example Company", "companyUrl": "https://www.linkedin.com/company/example-company", "location": "San Francisco, CA", "postedDate": "1w", "applicantsCount": 84, "workplaceType": "remote", "employmentType": "Full-time", "salary": { "currency": "usd", "minAmount": 140000, "maxAmount": 180000, "period": "yearly" }, "description": "Example job description text.", "applyUrl": "https://www.linkedin.com/jobs/view/4416248954/apply/", "easyApply": true } } ``` - `label` – included only if specified in the action parameters. - `data` – job details. - `jobId` – LinkedIn job identifier. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the job posting. - `jobUrl` – normalized LinkedIn job URL. - `title` – job title. - `companyName` – company name, if available. - `companyUrl` – normalized LinkedIn company URL, if available. - `location` – free-form job location, if available. - `postedDate` – compact relative date, such as `1w`, `3d`, or `2mo`, if available. - `applicantsCount` – number of applicants, if LinkedIn shows it. - `workplaceType` – workplace type label as shown by LinkedIn (such as `remote`, `hybrid`, or `on-site`), if available. Not one of the `workplaceTypes` filter values. - `employmentType` – employment type label as shown by LinkedIn (such as `Full-time`), if available. Not one of the `employmentTypes` filter values. - `salary` – parsed salary range, if LinkedIn shows one. - `currency` – lowercase currency code, such as `usd`, `eur`, or `gbp`. - `minAmount` – minimum amount in the parsed range. - `maxAmount` – maximum amount in the parsed range. - `period` – salary period. Possible values are `yearly`, `monthly`, and `hourly`. - `description` – job description text, if available. - `applyUrl` – Easy Apply or external application URL, if available. - `easyApply` – boolean indicating whether the job uses LinkedIn Easy Apply. 2. **Successful job opening with `basicInfo` set to `false`:** ```json { "actionType": "st.openJob", "label": "job1", "success": true } ``` - `label` – included only if specified in the action parameters. - The action opens the job but does not include job details in the result. 3. **Failed job opening:** ```json { "actionType": "st.openJob", "label": "job1", "success": false, "error": { "type": "jobNotFound", "message": "The job could not be opened or no longer exists." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `jobNotFound` – the job could not be opened or no longer exists. ## st.retrieveSSI This action allows you to retrieve your current SSI (Social Selling Index). :::surface node,python In the SDK this is the `retrieveSSI` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin stats ssi` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrieveSSI", "label": "ssi" } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveSSI.execute(); const { data, errors } = await linkedapi.retrieveSSI.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_ssi.execute() result = linkedapi.retrieve_ssi.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin stats ssi ``` In AI assistants, the MCP server runs this action as the `retrieve_ssi` tool. :::surface cli In the CLI this is `linkedin stats ssi`: ```bash linkedin stats ssi [flags] ``` ::: - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options 1. **Successful retrieval:** ```json { "actionType": "st.retrieveSSI", "label": "ssi", "success": true, "data": { "ssi": 78, "industryTop": 15, "networkTop": 10 } } ``` - `label` – included only if specified in the action parameters. - `data.ssi` – number (1-100) representing your current Social Selling Index. - `data.industryTop` – percentage (1-100) showing your industry ranking by SSI score. For example, a value of 5 means you are in the top 5% of your industry. - `data.networkTop` – percentage (1-100) showing your network ranking by SSI score. For example, a value of 10 means you are in the top 10% of your network. 2. **Failed retrieval:** ```json { "actionType": "st.retrieveSSI", "label": "ssi", "success": false, "error": { "type": "ssiNotAvailable", "message": "LinkedIn is not serving a Social Selling Index for this account right now. SSI is becoming a Sales Navigator feature, and accounts without it lose access to the page." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `ssiNotAvailable` – LinkedIn does not show a Social Selling Index for this account. SSI is becoming a Sales Navigator feature, and accounts without Sales Navigator lose access to it, so retrying does not help. ## st.retrievePerformance This action allows you to retrieve performance analytics from your [LinkedIn dashboard](https://www.linkedin.com/dashboard/). :::surface node,python In the SDK this is the `retrievePerformance` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin stats performance` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrievePerformance", "label": "performance" } ``` ```typescript tab=Node const workflow = await linkedapi.retrievePerformance.execute(); const { data, errors } = await linkedapi.retrievePerformance.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.retrieve_performance.execute() result = linkedapi.retrieve_performance.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin stats performance ``` In AI assistants, the MCP server runs this action as the `retrieve_performance` tool. :::surface cli In the CLI this is `linkedin stats performance`: ```bash linkedin stats performance [flags] ``` ::: - `label` (optional) – custom label for tracking this action in workflow completion. ## Result options This action always completes successfully. ```json { "actionType": "st.retrievePerformance", "label": "performance", "success": true, "data": { "followersCount": 1250, "postViewsLast7Days": 523, "profileViewsLast90Days": 342, "searchAppearancesPreviousWeek": 89 } } ``` - `label` – included only if specified in the action parameters. - `data.followersCount` – total number of your followers. - `data.postViewsLast7Days` – number of views on your posts in the last 7 days. - `data.profileViewsLast90Days` – number of views on your profile in the last 90 days. - `data.searchAppearancesPreviousWeek` – number of times your profile appeared in LinkedIn searches during the previous week. ## st.retrieveFeed This action allows you to retrieve posts from your own LinkedIn home feed. :::surface node,python In the SDK this is the `retrieveFeed` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin feed retrieve` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrieveFeed", "label": "feed", "limit": 20 } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveFeed.execute({ limit: 20 }); const { data, errors } = await linkedapi.retrieveFeed.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import RetrieveFeedParams workflow = linkedapi.retrieve_feed.execute( RetrieveFeedParams( limit=20, ) ) result = linkedapi.retrieve_feed.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin feed retrieve --limit 20 ``` In AI assistants, the MCP server runs this action as the `retrieve_feed` tool. :::surface cli In the CLI this is `linkedin feed retrieve`: ```bash linkedin feed retrieve [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--limit` | integer | Maximum posts to retrieve (1–100, default 20) | Each post uses the standard post result shape and includes `feedContext`, localized explanation of why the post appears in the feed. ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – number of posts to retrieve. Defaults to **20**, with a maximum value of **100**. ## Result options This action always completes successfully. ```json { "actionType": "st.retrieveFeed", "label": "feed", "success": true, "data": [ { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2023-01-02T12:30:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/example-person", "headline": "Product Marketing Lead", "viewerState": { "connectionDegree": "2nd" } }, "reposter": null, "text": "Check out our latest product launch!", "repostText": null, "hashtags": ["product", "launch"], "mentions": ["https://www.linkedin.com/company/example-company"], "externalLinks": ["https://example.com/launch"], "images": [ "https://static.linkedin.com/image1.jpg", "https://static.linkedin.com/image2.jpg" ], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 27, "commentsCount": 8, "repostsCount": 12, "viewerState": { "reaction": "like", "isOwn": false }, "feedContext": "Example Reactor reacted to this" }, { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2023-01-01T09:15:00Z", "type": "repost", "author": { "type": "company", "name": "Example Company", "urn": "urn:li:organization:1234567", "companyUrl": "https://www.linkedin.com/company/example-company" }, "reposter": { "type": "person", "name": "Example Reposter", "urn": "urn:li:member:234567890", "profileUrl": "https://www.linkedin.com/in/example-reposter", "headline": null }, "text": "Original post content about the webinar.", "repostText": "A useful summary for anyone planning a launch.", "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": true, "videoThumbnail": "https://media.licdn.com/dms/image/video-cover.jpg", "hasPoll": false, "reactionsCount": 6, "commentsCount": 0, "repostsCount": 1, "viewerState": { "reaction": null, "isOwn": false }, "feedContext": "Example Reposter reposted this" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of posts from your home feed. Sponsored (promoted) posts are included as regular posts. - `url` – URL of the post. - `activityUrn` – LinkedIn activity or UGC URN of the post, if available. - `time` – timestamp when the post was published. - `type` – type of the post. Enum with possible values: - `original` – for original posts. - `repost` – for reposts. - `author` – original content creator. Can be `null` if the actor cannot be parsed. - For person authors: `type`, `name`, `urn`, `profileUrl`, `headline`, `viewerState` with `connectionDegree` – `1st`, `2nd`, `3rd+`, or `null` if LinkedIn does not show it (see [viewer state](/docs/data-conventions#viewer-state)). - For company authors: `type`, `name`, `urn`, `companyUrl`. - `reposter` – person or company that reshared the post. Non-null only when `type` is `repost`. - For person reposters: `type`, `name`, `urn`, `profileUrl`, `headline`. - For company reposters: `type`, `name`, `urn`, `companyUrl`. - `urn` on an actor – [URN](/docs/data-conventions#linkedin-urns) of that person or company, or `null` when LinkedIn does not expose an identifier for the actor. - `text` – original author's post text, if available. - `repostText` – text added by the reposter on a repost with comment, if available. - `hashtags` – array of hashtags found in the post text, without leading `#`. - `mentions` – array of person and company profile URLs found in the post text. - `externalLinks` – array of outbound URLs found in the post text. - `images` – array of up to 3 preview image URLs, if available. - `documentSlides` – array of carousel or document slide image URLs, if available. - `hasVideo` – boolean indicating if the post contains a video. - `videoThumbnail` – URL of the video thumbnail, if available. - `hasPoll` – boolean indicating if the post contains a poll. - `reactionsCount` – number of reactions on the post. - `commentsCount` – number of comments on the post. - `repostsCount` – number of reposts on the post. - `viewerState` – what your account has done with the post, see [viewer state](/docs/data-conventions#viewer-state). - `reaction` – your reaction to the post (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`), or `null` if you have not reacted or it could not be read. - `isOwn` – `true` if your account is the post author, `false` if someone else is, or `null` if this cannot be told. - `feedContext` – verbatim localized context line explaining why the post appears in your feed (for example, "Example Reactor reacted to this", "Example Reposter reposted this", or "Promoted"). `null` when no context line is present. ## st.retrieveProfileViewers This action allows you to retrieve the list of people who recently viewed your own LinkedIn profile, as shown on your [profile viewers page](https://www.linkedin.com/analytics/profile-views/). :::surface node,python In the SDK this is the `retrieveProfileViewers` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin stats viewers`: ```bash linkedin stats viewers [flags] ``` | Flag | Type | Required | Description | | --- | --- | --- | --- | | `--limit` | number | no | Max viewers to retrieve, from 1 to 300. Defaults to 20 | | `--since` | string | no | Only viewers seen at or after this ISO timestamp | Each viewer is either `identified` – carrying `name`, `publicUrl` and `urn` – or `anonymous`, carrying only the description LinkedIn showed and a `searchUrl` holding the criteria it disclosed. `urn` is not always available, and `viewedAt` is an estimate derived from the relative age LinkedIn displays. ::: > 💡 This action is verified against LinkedIn's SDUI layout of the profile viewers page. ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "st.retrieveProfileViewers", "label": "viewers1", "limit": 50, "since": "2026-08-01T00:00:00Z" } ``` ```typescript tab=Node const workflow = await linkedapi.retrieveProfileViewers.execute({ limit: 50, since: "2026-08-01T00:00:00Z" }); const { data, errors } = await linkedapi.retrieveProfileViewers.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import RetrieveProfileViewersParams workflow = linkedapi.retrieve_profile_viewers.execute( RetrieveProfileViewersParams( limit=50, since="2026-08-01T00:00:00Z", ) ) result = linkedapi.retrieve_profile_viewers.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin stats viewers --limit 50 --since 2026-08-01T00:00:00Z ``` In AI assistants, the MCP server runs this action as the `retrieve_profile_viewers` tool. - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – number of viewers to retrieve. Defaults to **20**, with a maximum value of **300**. - `since` (optional) – ISO 8601 timestamp. Only viewers seen at or after it are returned, and the action stops reading the list once it reaches older views. Combine it with `limit`: whichever boundary is hit first ends the retrieval. - `then` is not supported. > 💡 `since` is matched against `viewedAt`, which is an **estimate** derived from the relative age LinkedIn displays (see the note below the result fields). A boundary that falls inside the displayed unit – for example a `since` in the middle of an hour when LinkedIn only says `6h` – can therefore include or exclude a view near the edge. A viewer whose relative age could not be parsed at all is kept rather than dropped, since nothing proves it is older than the boundary. > 💡 LinkedIn itself decides how much of your viewer history it shows. On an account without LinkedIn Premium, LinkedIn shows only part of the list, and the action returns what is visible – which can be fewer viewers than the requested `limit`. That is a **successful** result, not an error: there is no `noPremium` error and no truncation flag in the response. ## Result options This action always completes successfully. ```json { "actionType": "st.retrieveProfileViewers", "label": "viewers1", "success": true, "data": [ { "viewerType": "identified", "viewedAt": "2026-08-11T07:20:00.000Z", "viewedAgo": "6h", "name": "Nicolai Elmqvist", "publicUrl": "https://www.linkedin.com/in/elmqvist", "urn": "urn:li:member:123456789", "headline": "Turn live event audiences into video creators", "connectionDegree": 2, "avatarUrl": "https://media.licdn.com/dms/image/profile-photo.jpg" }, { "viewerType": "anonymous", "viewedAt": "2026-08-11T01:20:00.000Z", "viewedAgo": "12h", "description": "Business Strategist in the Computer and Network Security industry from Washington DC-Baltimore Area", "searchUrl": "https://www.linkedin.com/search/results/people/?keywords=Business+Strategist&origin=WHO_VIEWED_ME&industry=118&geoUrn=90000097" }, { "viewerType": "anonymous", "viewedAt": "2026-08-11T09:20:00.000Z", "viewedAgo": "4h", "description": "Someone at Example Company", "searchUrl": "https://www.linkedin.com/search/results/people/?keywords=&origin=WHO_VIEWED_ME¤tCompany=71168977" } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of profile viewers, in the order LinkedIn shows them. An empty array is a legal, successful result: it means the profile viewers page rendered and there were no viewers to read from it. Every item contains: - `viewerType` – how much LinkedIn revealed about the viewer. Enum with possible values: - `identified` – the viewer is shown with their profile. - `anonymous` – the viewer browsed semi-privately, so LinkedIn withholds their profile and describes them only by the criteria it chose to disclose, such as a role, an industry and a region, or just a company. Viewers in full private mode disclose nothing, so they are not returned. - `viewedAt` – estimated timestamp of the view, in ISO 8601. `null` when LinkedIn's relative time could not be recognized. - `viewedAgo` – normalized relative age of the view, such as `6h`, `3d`, `2w`, or `1mo`. `null` when LinkedIn's relative time could not be recognized. Type-specific fields: - `identified`: - `name` – name of the viewer. Always present. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the viewer. Always present. - `urn` – stable LinkedIn member URN of the viewer, in the form `urn:li:member:`. Unlike the public URL, it does not change when the viewer edits their vanity name, so it is the better identifier to store. **It is not always available:** LinkedIn exposes it only for some viewers, so expect `null` on part of the list and fall back to `publicUrl` when it is missing. - `headline` – headline of the viewer. `null` when LinkedIn did not show it. - `connectionDegree` – your connection degree with the viewer as a number: `1`, `2`, or `3`, where `3` also covers 3rd+. `null` when the degree badge is absent or not recognized. - `avatarUrl` – URL of the viewer's profile picture. `null` when LinkedIn did not show it. - `anonymous`: - `description` – verbatim description line shown by LinkedIn, for example "Business Strategist in the Computer and Network Security industry from Washington DC-Baltimore Area" or "Someone at Example Company". Always present. - `searchUrl` – LinkedIn people-search URL that LinkedIn itself offers for this anonymous viewer, carrying the criteria it disclosed. Always present. Open it to look for the viewer among matching people, or pass it to [`st.searchPeople`](/docs/action-st-search-people) as `customSearchUrl`. > Linked API deliberately returns these two fields as LinkedIn produced them, without splitting the description into position, industry, location, or company name. Those parts are stated in prose whose wording differs per interface language, so parsing them would silently produce wrong values on a language we have not verified. The criteria are also available in machine-readable form as the query parameters of `searchUrl`, so parse them there if you need them. > 💡 `viewedAt` is an **estimate**. It is calculated as the moment the action ran minus the relative age LinkedIn displayed, so it is only as accurate as the unit LinkedIn showed: `6h` means ±1 hour, `2mo` means ±1 month. Months are counted as 30 days and weeks as 7 days. `viewedAgo` is the **normalized** relative age parsed from LinkedIn's own wording rather than the string LinkedIn displayed, so it is language-independent: a German account showing "vor 3 Tagen" returns `3d`. ## st.doForCompanies This action allows you to apply the actions specified in its `then` parameter to each company provided by the parent action. :::surface node,python This action cannot start a workflow, so the SDK has no method for it: put it under its parent action in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow, so the CLI has no command for it: put it under its parent action in the workflow you pass to [`linkedin workflow run`](/cli/workflows) – see [Building Workflows](/docs/building-workflows). ::: > There is a limit of 20 companies that this action can be applied to. If the parent action provides more than 20 companies, only the first 20 will be processed. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.searchCompanies](/docs/action-st-search-companies). > ⬇️ **Child Actions:** [st.openCompanyPage](/docs/action-st-open-company-page). ## Parameters ```json tab=HTTP,CLI { "actionType": "st.doForCompanies", "then": { ... } } ``` ```typescript tab=Node { actionType: "st.doForCompanies", then: { ... } } ``` ```python tab=Python { "actionType": "st.doForCompanies", "then": {...}, } ``` - `then` – object or array of child actions to apply to each company from the parent action. ## Result options This action doesn't produce its own distinct result. The effects are visible in the companies that were involved, as they will contain a `then` field with the results of the applied actions. See usage examples for more details. ## Usage examples 1. **Retrieving basic information about searched companies:** **Workflow:** ```json tab=HTTP { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"] }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "basicInfo": true } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.searchCompanies", term: "Tech Inc", filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York"], industries: ["Software Development", "Robotics Engineering"] }, then: { actionType: "st.doForCompanies", then: { actionType: "st.openCompanyPage", basicInfo: true } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "basicInfo": True, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": [ "51-200", "201-500" ], "locations": [ "San Francisco", "New York" ], "industries": [ "Software Development", "Robotics Engineering" ] }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "basicInfo": true } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.searchCompanies", "success": true, "data": [ { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/techcorp", "industry": "Information Technology", "location": "California", "then": { "actionType": "st.openCompanyPage", "success": true, "data": { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/techcorp", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "California", "headquarters": "US", "industry": "Information Technology", "specialties": "Cloud Computing, AI, Software Development", "website": "https://techcorp.com", "employeeCount": 500, "yearFounded": 2019, "jobsCount": 12 } } }, { "name": "Techical Life", "publicUrl": "https://www.linkedin.com/company/techlife", "industry": "Software Development", "location": "Mountain View", "then": { "actionType": "st.openCompanyPage", "success": true, "data": { "name": "Techical Life", "publicUrl": "https://www.linkedin.com/company/techlife", "description": "TechLife is a worldwide leader in software development for sustainability.", "location": "Mountain View", "headquarters": "US", "industry": "Software Development", "specialties": "Software Development, AI", "website": "https://techlife.com", "employeeCount": 163, "yearFounded": 2016, "jobsCount": 4 } } } ] } ``` 2. **Retrieving decision makers of searched companies:** **Workflow:** ```json tab=HTTP { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York", "London"], "industries": ["Software Development", "Robotics Engineering"] }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "then": { "actionType": "st.retrieveCompanyDMs" } } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.searchCompanies", term: "Tech Inc", filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York", "London"], industries: ["Software Development", "Robotics Engineering"] }, then: { actionType: "st.doForCompanies", then: { actionType: "st.openCompanyPage", then: { actionType: "st.retrieveCompanyDMs" } } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York", "London"], "industries": ["Software Development", "Robotics Engineering"], }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "then": { "actionType": "st.retrieveCompanyDMs", }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.searchCompanies", "term": "Tech Inc", "filter": { "sizes": [ "51-200", "201-500" ], "locations": [ "San Francisco", "New York", "London" ], "industries": [ "Software Development", "Robotics Engineering" ] }, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "then": { "actionType": "st.retrieveCompanyDMs" } } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.searchCompanies", "success": true, "data": [ { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/techcorp", "industry": "Information Technology", "location": "California", "then": { "actionType": "st.openCompanyPage", "success": true, "data": { "then": { "actionType": "st.retrieveCompanyDMs", "success": true, "data": [ { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Founder of TechCorp", "location": "New York, USA", "countryCode": "US" }, { "name": "Jane Smith", "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "CEO at TechCorp", "location": "San Francisco, USA", "countryCode": "US" } ] } } } }, { "name": "Techical Life", "publicUrl": "https://www.linkedin.com/company/techlife", "industry": "Software Development", "location": "Mountain View", "then": { "actionType": "st.openCompanyPage", "success": true, "data": { "then": { "actionType": "st.retrieveCompanyDMs", "success": true, "data": [ { "name": "Row Pitterson", "publicUrl": "https://www.linkedin.com/in/robby", "headline": "Founder of AweSome", "location": "London, UK", "countryCode": "UK" }, { "name": "Angela White", "publicUrl": "https://www.linkedin.com/in/anglwiteh", "headline": "CEO at FunnyFace Inc.", "location": "San Francisco, USA", "countryCode": "US" } ] } } } } ] } ``` ## st.doForPeople This action allows you to apply the actions specified in its `then` parameter to each person provided by the parent action. :::surface node,python This action cannot start a workflow, so the SDK has no method for it: put it under its parent action in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow, so the CLI has no command for it: put it under its parent action in the workflow you pass to [`linkedin workflow run`](/cli/workflows) – see [Building Workflows](/docs/building-workflows). ::: > There is a limit of 20 people that this action can be applied to. If the parent action provides more than 20 people, only the first 20 will be processed. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.searchPeople](/docs/action-st-search-people), [st.retrieveCompanyEmployees](/docs/action-st-retrieve-company-employees), [st.retrieveCompanyDMs](/docs/action-st-retrieve-company-dms). > ⬇️ **Child Actions:** [st.openPersonPage](/docs/action-st-open-person-page). ## Parameters ```json tab=HTTP,CLI { "actionType": "st.doForPeople", "then": { ... } } ``` ```typescript tab=Node { actionType: "st.doForPeople", then: { ... } } ``` ```python tab=Python { "actionType": "st.doForPeople", "then": {...}, } ``` - `then` – object or array of child actions to apply to each person from the parent action. ## Result options This action doesn't produce its own distinct result. The effects are visible in the people that were involved, as they will contain a `then` field with the results of the applied actions. See usage examples for more details. ## Usage examples 1. **Retrieving basic information about the first 2 people from the search results:** **Workflow:** ```json tab=HTTP { "actionType": "st.searchPeople", "term": "John Doe", "limit": 2, "filter": { "position": "CEO", "locations": ["New York", "San Francisco", "London"] }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": true } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.searchPeople", term: "John Doe", limit: 2, filter: { position: "CEO", locations: ["New York", "San Francisco", "London"] }, then: { actionType: "st.doForPeople", then: { actionType: "st.openPersonPage", basicInfo: true } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchPeople", "term": "John Doe", "limit": 2, "filter": { "position": "CEO", "locations": ["New York", "San Francisco", "London"], }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": True, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.searchPeople", "term": "John Doe", "limit": 2, "filter": { "position": "CEO", "locations": [ "New York", "San Francisco", "London" ] }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": true } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.searchPeople", "success": true, "data": [ { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "then": { "actionType": "st.openPersonPage", "success": true, "data": { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johndoe", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "countryCode": "US", "position": "Software Engineer", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678" } } }, { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johnyde", "headline": "Product Manager at TechCorp", "location": "New York, USA", "then": { "actionType": "st.openPersonPage", "success": true, "data": { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johnyd", "hashedUrl": "https://www.linkedin.com/in/Hs763KsjdB153e", "headline": "Product Manager at TechCorp", "location": "New York, USA", "countryCode": "US", "position": "Product Manager", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678" } } } ] } ``` 2. **Sending connection requests to all the company's decision makers:** **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "then": { "actionType": "st.retrieveCompanyDMs", "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "then": { "actionType": "st.sendConnectionRequest" } } } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openCompanyPage", companyUrl: "https://www.linkedin.com/company/techcorp", then: { actionType: "st.retrieveCompanyDMs", then: { actionType: "st.doForPeople", then: { actionType: "st.openPersonPage", then: { actionType: "st.sendConnectionRequest" } } } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "then": { "actionType": "st.retrieveCompanyDMs", "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "then": { "actionType": "st.sendConnectionRequest", }, }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "then": { "actionType": "st.retrieveCompanyDMs", "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "then": { "actionType": "st.sendConnectionRequest" } } } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { "then": { "actionType": "st.retrieveCompanyDMs", "success": true, "data": [ { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johndoe", "headline": "CEO at TechCorp", "location": "New York, USA", "then": { "actionType": "st.openPersonPage", "success": true, "data": { "then": { "actionType": "st.sendConnectionRequest", "success": true } } } }, { "name": "CTO Smith", "publicUrl": "https://www.linkedin.com/in/janesmith", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "then": { "actionType": "st.openPersonPage", "success": true, "data": { "then": { "actionType": "st.sendConnectionRequest", "success": true } } } } ] } } } ``` ## st.doForPosts This action allows you to apply the actions specified in its `then` parameter to each post provided by the parent action. :::surface node,python This action cannot start a workflow, so the SDK has no method for it: put it under its parent action in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow, so the CLI has no command for it: put it under its parent action in the workflow you pass to [`linkedin workflow run`](/cli/workflows) – see [Building Workflows](/docs/building-workflows). ::: > There is a limit of 20 posts that this action can be applied to. If the parent action provides more than 20 posts, only the first 20 will be processed. Under [st.searchPosts](/docs/action-st-search-posts) the parent's own `limit` may not exceed 20 when child actions are used. > Post-scoped actions can be nested directly under `st.doForPosts`, or wrapped in [st.openPost](/docs/action-st-open-post) first when you also need the post's data or want to run several post-scoped actions together for each post. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.searchPosts](/docs/action-st-search-posts), [st.retrieveCompanyPosts](/docs/action-st-retrieve-company-posts), [st.retrievePersonPosts](/docs/action-st-retrieve-person-posts). > ⬇️ **Child Actions:** [st.openPost](/docs/action-st-open-post), [st.reactToPost](/docs/action-st-react-to-post), [st.createRepost](/docs/action-st-create-repost), [st.commentOnPost](/docs/action-st-comment-on-post), [st.retrievePostComments](/docs/action-st-retrieve-post-comments), [st.retrievePostReactions](/docs/action-st-retrieve-post-reactions). ## Parameters ```json tab=HTTP,CLI { "actionType": "st.doForPosts", "then": { ... } } ``` ```typescript tab=Node { actionType: "st.doForPosts", then: { ... } } ``` ```python tab=Python { "actionType": "st.doForPosts", "then": {...}, } ``` - `then` – object or array of child actions to apply to each post from the parent action. ## Result options This action doesn't produce its own distinct result. The effects are visible in the posts that were involved, as they will contain a `then` field with the results of the applied actions. See usage examples for more details. ## Usage examples 1. **Liking the latest 3 posts from the company:** **Workflow:** ```json tab=HTTP { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "then": { "actionType": "st.retrieveCompanyPosts", "limit": 3, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": false, "then": { "actionType": "st.reactToPost", "type": "like" } } } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openCompanyPage", companyUrl: "https://www.linkedin.com/company/company1", then: { actionType: "st.retrieveCompanyPosts", limit: 3, then: { actionType: "st.doForPosts", then: { actionType: "st.openPost", basicInfo: false, then: { actionType: "st.reactToPost", type: "like" } } } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "then": { "actionType": "st.retrieveCompanyPosts", "limit": 3, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": False, "then": { "actionType": "st.reactToPost", "type": "like", }, }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/company1", "then": { "actionType": "st.retrieveCompanyPosts", "limit": 3, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": false, "then": { "actionType": "st.reactToPost", "type": "like" } } } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openCompanyPage", "success": true, "data": { ... "then": { "actionType": "st.retrieveCompanyPosts", "success": true, "data": [ { ... "then": { "actionType": "st.openPost", "success": true, "data": { "then": { "actionType": "st.reactToPost", "success": true } } } }, { ... "then": { "actionType": "st.openPost", "success": true, "data": { "then": { "actionType": "st.reactToPost", "success": true } } } }, { ... "then": { "actionType": "st.openPost", "success": true, "data": { "then": { "actionType": "st.reactToPost", "success": true } } } } ] } } } ``` 2. **Commenting on the person's latest post:** **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "then": { "actionType": "st.retrievePersonPosts", "limit": 1, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": false, "then": { "actionType": "st.commentOnPost", "text": "Great post! Thanks for sharing." } } } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openPersonPage", personUrl: "https://www.linkedin.com/in/person1", then: { actionType: "st.retrievePersonPosts", limit: 1, then: { actionType: "st.doForPosts", then: { actionType: "st.openPost", basicInfo: false, then: { actionType: "st.commentOnPost", text: "Great post! Thanks for sharing." } } } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "then": { "actionType": "st.retrievePersonPosts", "limit": 1, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": False, "then": { "actionType": "st.commentOnPost", "text": "Great post! Thanks for sharing.", }, }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "then": { "actionType": "st.retrievePersonPosts", "limit": 1, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": false, "then": { "actionType": "st.commentOnPost", "text": "Great post! Thanks for sharing." } } } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openPersonPage", "success": true, "data": { ... "then": { "actionType": "st.retrievePersonPosts", "success": true, "data": [ { ... "then": { "actionType": "st.openPost", "success": true, "data": { "then": { "actionType": "st.commentOnPost", "success": true } } } } ] } } } ``` 3. **Opening every post from a content search to get its author URN:** **Workflow:** ```json tab=HTTP { "actionType": "st.searchPosts", "term": "climate tech", "limit": 5, "filter": { "datePosted": "pastWeek" }, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": true } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.searchPosts", term: "climate tech", limit: 5, filter: { datePosted: "pastWeek" }, then: { actionType: "st.doForPosts", then: { actionType: "st.openPost", basicInfo: true } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchPosts", "term": "climate tech", "limit": 5, "filter": { "datePosted": "pastWeek", }, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": True, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.searchPosts", "term": "climate tech", "limit": 5, "filter": { "datePosted": "pastWeek" }, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.openPost", "basicInfo": true } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.searchPosts", "success": true, "data": [ { ... "then": { "actionType": "st.openPost", "success": true, "data": { ... "author": { "type": "person", "name": "Example Person", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/example-person", "headline": "Product Marketing Lead" } } } } ] } ``` > Search results carry no `urn` on their `author` and `reposter`. The URN is read from the post's own page, so nest [st.openPost](/docs/action-st-open-post) as above when you need it. 4. **Liking the latest 3 posts from a person directly, without opening them:** **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "then": { "actionType": "st.retrievePersonPosts", "limit": 3, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.reactToPost", "type": "like" } } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.openPersonPage", personUrl: "https://www.linkedin.com/in/person1", then: { actionType: "st.retrievePersonPosts", limit: 3, then: { actionType: "st.doForPosts", then: { actionType: "st.reactToPost", type: "like" } } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "then": { "actionType": "st.retrievePersonPosts", "limit": 3, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.reactToPost", "type": "like", }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "then": { "actionType": "st.retrievePersonPosts", "limit": 3, "then": { "actionType": "st.doForPosts", "then": { "actionType": "st.reactToPost", "type": "like" } } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.openPersonPage", "success": true, "data": { ... "then": { "actionType": "st.retrievePersonPosts", "success": true, "data": [ { ... "then": { "actionType": "st.reactToPost", "success": true } } ] } } } ``` ## st.doForSkills This action allows you to endorse each skill provided by the parent action, or only the skills whose names match the patterns you specify. :::surface node This action cannot start a workflow on its own. The Node SDK's [`endorsePersonSkills`](/docs/endorsing-skills#endorsing-skills-that-match-patterns) method runs it for you: it opens the profile, reads the skills and endorses the ones `skillNamePatterns` selects, or all of them. To combine it with other actions, put it under [st.retrievePersonSkills](/docs/action-st-retrieve-person-skills) in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface python This action cannot start a workflow on its own. The Python SDK's [`endorse_person_skills`](/docs/endorsing-skills#endorsing-skills-that-match-patterns) method runs it for you: it opens the profile, reads the skills and endorses the ones `skill_name_patterns` selects, or all of them. To combine it with other actions, put it under [st.retrievePersonSkills](/docs/action-st-retrieve-person-skills) in the workflow you pass to [`custom_workflow`](/sdks/custom-workflow), where the action keeps its JSON keys such as `skillNamePatterns` – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow on its own. `linkedin person endorse --pattern …` (or `--all`) runs it for you: it opens the profile, reads the skills and endorses the ones the patterns select – see [Endorsing skills](/docs/endorsing-skills#endorsing-skills-that-match-patterns) for the CLI rules. To combine it with other actions, put it under [st.retrievePersonSkills](/docs/action-st-retrieve-person-skills) in the workflow you pass to [`linkedin workflow run`](/cli/workflows). ::: > Skill names are shown as the profile owner entered them, in the owner's language, whatever your own LinkedIn language. A German profile lists `Projektmanagement`, not `Project Management`. Write patterns in the language of the profiles you target, or cover several languages in one pattern, such as `"proje[ck]t\\s*management"`. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.retrievePersonSkills](/docs/action-st-retrieve-person-skills). > ⬇️ **Child Actions:** [st.endorsePersonSkill](/docs/action-st-endorse-person-skill). ## Parameters ```json tab=HTTP,CLI { "actionType": "st.doForSkills", "skillNamePatterns": ["business", "project\\s*manage", "^(agile|scrum)$"], "then": { "actionType": "st.endorsePersonSkill" } } ``` ```typescript tab=Node { actionType: "st.doForSkills", skillNamePatterns: ["business", "project\\s*manage", "^(agile|scrum)$"], then: { actionType: "st.endorsePersonSkill" } } ``` ```python tab=Python { "actionType": "st.doForSkills", "skillNamePatterns": ["business", "project\\s*manage", "^(agile|scrum)$"], "then": { "actionType": "st.endorsePersonSkill", }, } ``` - `skillNamePatterns` (optional) – array of regular expressions that select skills by name. A skill is selected when **any** of the patterns matches its name. If omitted, every skill is selected. - The syntax is [RE2](https://github.com/google/re2/wiki/Syntax), the dialect of Go `regexp` and Google Sheets `REGEXMATCH`. Ordinary patterns read like JavaScript regular expressions, but lookaround (`(?=…)`, `(?!…)`, `(?<=…)`, `(?>> result.data [PersonSkillEndorsement(skill_name='Business Strategy', success=True, error=None), PersonSkillEndorsement(skill_name='Project Management', success=True, error=None), PersonSkillEndorsement(skill_name='Scrum', success=False, error=LinkedApiActionError(type='limitExceeded', message='The configured limit for this action category has been exceeded.'))] >>> result.errors [] ``` ```json tab=CLI { "success": true, "data": [ { "skillName": "Business Strategy", "success": true }, { "skillName": "Project Management", "success": true }, { "skillName": "Scrum", "success": false, "error": { "type": "limitExceeded", "message": "The configured limit for this action category has been exceeded." } } ] } ``` `Python` matches none of the patterns. `Agile Coaching` is not selected either, because `^(agile|scrum)$` must match the whole name. `Scrum` was selected, but the account had reached its endorsement limit by then, so it was not endorsed. 2. **Endorsing every skill of a person:** **Workflow:** ```json tab=HTTP { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": false, "then": { "actionType": "st.retrievePersonSkills", "then": { "actionType": "st.doForSkills", "then": { "actionType": "st.endorsePersonSkill" } } } } ``` ```typescript tab=Node const workflow = await linkedapi.endorsePersonSkills.execute({ personUrl: "https://www.linkedin.com/in/person1" }); const { data, errors } = await linkedapi.endorsePersonSkills.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import EndorsePersonSkillsParams workflow = linkedapi.endorse_person_skills.execute( EndorsePersonSkillsParams( person_url="https://www.linkedin.com/in/person1", ) ) result = linkedapi.endorse_person_skills.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin person endorse https://www.linkedin.com/in/person1 --all ``` :::surface http **Completion:** ::: :::surface node **Result** – `result()` returns in `data` one item per selected skill, with `skillName`, `success` and `error`; skills the patterns did not select are left out. Check every item: `errors` only covers failures to open the profile or read its skills. ::: :::surface python **Result** – `result()` returns in `data` one `PersonSkillEndorsement` per selected skill, with `skill_name`, `success` and `error`; skills the patterns did not select are left out. Check every item: `errors` only covers failures to open the profile or read its skills. ::: :::surface cli **Output** – `linkedin person endorse` prints one item per selected skill, with `skillName`, `success` and `error`, as `data`; skills the patterns did not select are left out. This is the JSON it prints with `--json` or into a pipe; check every item. ::: ```json tab=HTTP { "actionType": "st.openPersonPage", "success": true, "data": { "then": { "actionType": "st.retrievePersonSkills", "success": true, "data": [ { "name": "Business Strategy", "then": [ { "actionType": "st.endorsePersonSkill", "success": true } ] }, { "name": "Python", "then": [ { "actionType": "st.endorsePersonSkill", "success": true } ] } ] } } } ``` ```json tab=Node { "data": [ { "skillName": "Business Strategy", "success": true }, { "skillName": "Python", "success": true } ], "errors": [] } ``` ```text tab=Python >>> result.data [PersonSkillEndorsement(skill_name='Business Strategy', success=True, error=None), PersonSkillEndorsement(skill_name='Python', success=True, error=None)] >>> result.errors [] ``` ```json tab=CLI { "success": true, "data": [ { "skillName": "Business Strategy", "success": true }, { "skillName": "Python", "success": true } ] } ``` ## st.doForComments This action allows you to apply the actions specified in its `then` parameter to each comment provided by the parent action. :::surface node,python This action cannot start a workflow, so the SDK has no method for it: put it under its parent action in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow, so the CLI has no command for it: put it under its parent action in the workflow you pass to [`linkedin workflow run`](/cli/workflows) – see [Building Workflows](/docs/building-workflows). ::: > The comments this action iterates over are the ones returned by the parent [st.retrievePostComments](/docs/action-st-retrieve-post-comments) action, so their number is controlled by its `limit` parameter. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.retrievePostComments](/docs/action-st-retrieve-post-comments). > ⬇️ **Child Actions:** [st.openComment](/docs/action-st-open-comment), [st.reactToComment](/docs/action-st-react-to-comment), [st.replyToComment](/docs/action-st-reply-to-comment). ## Parameters ```json tab=HTTP,CLI { "actionType": "st.doForComments", "then": { ... } } ``` ```typescript tab=Node { actionType: "st.doForComments", then: { ... } } ``` ```python tab=Python { "actionType": "st.doForComments", "then": {...}, } ``` - `then` – object or array of child actions to apply to each comment from the parent action. ## Result options This action doesn't produce its own distinct result. The effects are visible in the comments that were involved, as they will contain a `then` field with the results of the applied actions. See usage examples for more details. ## Usage examples 1. **Liking the 5 most relevant comments on a post:** **Workflow:** ```json tab=HTTP { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 5, "sort": "mostRelevant", "then": { "actionType": "st.doForComments", "then": { "actionType": "st.reactToComment", "type": "like" } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.retrievePostComments", postUrl: "https://www.linkedin.com/posts/post1", limit: 5, sort: "mostRelevant", then: { actionType: "st.doForComments", then: { actionType: "st.reactToComment", type: "like" } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 5, "sort": "mostRelevant", "then": { "actionType": "st.doForComments", "then": { "actionType": "st.reactToComment", "type": "like", }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 5, "sort": "mostRelevant", "then": { "actionType": "st.doForComments", "then": { "actionType": "st.reactToComment", "type": "like" } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.retrievePostComments", "success": true, "data": [ { ... "then": { "actionType": "st.reactToComment", "success": true } }, { ... "then": { "actionType": "st.reactToComment", "success": true } } ] } ``` 2. **Reacting to and replying to each comment through the `st.openComment` wrapper:** **Workflow:** ```json tab=HTTP { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 3, "then": { "actionType": "st.doForComments", "then": { "actionType": "st.openComment", "then": [ { "actionType": "st.reactToComment", "type": "celebrate" }, { "actionType": "st.replyToComment", "text": "Well said, thanks for sharing!" } ] } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.retrievePostComments", postUrl: "https://www.linkedin.com/posts/post1", limit: 3, then: { actionType: "st.doForComments", then: { actionType: "st.openComment", then: [ { actionType: "st.reactToComment", type: "celebrate" }, { actionType: "st.replyToComment", text: "Well said, thanks for sharing!" } ] } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 3, "then": { "actionType": "st.doForComments", "then": { "actionType": "st.openComment", "then": [ { "actionType": "st.reactToComment", "type": "celebrate", }, { "actionType": "st.replyToComment", "text": "Well said, thanks for sharing!", }, ], }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/post1", "limit": 3, "then": { "actionType": "st.doForComments", "then": { "actionType": "st.openComment", "then": [ { "actionType": "st.reactToComment", "type": "celebrate" }, { "actionType": "st.replyToComment", "text": "Well said, thanks for sharing!" } ] } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.retrievePostComments", "success": true, "data": [ { ... "then": { "actionType": "st.openComment", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543210)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "then": [ { "actionType": "st.reactToComment", "success": true }, { "actionType": "st.replyToComment", "success": true, "data": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,1122334455)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(1122334455%2Curn%3Ali%3Aactivity%3A1234567890123456789)" } } ] } } } ] } ``` ## st.doForJobs This action allows you to apply the actions specified in its `then` parameter to each job provided by the parent action. :::surface node,python This action cannot start a workflow, so the SDK has no method for it: put it under its parent action in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow, so the CLI has no command for it: put it under its parent action in the workflow you pass to [`linkedin workflow run`](/cli/workflows) – see [Building Workflows](/docs/building-workflows). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [st.searchJobs](/docs/action-st-search-jobs). > ⬇️ **Child Actions:** [st.openJob](/docs/action-st-open-job). ## Parameters ```json tab=HTTP,CLI { "actionType": "st.doForJobs", "then": { ... } } ``` ```typescript tab=Node { actionType: "st.doForJobs", then: { ... } } ``` ```python tab=Python { "actionType": "st.doForJobs", "then": {...}, } ``` - `then` – object or array of child actions to apply to each job from the parent action. ## Result options This action doesn't produce its own distinct result. The effects are visible in the jobs that were involved, as they will contain a `then` field with the results of the applied actions. See usage examples for more details. ## Usage examples 1. **Retrieving details for jobs from search results:** **Workflow:** ```json tab=HTTP { "actionType": "st.searchJobs", "term": "product manager", "limit": 2, "then": { "actionType": "st.doForJobs", "then": { "actionType": "st.openJob", "basicInfo": true } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.searchJobs", term: "product manager", limit: 2, then: { actionType: "st.doForJobs", then: { actionType: "st.openJob", basicInfo: true } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchJobs", "term": "product manager", "limit": 2, "then": { "actionType": "st.doForJobs", "then": { "actionType": "st.openJob", "basicInfo": True, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "st.searchJobs", "term": "product manager", "limit": 2, "then": { "actionType": "st.doForJobs", "then": { "actionType": "st.openJob", "basicInfo": true } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "st.searchJobs", "success": true, "data": [ { "jobId": "4416248954", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "title": "Senior Product Manager", "companyName": "Example Company", "location": "San Francisco, CA", "workplaceType": "remote", "salary": { "currency": "usd", "minAmount": 140000, "maxAmount": 180000, "period": "yearly" }, "easyApply": true, "isPromoted": false, "isSimilarMatch": false, "then": { "actionType": "st.openJob", "success": true, "data": { "jobId": "4416248954", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "title": "Senior Product Manager", "companyName": "Example Company", "companyUrl": "https://www.linkedin.com/company/example-company", "location": "San Francisco, CA", "postedDate": "1w", "applicantsCount": 84, "workplaceType": "remote", "employmentType": "Full-time", "salary": { "currency": "usd", "minAmount": 140000, "maxAmount": 180000, "period": "yearly" }, "description": "Example job description text.", "applyUrl": "https://www.linkedin.com/jobs/view/4416248954/apply/", "easyApply": true } } } ] } ``` ## nv.sendMessage This action allows you to send a message to a person in Sales Navigator. :::surface node,python In the SDK this is the `nvSendMessage` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. Unlike `sendMessage`, this method cannot manage the conversation in the same call: to archive or unarchive it, call [`nvManageConversation`](/docs/action-nv-manage-conversation) separately with the conversation's `threadId`. ::: :::surface cli In the CLI this is `linkedin navigator message send` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` or `threadId` parameter is provided. > ⬆️ **Parent Actions:** [nv.openPersonPage](/docs/action-nv-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "nv.sendMessage", "label": "person1", "personUrl": "https://www.linkedin.com/in/person1", "text": "Hi! I'd love to connect and discuss some ideas.", "subject": "Let's Connect!" } ``` ```typescript tab=Node const workflow = await linkedapi.nvSendMessage.execute({ personUrl: "https://www.linkedin.com/in/person1", text: "Hi! I'd love to connect and discuss some ideas.", subject: "Let's Connect!" }); const { errors } = await linkedapi.nvSendMessage.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import NvSendMessageParams workflow = linkedapi.nv_send_message.execute( NvSendMessageParams( person_url="https://www.linkedin.com/in/person1", text="Hi! I'd love to connect and discuss some ideas.", subject="Let's Connect!", ) ) result = linkedapi.nv_send_message.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin navigator message send \ https://www.linkedin.com/in/person1 \ "Hi! I'd love to connect and discuss some ideas." \ --subject "Let's Connect!" ``` In AI assistants, the MCP server runs this action as the `nv_send_message` tool. :::surface cli In the CLI this is `linkedin navigator message send`: ```bash linkedin navigator message send --subject linkedin navigator message send --thread-id ``` | Arg | Required | Description | | --- | --- | --- | | `person-url` | conditional | LinkedIn profile URL of the recipient. Omit it when `--thread-id` is provided | | `text` | yes | Message text (up to 1900 characters) | | Flag | Type | Required | Description | | --- | --- | --- | --- | | `--subject` | string | unless `--thread-id` is given | Message subject line (up to 80 characters), required when starting a new conversation | | `--thread-id` | string | no | Reply into an existing conversation thread instead of passing `person-url` | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start unless `threadId` is provided, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person you want to send a message to. - `threadId` (optional) – identifier of an existing conversation thread to reply into, as returned by [inbox polling](/docs/monitoring-inbox), or read from the address bar of an open conversation — the `` in `linkedin.com/sales/inbox/`. Provide either `personUrl` or `threadId`; if both are given, `threadId` takes precedence. - `text` – message text, must be up to **1900** characters. - `subject` – subject line, must be up to **80** characters. Required when starting a new conversation; ignored when replying into an existing thread via `threadId`. ## Result options 1. **Successful message sending:** ```json { "actionType": "nv.sendMessage", "label": "person1", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **Failed message sending:** ```json { "actionType": "nv.sendMessage", "label": "person1", "success": false, "error": { "type": "personNotFound", "message": "The provided URL is not an existing LinkedIn person." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `personNotFound` – provided URL is not an existing LinkedIn person. - `noSalesNavigator` – your account does not have Sales Navigator subscription. - `messagingNotAllowed` – sending a message to the person is not allowed. This could happen for several reasons: - Your monthly Sales Navigator message limit has been reached. - LinkedIn has restricted your ability to send messages to the person, for example, due the person’s privacy settings. ## nv.syncConversation This action loads your Sales Navigator message history with a person. It finds the person's Sales Navigator threads and reads each of them. Read the history, together with the new messages the inbox captures, through [`/inbox/poll` with `personUrl`](/docs/monitoring-inbox#polling-one-person). See [Loading conversation history](/docs/working-with-conversations) for when you need it. :::surface node,python In the SDK this is the `nvSyncConversation` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin navigator message get` – the command is below, under [Parameters](#parameters). ::: > 💡 Run it once per person – not to check for new messages. New messages reach you through [inbox monitoring](/docs/monitoring-inbox). ## Constraints > ⏺️ **Root Start:** allowed, when `personUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.openPersonPage](/docs/action-st-open-person-page), [nv.openPersonPage](/docs/action-nv-open-person-page). > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "nv.syncConversation", "label": "sync1", "personUrl": "https://www.linkedin.com/in/person1", "days": 14 } ``` ```typescript tab=Node const workflow = await linkedapi.nvSyncConversation.execute({ personUrl: "https://www.linkedin.com/in/person1", days: 14 }); const { data, errors } = await linkedapi.nvSyncConversation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import NvSyncConversationParams workflow = linkedapi.nv_sync_conversation.execute( NvSyncConversationParams( person_url="https://www.linkedin.com/in/person1", days=14, ) ) result = linkedapi.nv_sync_conversation.result(workflow.workflow_id) ``` ```bash tab=CLI # Not available in the CLI: days linkedin navigator message get https://www.linkedin.com/in/person1 ``` In AI assistants, the MCP server runs this action as the `nv_sync_conversation` tool. :::surface cli The CLI has no command that only loads history: `linkedin navigator message get` reads one Sales Navigator conversation and, when its earlier history was never loaded, loads it with this action first. ```bash linkedin navigator message get [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--since` | string | Only return messages since this ISO timestamp | ```bash linkedin navigator message get https://www.linkedin.com/in/john-doe --since 2024-01-15T10:30:00Z ``` New messages reach you through `linkedin inbox get --person --type nv` ([`POST /inbox/poll`](/docs/monitoring-inbox?surface=cli)). ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personUrl` (required for root start, forbidden for parent start) – [public or hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person whose conversation you want to load. - `days` (optional) – how many days Linked API keeps adding this person's new messages to the loaded history, from 1 to 90. Defaults to 30. ## Syncing period > 💡 **`days` is the syncing period, not the depth of history.** It sets how many days new messages keep being added; the action always reads the whole conversation, however old it is. Linked API keeps adding this person's new messages to the loaded history until the `syncUntil` moment returned by this action, counted from the moment the action starts running. With [inbox monitoring](/docs/monitoring-inbox) enabled, new messages from this person keep arriving through the inbox after that moment as well. After that moment nothing is deleted: [polling](/docs/monitoring-inbox#polling-one-person) keeps returning the messages collected so far. Running this action again for the same person keeps the accumulated history and never shortens the period: `syncUntil` stays at the later of the current end and the new one. A reply from the other person does not extend the period. A thread read back to its first message is reported with `historyComplete: true` when you [poll the person](/docs/monitoring-inbox#polling-one-person). A very long conversation may not be read to its start within the action's time limit: the messages read are kept, and the thread stays `historyComplete: false`. This action does not enable inbox monitoring and does not produce [inbox webhook events](/docs/webhook-event-types#inbox-events). ## Result options 1. **Successful syncing:** ```json { "actionType": "nv.syncConversation", "label": "sync1", "success": true, "data": { "syncUntil": "2026-09-09T09:55:31.582Z" } } ``` - `label` – included only if specified in the action parameters. - `data.syncUntil` – moment until which Linked API keeps adding this person's new messages to the loaded history. 2. **Failed syncing:** ```json { "actionType": "nv.syncConversation", "label": "sync1", "success": false, "error": { "type": "noSalesNavigator", "message": "Your account does not have Sales Navigator subscription." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `noSalesNavigator` – your account does not have Sales Navigator subscription. - `personNotFound` – provided URL is not an existing LinkedIn person. ## nv.syncInbox This action enables **whole-inbox monitoring** for your Sales Navigator inbox so you can [poll the inbox](/docs/monitoring-inbox) for messages across every conversation. :::surface node,python In the SDK this is the `nvSyncInbox` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin inbox sync` with `--nv`. ::: > 💡 **Recommended first step for reading Sales Navigator messages.** See the [Messaging overview](/docs/messaging-overview) for how it fits with the other messaging tools. `nv.syncInbox` starts tracking **all** Sales Navigator threads. Run it once per account. After it succeeds, new messages become available through the [inbox polling endpoint](/docs/monitoring-inbox). > 💡 Only messages that arrive **after** inbox sync is enabled are captured. To get a person's earlier history, load it once with [`nv.syncConversation`](/docs/action-nv-sync-conversation) and read it through [inbox polling with `personUrl`](/docs/monitoring-inbox#polling-one-person). This action only switches monitoring on: it records the setting and returns. It does not browse your inbox and does not open anyone's profile. The messages themselves are picked up afterwards by a sync that runs periodically, so the first ones appear on the next sync rather than the moment the action completes. > **Note:** When that sync finds new messages in a thread – including messages you send yourself – it opens the profile page of the other participant in that thread to resolve their public URL – but only when Linked API does not already know it, so a person is visited once, which is what fills `personPublicUrl` on the messages you [poll](/docs/monitoring-inbox) or receive by [webhook](/docs/webhooks). Those visits are made from your own account, and such a visit is visible to the person whose profile is opened. ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "nv.syncInbox", "label": "enableNvInbox" } ``` ```typescript tab=Node const workflow = await linkedapi.nvSyncInbox.execute({}); const { errors } = await linkedapi.nvSyncInbox.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.nv_sync_inbox.execute() result = linkedapi.nv_sync_inbox.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin inbox sync --nv ``` In AI assistants, the MCP server runs this action as the `nv_sync_inbox` tool. - `label` (optional) – custom label for tracking this action in workflow completion. This action takes no other parameters. ## Result options 1. **Successful enabling:** ```json { "actionType": "nv.syncInbox", "label": "enableNvInbox", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **Failed enabling:** ```json { "actionType": "nv.syncInbox", "label": "enableNvInbox", "success": false, "error": { "type": "noSalesNavigator", "message": "Your account does not have Sales Navigator subscription." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `noSalesNavigator` – your account does not have Sales Navigator subscription. ## nv.manageConversation This action allows you to manage a conversation thread in Sales Navigator — archive or unarchive it — by its `threadId`. :::surface node,python In the SDK this is the `nvManageConversation` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON. It returns no `data`: a failed action comes back in `errors`, with one of the error types below. ::: :::surface cli In the CLI this is `linkedin navigator message manage` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `threadId` parameter is provided. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** N/A. ## Parameters ```json tab=HTTP { "actionType": "nv.manageConversation", "label": "thread1", "threadId": "2-Zjhm...", "operation": "archive" } ``` ```typescript tab=Node const workflow = await linkedapi.nvManageConversation.execute({ threadId: "2-Zjhm...", operation: "archive" }); const { errors } = await linkedapi.nvManageConversation.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import NvManageConversationParams workflow = linkedapi.nv_manage_conversation.execute( NvManageConversationParams( thread_id="2-Zjhm...", operation="archive", ) ) result = linkedapi.nv_manage_conversation.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin navigator message manage 2-Zjhm... archive ``` In AI assistants, the MCP server runs this action as the `nv_manage_conversation` tool. :::surface cli In the CLI this is `linkedin navigator message manage`: ```bash linkedin navigator message manage ``` | Arg | Required | Description | | --- | --- | --- | | `thread-id` | yes | Conversation thread identifier, as returned by `inbox get --type nv` / `navigator message get`, or the `` in `linkedin.com/sales/inbox/` of an open conversation | | `operation` | yes | One of `archive`, `unarchive` | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `threadId` (required) – identifier of the conversation thread to manage, as returned by [inbox polling](/docs/monitoring-inbox). You can also read it from the address bar of an open conversation — it is the `` in `linkedin.com/sales/inbox/`. - `operation` – operation to apply to the thread. One of: - `archive` / `unarchive` – archive or unarchive the conversation. ## Result options 1. **Successful operation:** ```json { "actionType": "nv.manageConversation", "label": "thread1", "success": true } ``` - `label` – included only if specified in the action parameters. 2. **Failed operation:** ```json { "actionType": "nv.manageConversation", "label": "thread1", "success": false, "error": { "type": "threadNotFound", "message": "The provided thread identifier does not match an existing conversation." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `noSalesNavigator` – your account does not have Sales Navigator subscription. - `threadNotFound` – provided `threadId` does not match an existing conversation. ## nv.searchPeople This action allows you to search for people in Sales Navigator applying various filtering criteria. :::surface node,python In the SDK this is the `nvSearchPeople` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin navigator person search` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** [nv.doForPeople](/docs/action-nv-do-for-people). ## Parameters ```json tab=HTTP { "actionType": "nv.searchPeople", "label": "johnDoeSearch1", "term": "John Doe", "limit": 2, "filter": { "firstName": "John", "lastName": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "currentCompanies": ["Tech Solutions", "Innovatech"], "previousCompanies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], "yearsOfExperiences": ["lessThanOne", "oneToTwo", "threeToFive"], "connectionDegrees": ["2nd", "groupMembers"] }, "customSearchUrl": "https://www.linkedin.com/sales/search/people?query=(recentSearchParam%3A(doLogHistory%3Atrue)%2CspellCorrectionEnabled%3Atrue%2Ckeywords%3ABill%2520Gates)", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.nvSearchPeople.execute({ term: "John Doe", limit: 2, filter: { firstName: "John", lastName: "Doe", position: "CEO", locations: ["New York", "San Francisco", "London"], industries: ["Software Development", "Professional Services"], currentCompanies: ["Tech Solutions", "Innovatech"], previousCompanies: ["FutureCorp"], schools: ["Harvard University", "MIT"], yearsOfExperiences: ["lessThanOne", "oneToTwo", "threeToFive"], connectionDegrees: ["2nd", "groupMembers"] }, customSearchUrl: "https://www.linkedin.com/sales/search/people?query=(recentSearchParam%3A(doLogHistory%3Atrue)%2CspellCorrectionEnabled%3Atrue%2Ckeywords%3ABill%2520Gates)" }); const { data, errors } = await linkedapi.nvSearchPeople.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import NvSearchPeopleParams workflow = linkedapi.nv_search_people.execute( NvSearchPeopleParams( term="John Doe", limit=2, filter={ "first_name": "John", "last_name": "Doe", "position": "CEO", "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "current_companies": ["Tech Solutions", "Innovatech"], "previous_companies": ["FutureCorp"], "schools": ["Harvard University", "MIT"], "years_of_experiences": ["lessThanOne", "oneToTwo", "threeToFive"], "connection_degrees": ["2nd", "groupMembers"], }, custom_search_url="https://www.linkedin.com/sales/search/people?query=(recentSearchParam%3A(doLogHistory%3Atrue)%2CspellCorrectionEnabled%3Atrue%2Ckeywords%3ABill%2520Gates)", ) ) result = linkedapi.nv_search_people.result(workflow.workflow_id) ``` ```bash tab=CLI # Not available in the CLI: customSearchUrl linkedin navigator person search \ --term "John Doe" \ --limit 2 \ --first-name John \ --last-name Doe \ --position CEO \ --locations "New York,San Francisco,London" \ --industries "Software Development,Professional Services" \ --current-companies "Tech Solutions,Innovatech" \ --previous-companies FutureCorp \ --schools "Harvard University,MIT" \ --years-of-experience lessThanOne,oneToTwo,threeToFive \ --connection-degrees 2nd,groupMembers ``` In AI assistants, the MCP server runs this action as the `nv_search_people` tool. :::surface cli In the CLI this is `linkedin navigator person search`: ```bash linkedin navigator person search [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--term` | string | Search keyword or phrase | | `--limit` | integer | Max results to return | | `--first-name` | string | Filter by first name | | `--last-name` | string | Filter by last name | | `--position` | string | Filter by job position | | `--locations` | string | Comma-separated locations | | `--industries` | string | Comma-separated industries | | `--current-companies` | string | Comma-separated current company names | | `--previous-companies` | string | Comma-separated previous company names | | `--schools` | string | Comma-separated school names | | `--years-of-experience` | string | Comma-separated experience ranges: `lessThanOne`, `oneToTwo`, `threeToFive`, `sixToTen`, `moreThanTen` | | `--connection-degrees` | string | Comma-separated connection degrees: `1st`, `2nd`, `3rd+`, `groupMembers` | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `term` (optional) – keyword or phrase to search, from 1 to 50 characters. Either `term` or `customSearchUrl` must be provided; a request with neither (for example, with `filter` only) is rejected. - `limit` (optional) – number of search results to return. Defaults to **25**, with a maximum value of **2500**. - `filter` (optional) – object that specifies filtering criteria for people. When multiple filter fields are specified, they are combined using `AND` logic. - `firstName` (optional) – first name of person. - `lastName` (optional) – last name of person. - `position` (optional) – job position of person. - `locations` (optional) – array of free-form strings representing locations. Matches if person is located in any of the listed locations. - `industries` (optional) – array of enums representing industries. Matches if person works in any of the listed industries. Takes specific values available in the LinkedIn interface. - `currentCompanies` (optional) – array of company names. Matches if person currently works at any of the listed companies. - `previousCompanies` (optional) – array of company names. Matches if person previously worked at any of the listed companies. - `schools` (optional) – array of institution names. Matches if person currently attends or previously attended any of the listed institutions. - `yearsOfExperiences` (optional) – array of enums representing professional experience. Matches if person’s experience falls within any of the listed ranges. Options: - `lessThanOne` – less than 1 year. - `oneToTwo` – 1 to 2 years. - `threeToFive` – 3 to 5 years. - `sixToTen` – 6 to 10 years. - `moreThanTen` – more than 10 years. - `connectionDegrees` (optional) – array of enums representing your connection degree to the person. Matches if the person is at any of the listed degrees. Options: - `1st` – your 1st-degree connections. - `2nd` – your 2nd-degree connections. - `3rd+` – 3rd-degree connections and beyond. - `groupMembers` – members of LinkedIn groups you belong to. - `customSearchUrl` (optional) – URL copied from Sales Navigator search results page after configuring desired filters. When specified, overrides term and filter parameters. Allows using any search configuration available in Sales Navigator. Either `term` or `customSearchUrl` must be provided. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful search:** ```json { "actionType": "nv.searchPeople", "label": "johnDoeSearch1", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8OeJoj0mrkxx7Jiuy0", "position": "Founder & CEO", "location": "London", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "viewerState": { "connectionDegree": "2nd" }, "then": { ... } }, { "name": "John Doe", "urn": null, "hashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPoу3Gho0o", "position": "Product Manager", "location": "New York", "avatarUrl": null, "viewerState": { "connectionDegree": "3rd+" }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of search outputs with results of child actions execution. - `name` – full name of the person. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `hashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `position` – job position of the person. - `location` – free-form string indicating the person's location. - `avatarUrl` – URL of the person's profile photo, or `null` if the person has no photo. - `viewerState` – how your account relates to the person, see [viewer state](/docs/data-conventions#viewer-state). - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` if LinkedIn does not show it. - `then` – results of child actions execution. 2. **Failed search:** ```json { "actionType": "nv.searchPeople", "label": "johnDoeSearch1", "success": false, "error": { "type": "noSalesNavigator", "message": "Your account does not have Sales Navigator subscription." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – Sales Navigator offered no matching option for one or more filter values. The message lists each field and value, so correct or remove them rather than retrying. - `noSalesNavigator` – your account does not have Sales Navigator subscription. ## nv.openPersonPage This action allows you to open a person page in Sales Navigator to retrieve their basic information and perform additional person-related actions if needed. :::surface node,python In the SDK this is the `nvFetchPerson` method. It always returns basic information: `basicInfo`, like `label` and `then`, exists only in workflow JSON. It takes the other parameters below and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin navigator person fetch` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `personHashedUrl` parameter is provided. > ⬆️ **Parent Actions:** [nv.doForPeople](/docs/action-nv-do-for-people), [st.openPersonPage](/docs/action-st-open-person-page). > ⬇️ **Child Actions:** [nv.sendMessage](/docs/action-nv-send-message), [nv.syncConversation](/docs/action-nv-sync-conversation), [st.openPersonPage](/docs/action-st-open-person-page). ## Parameters ```json tab=HTTP { "actionType": "nv.openPersonPage", "label": "person1", "personHashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "basicInfo": true, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.nvFetchPerson.execute({ personHashedUrl: "https://www.linkedin.com/in/SInQBmjJ015eLr8" }); const { data, errors } = await linkedapi.nvFetchPerson.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import NvOpenPersonPageParams workflow = linkedapi.nv_fetch_person.execute( NvOpenPersonPageParams( person_hashed_url="https://www.linkedin.com/in/SInQBmjJ015eLr8", ) ) result = linkedapi.nv_fetch_person.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin navigator person fetch https://www.linkedin.com/in/SInQBmjJ015eLr8 ``` In AI assistants, the MCP server runs this action as the `nv_fetch_person` tool. :::surface cli In the CLI this is `linkedin navigator person fetch`: ```bash linkedin navigator person fetch ``` | Arg | Required | Description | | --- | --- | --- | | `hashed-url` | yes | Hashed LinkedIn profile URL | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `personHashedUrl` (required for root start, forbidden for parent start) – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `basicInfo` (optional, default: `false`) – when set to `true`, the action includes basic person information in the results. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful page opening with `basicInfo` set to `true`:** ```json { "actionType": "nv.openPersonPage", "label": "person1", "success": true, "data": { "name": "John Doe", "urn": "urn:li:member:123456789", "publicUrl": "https://www.linkedin.com/in/person1", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "countryCode": "US", "position": "Software Engineer", "companyName": "TechCorp", "companyUrn": "urn:li:organization:12345678", "companyHashedUrl": "https://www.linkedin.com/company/12345678", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data` – basic information about the person and results of child actions execution. - `name` – full name of the person. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the person, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `hashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person. - `headline` – headline of the person. - `location` – free-form string indicating the person's location. - `countryCode` – two-character code of the person's country. - `position` – current job position of the person. - `companyName` – name of the person's current company. - `companyUrn` – [URN](/docs/data-conventions#linkedin-urns) of the person's current company, or `null` if LinkedIn does not expose it. - `companyHashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the person's current company. - `avatarUrl` – URL of the person's profile photo, or `null` if the person has no photo. 2. **Successful page opening with `basicInfo` set to `false`:** ```json { "actionType": "nv.openPersonPage", "label": "person1", "success": true, "data": { "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data.then` – results of child actions execution. 3. **Failed page opening:** ```json { "actionType": "nv.openPersonPage", "label": "person1", "success": false, "error": { "type": "personNotFound", "message": "The provided URL is not an existing LinkedIn person." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `noSalesNavigator` – your account does not have Sales Navigator subscription. - `personNotFound` – provided URL is not an existing LinkedIn person. ## nv.searchCompanies This action allows you to search for companies in Sales Navigator applying various filtering criteria. :::surface node,python In the SDK this is the `nvSearchCompanies` method. It takes the parameters below except `label` and `then`, which exist only in workflow JSON, and returns the result below in `data`. ::: :::surface cli In the CLI this is `linkedin navigator company search` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed. > ⬆️ **Parent Actions:** N/A. > ⬇️ **Child Actions:** [nv.doForCompanies](/docs/action-nv-do-for-companies). ## Parameters ```json tab=HTTP { "actionType": "nv.searchCompanies", "label": "techIncSearch1", "term": "Tech Inc", "limit": 2, "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], "annualRevenue": { "min": "0", "max": "2.5" } }, "customSearchUrl": "https://www.linkedin.com/sales/search/company?query=(spellCorrectionEnabled%3Atrue%2Ckeywords%3ALinked%2520API)", "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.nvSearchCompanies.execute({ term: "Tech Inc", limit: 2, filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York"], industries: ["Software Development", "Robotics Engineering"], annualRevenue: { min: "0", max: "2.5" } }, customSearchUrl: "https://www.linkedin.com/sales/search/company?query=(spellCorrectionEnabled%3Atrue%2Ckeywords%3ALinked%2520API)" }); const { data, errors } = await linkedapi.nvSearchCompanies.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import NvSearchCompaniesParams workflow = linkedapi.nv_search_companies.execute( NvSearchCompaniesParams( term="Tech Inc", limit=2, filter={ "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], "annual_revenue": {"min": "0", "max": "2.5"}, }, custom_search_url="https://www.linkedin.com/sales/search/company?query=(spellCorrectionEnabled%3Atrue%2Ckeywords%3ALinked%2520API)", ) ) result = linkedapi.nv_search_companies.result(workflow.workflow_id) ``` ```bash tab=CLI # Not available in the CLI: customSearchUrl linkedin navigator company search \ --term "Tech Inc" \ --limit 2 \ --sizes 51-200,201-500 \ --locations "San Francisco,New York" \ --industries "Software Development,Robotics Engineering" \ --revenue-min 0 \ --revenue-max 2.5 ``` In AI assistants, the MCP server runs this action as the `nv_search_companies` tool. :::surface cli In the CLI this is `linkedin navigator company search`: ```bash linkedin navigator company search [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--term` | string | Search keyword or phrase | | `--limit` | integer | Max results to return | | `--sizes` | string | Company sizes, comma-separated | | `--locations` | string | Comma-separated locations | | `--industries` | string | Comma-separated industries | | `--revenue-min` | string | Min annual revenue in M USD: `0`, `0.5`, `1`, `2.5`, `5`, `10`, `20`, `50`, `100`, `500`, `1000` | | `--revenue-max` | string | Max annual revenue in M USD: `0.5`, `1`, `2.5`, `5`, `10`, `20`, `50`, `100`, `500`, `1000`, `1000+` | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `term` (optional) – keyword or phrase to search, from 1 to 50 characters. Either `term` or `customSearchUrl` must be provided; a request with neither (for example, with `filter` only) is rejected. - `limit` (optional) – number of search results to return. Defaults to **25**, with a maximum value of **1000**. - `filter` (optional) – object that specifies filtering criteria for companies. When multiple filter fields are specified, they are combined using `AND` logic. - `sizes` (optional) – array of enums representing employee count ranges. Matches if company’s size falls within any of the listed ranges. Options: - `1-10`. - `11-50`. - `51-200`. - `201-500`. - `501-1000`. - `1001-5000`. - `5001-10000`. - `10001+`. - `locations` (optional) – array of free-form strings representing locations. Matches if company is headquartered in any of the listed locations. - `industries` (optional) – array of enums representing industries. Matches if company operates in any of the listed industries. Takes specific values available in the LinkedIn interface. - `annualRevenue` (optional) – object representing company annual revenue range in million USD; `min` must be lower than `max`: - `min` – enum with options: - `0`. - `0.5`. - `1`. - `2.5`. - `5`. - `10`. - `20`. - `50`. - `100`. - `500`. - `1000`. - `max` – enum with options: - `0.5`. - `1`. - `2.5`. - `5`. - `10`. - `20`. - `50`. - `100`. - `500`. - `1000`. - `1000+`. - `customSearchUrl` (optional) – URL copied from Sales Navigator search results page after configuring desired filters. When specified, overrides term and filter parameters. Allows using any search configuration available in Sales Navigator. Either `term` or `customSearchUrl` must be provided. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful search:** ```json { "actionType": "nv.searchCompanies", "label": "techIncSearch1", "success": true, "data": [ { "name": "TechCorp", "urn": "urn:li:organization:12345678", "hashedUrl": "https://www.linkedin.com/company/12345678", "industry": "Information Technology", "employeesCount": 500, "logoUrl": "https://media.licdn.com/dms/image/v2/C4D0BAQE/company-logo_100_100/0/1700000000000", "then": { ... } }, { "name": "Techical Life", "urn": null, "hashedUrl": "https://www.linkedin.com/company/87654321", "industry": "Software Development", "employeesCount": 120, "logoUrl": null, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of search outputs with results of child actions execution. - `name` – name of the company. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the company, or `null` if LinkedIn does not expose it. - `hashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the company. - `industry` – enum representing the company industry. Takes specific values available in the LinkedIn interface. - `employeesCount` – total number of employees associated with the company. - `logoUrl` – URL of the company's logo, or `null` if the company has no logo. - `then` – results of child actions execution. 2. **Failed search:** ```json { "actionType": "nv.searchCompanies", "label": "techIncSearch1", "success": false, "error": { "type": "noSalesNavigator", "message": "Your account does not have Sales Navigator subscription." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – Sales Navigator offered no matching option for one or more filter values. The message lists each field and value, so correct or remove them rather than retrying. - `noSalesNavigator` – your account does not have Sales Navigator subscription. ## nv.openCompanyPage This action allows you to open a company page in Sales Navigator to retrieve its basic information and perform additional company-related actions if needed. :::surface node In the Node SDK this is `linkedapi.nvFetchCompany` – the call is below, under [Parameters](#parameters). ::: :::surface python In the Python SDK this is `linkedapi.nv_fetch_company` – the call is below, under [Parameters](#parameters). ::: :::surface cli In the CLI this is `linkedin navigator company fetch` – the command is below, under [Parameters](#parameters). ::: ## Constraints > ⏺️ **Root Start:** allowed, when `companyHashedUrl` parameter is provided. > ⬆️ **Parent Actions:** [st.openCompanyPage](/docs/action-st-open-company-page), [nv.doForCompanies](/docs/action-nv-do-for-companies). > ⬇️ **Child Actions:** [nv.retrieveCompanyEmployees](/docs/action-nv-retrieve-company-employees), [nv.retrieveCompanyDMs](/docs/action-nv-retrieve-company-dms), [st.openCompanyPage](/docs/action-st-open-company-page). ## Parameters ```json tab=HTTP { "actionType": "nv.openCompanyPage", "label": "company1", "companyHashedUrl": "https://www.linkedin.com/company/12345678", "basicInfo": true, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.nvFetchCompany.execute({ companyHashedUrl: "https://www.linkedin.com/company/12345678" }); const { data, errors } = await linkedapi.nvFetchCompany.result(workflow.workflowId); ``` ```python tab=Python from linkedapi import NvFetchCompanyParams workflow = linkedapi.nv_fetch_company.execute( NvFetchCompanyParams( company_hashed_url="https://www.linkedin.com/company/12345678", ) ) result = linkedapi.nv_fetch_company.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin navigator company fetch https://www.linkedin.com/company/12345678 ``` In AI assistants, the MCP server runs this action as the `nv_fetch_company` tool. :::surface node,python In the SDK this is the `nvFetchCompany` method. It always returns basic information: `basicInfo`, like `label` and `then`, exists only in workflow JSON. It takes the other parameters below and returns the result below in `data`. Each `retrieve…` flag of `nvFetchCompany` adds one child action, and its data comes back under its own property of `data`: | Flag | Child action | Configuration | Property of `data` | | --- | --- | --- | --- | | `retrieveEmployees` | [`nv.retrieveCompanyEmployees`](/docs/action-nv-retrieve-company-employees) | `employeesRetrievalConfig`: `limit`, `filter` | `employees` | | `retrieveDMs` | [`nv.retrieveCompanyDMs`](/docs/action-nv-retrieve-company-dms) | `dmsRetrievalConfig`: `limit` | `dms` | A configuration object takes effect only with its flag set to `true`, and takes the parameters of its child action – their defaults, limits and the employee `filter` fields are on the child's page. For example, up to 100 employees: ::: :::surface node ```typescript const workflow = await linkedapi.nvFetchCompany.execute({ companyHashedUrl: "https://www.linkedin.com/company/12345678", retrieveEmployees: true, employeesRetrievalConfig: { limit: 100 } }); ``` ::: :::surface python ```python from linkedapi import NvFetchCompanyParams workflow = linkedapi.nv_fetch_company.execute( NvFetchCompanyParams( company_hashed_url="https://www.linkedin.com/company/12345678", retrieve_employees=True, employees_retrieval_config={"limit": 100}, ) ) ``` ::: :::surface node,python When a child action fails, its error comes back in `errors` and the rest of `data` is still returned. ::: :::surface cli In the CLI this is `linkedin navigator company fetch`: ```bash linkedin navigator company fetch [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--employees` | boolean | Include employee data | | `--dms` | boolean | Include decision makers | | `--employees-limit` | integer | Max employees to retrieve | | `--employees-first-name` | string | Filter employees by first name | | `--employees-last-name` | string | Filter employees by last name | | `--employees-positions` | string | Filter employees by positions, comma-separated | | `--employees-locations` | string | Filter employees by locations, comma-separated | | `--employees-industries` | string | Filter employees by industries, comma-separated | | `--employees-schools` | string | Filter employees by schools, comma-separated | | `--employees-years-of-experience` | string | Filter employees by experience ranges, comma-separated | | `--employees-connection-degrees` | string | Filter employees by connection degrees (`1st`, `2nd`, `3rd+`, `groupMembers`), comma-separated | | `--dms-limit` | integer | Max decision makers to retrieve | ::: - `label` (optional) – custom label for tracking this action in workflow completion. - `companyHashedUrl` (required for root start, forbidden for parent start) – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the company. - `basicInfo` (optional, default: `false`) – when set to `true`, the action includes basic company information in the results. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful page opening with `basicInfo` set to `true`:** ```json { "actionType": "nv.openCompanyPage", "label": "company1", "success": true, "data": { "name": "TechCorp", "urn": "urn:li:organization:12345678", "publicUrl": "https://www.linkedin.com/company/company1", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "Cupertino, California", "headquarters": "US", "industry": "Information Technology", "website": "https://techcorp.com", "employeesCount": 500, "yearFounded": 2019, "logoUrl": "https://media.licdn.com/dms/image/v2/C4D0BAQE/company-logo_200_200/0/1700000000000", "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data` – basic information about the company and results of child actions execution. - `name` – name of the company. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the company, or `null` if LinkedIn does not expose it. - `publicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the company. - `description` – description of the company. - `location` – free-form string representing the company headquarters location. - `headquarters` – two-character country code (e.g., "US", "UK") representing headquarters location. - `industry` – enum representing the company industry. Takes specific values available in the LinkedIn interface. - `logoUrl` – URL of the company's logo, or `null` if the company has no logo. 2. **Successful page opening with `basicInfo` set to `false`:** ```json { "actionType": "nv.openCompanyPage", "label": "company1", "success": true, "data": { "then": { ... } } } ``` - `label` – included only if specified in the action parameters. - `data.then` – results of child actions execution. 3. **Failed page opening:** ```json { "actionType": "nv.openCompanyPage", "label": "company1", "success": false, "error": { "type": "companyNotFound", "message": "The provided URL is not an existing LinkedIn company." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `noSalesNavigator` – your account does not have Sales Navigator subscription. - `companyNotFound` – provided URL is not an existing LinkedIn company. ## nv.retrieveCompanyEmployees This action allows you to retrieve company employees from Sales Navigator and perform additional person-related actions if needed. :::surface node,python In the SDK this action runs inside `nvFetchCompany` with `retrieveEmployees` enabled; its result comes back in `data.employees`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin navigator company fetch` with `--employees`; that command's other options are on [`nv.openCompanyPage`](/docs/action-nv-open-company-page). ::: ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [nv.openCompanyPage](/docs/action-nv-open-company-page). > ⬇️ **Child Actions:** [nv.doForPeople](/docs/action-nv-do-for-people). ## Parameters ```json tab=HTTP { "actionType": "nv.retrieveCompanyEmployees", "label": "company1Employees", "limit": 300, "filter": { "firstName": "John", "lastName": "Doe", "positions": ["Manager", "Executive"], "locations": ["New York", "San Francisco", "London"], "industries": ["Software Development", "Professional Services"], "schools": ["Harvard University", "MIT"], "yearsOfExperiences": ["threeToFive", "sixToTen"], "connectionDegrees": ["2nd", "groupMembers"] }, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.nvFetchCompany.execute({ companyHashedUrl: "https://www.linkedin.com/company/12345678", retrieveEmployees: true, }); const { data, errors } = await linkedapi.nvFetchCompany.result(workflow.workflowId); console.log(data?.employees); console.log(errors); ``` ```python tab=Python from linkedapi import NvFetchCompanyParams workflow = linkedapi.nv_fetch_company.execute( NvFetchCompanyParams( company_hashed_url="https://www.linkedin.com/company/12345678", retrieve_employees=True, ) ) result = linkedapi.nv_fetch_company.result(workflow.workflow_id) if result.data: print(result.data.employees) print(result.errors) ``` ```bash tab=CLI linkedin navigator company fetch https://www.linkedin.com/company/12345678 --employees --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` – (optional) maximum number of employees to retrieve. Defaults to **25**, with a maximum value of **2500**. - `filter` (optional) – object that specifies filtering criteria for employees. When multiple filter fields are specified, they are combined using `AND` logic. - `firstName` (optional) – first name of employee. - `lastName` (optional) – last name of employee. - `positions` (optional) – array of job position names. Matches if employee's current position is any of the listed options. - `locations` (optional) – array of free-form strings representing locations. Matches if employee is located in any of the listed locations. - `industries` (optional) – array of enums representing industries. Matches if employee works in any of the listed industries. Takes specific values available in the LinkedIn interface. - `schools` (optional) – array of institution names. Matches if employee currently attends or previously attended any of the listed institutions. - `yearsOfExperiences` (optional) – array of enums representing professional experience. Matches if employee’s experience falls within any of the listed ranges. Options: - `lessThanOne` – less than 1 year. - `oneToTwo` – 1 to 2 years. - `threeToFive` – 3 to 5 years. - `sixToTen` – 6 to 10 years. - `moreThanTen` – more than 10 years. - `connectionDegrees` (optional) – array of enums representing your connection degree to the employee. Matches if the employee is at any of the listed degrees. Options: - `1st` – your 1st-degree connections. - `2nd` – your 2nd-degree connections. - `3rd+` – 3rd-degree connections and beyond. - `groupMembers` – members of LinkedIn groups you belong to. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful retrieval:** ```json { "actionType": "nv.retrieveCompanyEmployees", "label": "company1Employees", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8OeJoj0mrkxx7Jiuy0", "position": "Manager", "location": "New York, USA", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "viewerState": { "connectionDegree": "2nd" }, "then": { ... } }, { "name": "Jane Smith", "urn": null, "hashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPoу3Gho0o", "position": "Software Engineer", "location": "San Francisco, USA", "avatarUrl": null, "viewerState": { "connectionDegree": "3rd+" }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of company employees with results of child actions execution. - `name` – full name of the employee. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the employee, or `null` if LinkedIn does not expose it. - `hashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the employee. - `position` – job position of the employee. - `location` – free-form string indicating the employee's location. - `avatarUrl` – URL of the employee's profile photo, or `null` if the employee has no photo. - `viewerState` – how your account relates to the employee, see [viewer state](/docs/data-conventions#viewer-state). - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` if LinkedIn does not show it. - `then` – results of child actions execution. 2. **Failed retrieval:** ```json { "actionType": "nv.retrieveCompanyEmployees", "label": "company1Employees", "success": false, "error": { "type": "retrievingNotAllowed", "message": "LinkedIn has blocked performing the retrieval." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `filterNotApplied` – Sales Navigator offered no matching option for one or more filter values. The message lists each field and value, so correct or remove them rather than retrying. - `retrievingNotAllowed` – LinkedIn has blocked performing the retrieval due to exceeding limits or other restrictions. ## nv.retrieveCompanyDMs This action allows you to retrieve company decision makers from Sales Navigator and perform additional person-related actions if needed. :::surface node,python In the SDK this action runs inside `nvFetchCompany` with `retrieveDMs` enabled; its result comes back in `data.dms`, and its errors in `errors`. ::: :::surface cli In the CLI this action runs inside `linkedin navigator company fetch` with `--dms`; that command's other options are on [`nv.openCompanyPage`](/docs/action-nv-open-company-page). ::: > Decision makers are ranked by seniority, starting with the highest-level positions. For example, founders and C-level executives are returned first, followed by VPs, directors, and so on. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [nv.openCompanyPage](/docs/action-nv-open-company-page). > ⬇️ **Child Actions:** [nv.doForPeople](/docs/action-nv-do-for-people). ## Parameters ```json tab=HTTP { "actionType": "nv.retrieveCompanyDMs", "label": "company1DMs", "limit": 3, "then": { ... } } ``` ```typescript tab=Node const workflow = await linkedapi.nvFetchCompany.execute({ companyHashedUrl: "https://www.linkedin.com/company/12345678", retrieveDMs: true, }); const { data, errors } = await linkedapi.nvFetchCompany.result(workflow.workflowId); console.log(data?.dms); console.log(errors); ``` ```python tab=Python from linkedapi import NvFetchCompanyParams workflow = linkedapi.nv_fetch_company.execute( NvFetchCompanyParams( company_hashed_url="https://www.linkedin.com/company/12345678", retrieve_dms=True, ) ) result = linkedapi.nv_fetch_company.result(workflow.workflow_id) if result.data: print(result.data.dms) print(result.errors) ``` ```bash tab=CLI linkedin navigator company fetch https://www.linkedin.com/company/12345678 --dms --json ``` - `label` (optional) – custom label for tracking this action in workflow completion. - `limit` (optional) – maximum number of decision makers to retrieve. Defaults to **20**, with a maximum value of **20**. If a company has fewer decision makers than specified, only the available ones will be returned. - `then` (optional) – object or array of child actions to be executed within this action. ## Result options 1. **Successful retrieval:** ```json { "actionType": "nv.retrieveCompanyDMs", "label": "company1DMs", "success": true, "data": [ { "name": "John Doe", "urn": "urn:li:member:123456789", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8OeJoj0mrkxx7Jiuy0", "position": "Founder", "location": "New York, USA", "avatarUrl": "https://media.licdn.com/dms/image/v2/D5603AQE/profile-displayphoto-shrink_100_100/0/1700000000000", "countryCode": "US", "viewerState": { "connectionDegree": "2nd" }, "then": { ... } }, { "name": "Jane Smith", "urn": null, "hashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPoу3Gho0o", "position": "CEO", "location": "San Francisco, USA", "avatarUrl": null, "countryCode": "US", "viewerState": { "connectionDegree": "3rd+" }, "then": { ... } } ] } ``` - `label` – included only if specified in the action parameters. - `data` – array of decision makers with results of child actions execution. - `name` – full name of the decision-maker. - `urn` – [URN](/docs/data-conventions#linkedin-urns) of the decision-maker, or `null` if LinkedIn does not expose it. - `hashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the decision-maker. - `position` – job position of the decision-maker. - `location` – free-form string indicating the decision-maker's location. - `avatarUrl` – URL of the decision-maker's profile photo, or `null` if the decision-maker has no photo. - `countryCode` – two-character code of the decision-maker's country. - `viewerState` – how your account relates to the decision-maker, see [viewer state](/docs/data-conventions#viewer-state). - `connectionDegree` – `1st`, `2nd`, or `3rd+`, as LinkedIn shows it next to the name, or `null` if LinkedIn does not show it. - `then` – results of child actions execution. 2. **Failed retrieval:** ```json { "actionType": "nv.retrieveCompanyDMs", "label": "company1DMs", "success": false, "error": { "type": "retrievingNotAllowed", "message": "LinkedIn has blocked performing the retrieval." } } ``` - `label` – included only if specified in the action parameters. - `error.type` – enum with the following possible values: - `retrievingNotAllowed` – LinkedIn has blocked performing the retrieval due to exceeding limits or other restrictions. ## nv.doForCompanies This action allows you to apply the actions specified in its `then` parameter to each company provided by the parent action in Sales Navigator. :::surface node,python This action cannot start a workflow, so the SDK has no method for it: put it under its parent action in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow, so the CLI has no command for it: put it under its parent action in the workflow you pass to [`linkedin workflow run`](/cli/workflows) – see [Building Workflows](/docs/building-workflows). ::: > There is a limit of 20 companies that this action can be applied to. If the parent action provides more than 20 companies, only the first 20 will be processed. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [nv.searchCompanies](/docs/action-nv-search-companies). > ⬇️ **Child Actions:** [nv.openCompanyPage](/docs/action-nv-open-company-page). ## Parameters ```json tab=HTTP,CLI { "actionType": "nv.doForCompanies", "then": { ... } } ``` ```typescript tab=Node { actionType: "nv.doForCompanies", then: { ... } } ``` ```python tab=Python { "actionType": "nv.doForCompanies", "then": {...}, } ``` - `then` – object or array of child actions to apply to each company from the parent action. ## Result options This action doesn't produce its own distinct result. The effects are visible in the companies that were involved, as they will contain a `then` field with the results of the applied actions. See usage examples for more details. ## Usage examples 1. **Retrieving basic information about searched companies in Sales Navigator:** **Workflow:** ```json tab=HTTP { "actionType": "nv.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], "annualRevenue": { "min": "0", "max": "2.5" } }, "then": { "actionType": "nv.doForCompanies", "then": { "actionType": "nv.openCompanyPage", "basicInfo": true } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "nv.searchCompanies", term: "Tech Inc", filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York"], industries: ["Software Development", "Robotics Engineering"], annualRevenue: { min: "0", max: "2.5" } }, then: { actionType: "nv.doForCompanies", then: { actionType: "nv.openCompanyPage", basicInfo: true } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "nv.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York"], "industries": ["Software Development", "Robotics Engineering"], "annualRevenue": { "min": "0", "max": "2.5", }, }, "then": { "actionType": "nv.doForCompanies", "then": { "actionType": "nv.openCompanyPage", "basicInfo": True, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "nv.searchCompanies", "term": "Tech Inc", "filter": { "sizes": [ "51-200", "201-500" ], "locations": [ "San Francisco", "New York" ], "industries": [ "Software Development", "Robotics Engineering" ], "annualRevenue": { "min": "0", "max": "2.5" } }, "then": { "actionType": "nv.doForCompanies", "then": { "actionType": "nv.openCompanyPage", "basicInfo": true } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "nv.searchCompanies", "success": true, "data": [ { "name": "TechCorp", "hashedUrl": "https://www.linkedin.com/company/12345678", "industry": "Information Technology", "employeeCount": 500, "then": { "actionType": "nv.openCompanyPage", "success": true, "data": { "name": "TechCorp", "publicUrl": "https://www.linkedin.com/company/techcorp", "description": "TechCorp is a leading provider of innovative technology solutions for businesses worldwide.", "location": "Cupertino, California", "headquarters": "US", "industry": "Information Technology", "website": "https://techcorp.com", "employeeCount": 500, "yearFounded": 2019 } } }, { "name": "Techical Life", "hashedUrl": "https://www.linkedin.com/company/12345678", "industry": "Software Development", "employeeCount": 230, "then": { "actionType": "nv.openCompanyPage", "success": true, "data": { "name": "Techical Life", "publicUrl": "https://www.linkedin.com/company/techlife", "description": "TechLife is a worldwide leader in software development for sustainability.", "location": "Mountain View", "headquarters": "US", "industry": "Software Development", "website": "https://techlife.com", "employeeCount": 230, "yearFounded": 2016 } } } ] } ``` 2. **Retrieving decision makers of searched companies in Sales Navigator:** **Workflow:** ```json tab=HTTP { "actionType": "nv.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York", "London"], "industries": ["Software Development", "Robotics Engineering"], "annualRevenue": { "min": "0", "max": "2.5" } }, "then": { "actionType": "nv.doForCompanies", "then": { "actionType": "nv.openCompanyPage", "then": { "actionType": "nv.retrieveCompanyDMs" } } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "nv.searchCompanies", term: "Tech Inc", filter: { sizes: ["51-200", "201-500"], locations: ["San Francisco", "New York", "London"], industries: ["Software Development", "Robotics Engineering"], annualRevenue: { min: "0", max: "2.5" } }, then: { actionType: "nv.doForCompanies", then: { actionType: "nv.openCompanyPage", then: { actionType: "nv.retrieveCompanyDMs" } } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "nv.searchCompanies", "term": "Tech Inc", "filter": { "sizes": ["51-200", "201-500"], "locations": ["San Francisco", "New York", "London"], "industries": ["Software Development", "Robotics Engineering"], "annualRevenue": { "min": "0", "max": "2.5", }, }, "then": { "actionType": "nv.doForCompanies", "then": { "actionType": "nv.openCompanyPage", "then": { "actionType": "nv.retrieveCompanyDMs", }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "nv.searchCompanies", "term": "Tech Inc", "filter": { "sizes": [ "51-200", "201-500" ], "locations": [ "San Francisco", "New York", "London" ], "industries": [ "Software Development", "Robotics Engineering" ], "annualRevenue": { "min": "0", "max": "2.5" } }, "then": { "actionType": "nv.doForCompanies", "then": { "actionType": "nv.openCompanyPage", "then": { "actionType": "nv.retrieveCompanyDMs" } } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "nv.searchCompanies", "success": true, "data": [ { "name": "TechCorp", "hashedUrl": "https://www.linkedin.com/company/12345678", "industry": "Information Technology", "employeeCount": 500, "then": { "actionType": "nv.openCompanyPage", "success": true, "data": { "then": { "actionType": "nv.retrieveCompanyDMs", "success": true, "data": [ { "name": "John Doe", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8OeJoj0mrkxx7Jiuy0", "position": "Founder", "location": "New York, USA", "countryCode": "US" }, { "name": "Jane Smith", "hashedUrl": "https://www.linkedin.com/in/ACoAA2DdsSV83B48UDvgO5jPoу3Gho0o", "position": "CEO", "location": "San Francisco, USA", "countryCode": "US" } ] } } } }, { "name": "Techical Life", "hashedUrl": "https://www.linkedin.com/company/12345678", "industry": "Software Development", "employeeCount": 230, "then": { "actionType": "nv.openCompanyPage", "success": true, "data": { "then": { "actionType": "nv.retrieveCompanyDMs", "success": true, "data": [ { "name": "Row Pitterson", "hashedUrl": "https://www.linkedin.com/in/S0mrkxx7JiuyInQBmjJ015eLr8OeJoj0", "position": "Founder", "location": "London, UK", "countryCode": "UK" }, { "name": "Angela White", "hashedUrl": "https://www.linkedin.com/in/48UDvgO5jPoу3Gho0oACoAA2DdsSV83B", "position": "CEO", "location": "San Francisco, USA", "countryCode": "US" } ] } } } } ] } ``` ## nv.doForPeople This action allows you to apply the actions specified in its `then` parameter to each person provided by the parent action in Sales Navigator. :::surface node,python This action cannot start a workflow, so the SDK has no method for it: put it under its parent action in the workflow you pass to [`customWorkflow`](/sdks/custom-workflow) – see [Building Workflows](/docs/building-workflows). ::: :::surface cli This action cannot start a workflow, so the CLI has no command for it: put it under its parent action in the workflow you pass to [`linkedin workflow run`](/cli/workflows) – see [Building Workflows](/docs/building-workflows). ::: > There is a limit of 20 people that this action can be applied to. If the parent action provides more than 20 people, only the first 20 will be processed. ## Constraints > ⏺️ **Root Start:** not allowed. > ⬆️ **Parent Actions:** [nv.searchPeople](/docs/action-nv-search-people), [nv.retrieveCompanyEmployees](/docs/action-nv-retrieve-company-employees), [nv.retrieveCompanyDMs](/docs/action-nv-retrieve-company-dms). > ⬇️ **Child Actions:** [nv.openPersonPage](/docs/action-nv-open-person-page). ## Parameters ```json tab=HTTP,CLI { "actionType": "nv.doForPeople", "then": { ... } } ``` ```typescript tab=Node { actionType: "nv.doForPeople", then: { ... } } ``` ```python tab=Python { "actionType": "nv.doForPeople", "then": {...}, } ``` - `then` – object or array of child actions to apply to each person from the parent action. ## Result options This action doesn't produce its own distinct result. The effects are visible in the people that were involved, as they will contain a `then` field with the results of the applied actions. See usage examples for more details. ## Usage examples 1. **Retrieving basic information about the first 2 people from the Sales Navigator search results:** **Workflow:** ```json tab=HTTP { "actionType": "nv.searchPeople", "term": "John Doe", "limit": 2, "filter": { "position": "CEO", "locations": ["New York", "San Francisco", "London"] }, "then": { "actionType": "nv.doForPeople", "then": { "actionType": "nv.openPersonPage", "basicInfo": true } } } ``` ```typescript tab=Node const workflow = await linkedapi.customWorkflow.execute({ actionType: "nv.searchPeople", term: "John Doe", limit: 2, filter: { position: "CEO", locations: ["New York", "San Francisco", "London"] }, then: { actionType: "nv.doForPeople", then: { actionType: "nv.openPersonPage", basicInfo: true } } }); const { data, errors } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python tab=Python workflow = linkedapi.custom_workflow.execute( { "actionType": "nv.searchPeople", "term": "John Doe", "limit": 2, "filter": { "position": "CEO", "locations": ["New York", "San Francisco", "London"], }, "then": { "actionType": "nv.doForPeople", "then": { "actionType": "nv.openPersonPage", "basicInfo": True, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) ``` ```bash tab=CLI linkedin workflow run <<'EOF' { "actionType": "nv.searchPeople", "term": "John Doe", "limit": 2, "filter": { "position": "CEO", "locations": [ "New York", "San Francisco", "London" ] }, "then": { "actionType": "nv.doForPeople", "then": { "actionType": "nv.openPersonPage", "basicInfo": true } } } EOF ``` :::surface http **Completion:** ::: :::surface node,python **Completion** – `result()` returns this whole completion in `data`: ::: :::surface cli **Completion** – `linkedin workflow run` prints this whole completion as `data`: ::: ```json { "actionType": "nv.searchPeople", "success": true, "data": [ { "name": "John Doe", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "position": "Founder & CEO", "location": "San Francisco, USA", "then": { "actionType": "nv.openPersonPage", "success": true, "data": { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johndoe", "hashedUrl": "https://www.linkedin.com/in/SInQBmjJ015eLr8", "headline": "Software Engineer at TechCorp", "location": "San Francisco, USA", "countryCode": "US", "position": "Software Engineer", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678" } } }, { "name": "John Doe", "hashedUrl": "https://www.linkedin.com/in/CoAA2DdsSV83B48", "position": "Product Manager", "location": "New York, USA", "then": { "actionType": "nv.openPersonPage", "success": true, "data": { "name": "John Doe", "publicUrl": "https://www.linkedin.com/in/johnyd", "hashedUrl": "https://www.linkedin.com/in/Hs763KsjdB153e", "headline": "Product Manager at TechCorp", "location": "New York, USA", "countryCode": "US", "position": "Product Manager", "companyName": "TechCorp", "companyHashedUrl": "https://www.linkedin.com/company/12345678" } } } ] } ``` ## Signals Overview > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. A workflow does something **now**: open a profile, search, send a message. A signal tells you **when something happens**: a person publishes a post, someone comments under your post, a new person views your profile, an invitation is accepted. You create a signal once. From then on, Linked API checks its target in the background, inside the account's working hours and within its limits, and records an event every time something new appears. You read the events by polling one endpoint or receive them as webhook events. No cron job on your side, and no re-reading the same profile every day to see whether anything changed. | You want to know when… | Signal type | |---|---| | someone accepts your invitation, sends you one, or becomes your connection | [`network`](/docs/signal-network) | | a person or a company publishes a new post | [`personPosts`, `companyPosts`](/docs/signal-posts) | | a new post matches a keyword | [`postSearch`](/docs/signal-keyword-posts) | | someone comments on or reacts to a post – yours or any other | [`postEngagement`](/docs/signal-post-engagement) | | someone views your profile | [`profileViewers`](/docs/signal-profile-viewers) | > 💡 New **messages** are not a signal type: they are covered by [inbox monitoring](/docs/monitoring-inbox), which also keeps the conversations themselves, so you can read a whole thread and not just learn that it changed. ## How a signal works 1. **You create it** with [`POST /signals`](/docs/managing-signals#creating-a-signal): a type and a target, such as a person's URL or a keyword. See [Managing signals](/docs/managing-signals). 2. **The first check is a silent baseline.** It records what is already there – existing posts, comments, connections, viewers – and emits nothing. Only what appears after it becomes an event. 3. **Linked API checks the target on a schedule** – see [How often a signal is checked](#how-often-a-signal-is-checked). A check compares what LinkedIn shows with what the signal has already seen. 4. **Every new item becomes an event.** Poll them with [`/signals/poll`](/docs/signal-events#polling) or receive them as [`signal.*` webhook events](/docs/signal-events#webhook-events). Events are kept for **90 days**. A check reads LinkedIn in the account's own cloud browser, at the same human pace as your workflows. A signal saves you the scheduling and the comparing: you get only what is new. ## How often a signal is checked | Type | Checked | |---|---| | `network` | every hour | | `profileViewers` | every 3 hours | | `personPosts` | at least twice a day, more often for people who post a lot | | `companyPosts` | at least twice a day, more often for companies that post a lot | | `postSearch` | once or twice a day, depending on how many posts the search brings | | `postEngagement` | by the post's age: about every 4 hours in its first 24 hours, once a day until it is 3 days old, once every 2 days until it is 7 days old, then very rarely | The times are spread inside the account's working hours, so checks do not land at the same minute every day. Most checks spend nothing from the account's [limits](/docs/admin-limits). Two kinds do: - **Every `postSearch` check** spends 1 of `stSearchQueries`. - **The first check of a `personPosts` or `companyPosts` signal** opens the person's profile or the company page once, to record who the signal watches. That visit counts as 1 of `stPersonProfileViews` or `stCompanyPageViews`, and the person may see the account among their profile viewers, as after any visit. Later checks find new posts through LinkedIn search and do not open the profile again. ## Signals and your own work Signals share the account with your workflows, and they are designed to stay out of their way: - **Your workflows go first.** An account runs one thing at a time. A check starts only when no workflow of yours is waiting, so it never jumps ahead of your requests. A workflow you send while a check is running waits for that check to finish, then runs in workflow queue order. An account that is busy all day gets fewer checks. - **Checks stay inside [working hours](/docs/working-hours)**, under every off-hours policy – the same as inbox and network synchronisation. - **Checks that spend [limits](/docs/admin-limits) share them with your actions.** LinkedIn sees one account, so a search by a signal counts like any other. Once a limit is used up for the current period, checks that need it wait until the next one. - **Checks pause with the account.** While the account needs reconnection or is frozen, nothing is checked. Checks resume on their own once the account is active again. With default limits, 20 `personPosts` signals spend 20 of the account's 80 daily profile views once, on their first checks, and nothing after that. Each `postSearch` signal spends 30 to 60 of the account's 300 monthly searches, so five of them can use all 300. How to split the limits between signals and your own workflows is up to you; to make searches go further, put several phrases into one signal rather than one signal per phrase. ## Limits on signals An account can have this many active signals of each type. Paused signals do not count. | Type | Active signals per account | |---|---| | `personPosts` | 20 | | `companyPosts` | 20 | | `postSearch` | 5 | | `postEngagement` on one post (`postUrl`) | 15 | | `postEngagement` on the account's own posts (`myPosts`) | 1 | | `network` | 1 | | `profileViewers` | 1 | `network`, `profileViewers` and `myPosts` watch the account itself and take no target, so an account has one of each. Creating a signal that already exists – the same type and target on the same account – returns the existing signal instead of a second one. ## Signals in SDKs, CLI and AI agents The same signals are available in every way you reach Linked API: | Surface | Create | Read new events | |---|---|---| | REST API | `POST /signals` | `POST /signals/poll` or `signal.*` webhook events | | Node and Python SDKs | `createSignal` / `create_signal` | `pollSignals` / `poll_signals` | | CLI | `linkedin signals create` | `linkedin signals poll` | | MCP server | `create_signal` tool | `poll_signals` tool | For an AI agent, a signal is the missing piece of "keep an eye on it": an agent cannot receive webhooks or wake itself up on a schedule, but it can ask what is new since the last time – and the answer is already collected. ## Managing Signals > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. Signals belong to a LinkedIn account. Every request below carries the same two [authorization headers](/docs/making-requests#authorization) as the rest of the API, and the `identification-token` decides which account the signal watches. | Task | Request | |---|---| | Create a signal | `POST /signals` | | List signals | `GET /signals` | | Get one signal | `GET /signals/{signalId}` | | Pause or resume | `POST /signals/{signalId}/pause`, `POST /signals/{signalId}/resume` | | Delete | `DELETE /signals/{signalId}` | To read what signals detected, see [Receiving events](/docs/signal-events). ## Creating a signal ```text POST https://api.linkedapi.io/signals ``` The body names the signal `type` and its target. The target fields depend on the type and are described on each type's page. ```json { "type": "personPosts", "personUrl": "https://www.linkedin.com/in/person1", "label": "lead-1042" } ``` - `type` (required) – one of `network`, `personPosts`, `companyPosts`, `postSearch`, `postEngagement`, `profileViewers`. - Target fields – see [Signal types](#signal-types) below. - `label` (optional) – your own identifier, up to 64 characters, such as a CRM record ID. It is returned with the signal and with every event it produces, so you can route events without keeping a map of signal IDs. If your request is correct, you receive the signal: ```json { "success": true, "result": { "id": "5b0d3c1e-8f2a-4c1b-9e7d-2a6f4c8b1d90", "type": "personPosts", "personUrl": "https://www.linkedin.com/in/person1", "label": "lead-1042", "status": "active", "createdAt": "2026-10-12T10:00:00Z", "baselineAt": null, "lastCheckedAt": null, "nextCheckAt": "2026-10-12T10:20:00Z" } } ``` - `id` – signal identifier. - `type` and the target fields – as you created it. - `label` – your label, or `null`. - `status` – `active`, `paused` or `stopped`. See [Signal status](#signal-status). - `createdAt` – when the signal was created. - `baselineAt` – when the [silent baseline](/docs/signals-overview#how-a-signal-works) was recorded. `null` until the first check; events can appear only after it. - `lastCheckedAt` – the latest completed check, or `null`. - `nextCheckAt` – the planned time of the next check, or `null` when the signal is not active. It is a plan: your workflows and working hours can move it. Creating a signal that already exists on this account – the same `type` and target – returns that signal with `200` instead of creating a second one. A paused signal stays paused; resume it explicitly. ### Signal types | `type` | Target fields | Page | |---|---|---| | `network` | none | [Connections and invitations](/docs/signal-network) | | `personPosts` | `personUrl` | [Posts by person or company](/docs/signal-posts) | | `companyPosts` | `companyUrl` | [Posts by person or company](/docs/signal-posts) | | `postSearch` | `term` – a phrase or an array of phrases, optional `filter` | [Posts by keyword](/docs/signal-keyword-posts) | | `postEngagement` | `postUrl`, or `myPosts: true` | [Comments and reactions](/docs/signal-post-engagement) | | `profileViewers` | none | [Profile viewers](/docs/signal-profile-viewers) | ## Listing signals ```text GET https://api.linkedapi.io/signals?type=personPosts&status=active ``` Both query parameters are optional: - `type` – return only signals of this type. - `status` – return only signals with this status. ```json { "success": true, "result": { "signals": [ { "id": "5b0d3c1e-8f2a-4c1b-9e7d-2a6f4c8b1d90", "type": "personPosts", "personUrl": "https://www.linkedin.com/in/person1", "label": "lead-1042", "status": "active", "createdAt": "2026-10-12T10:00:00Z", "baselineAt": "2026-10-12T10:21:00Z", "lastCheckedAt": "2026-10-12T16:40:00Z", "nextCheckAt": "2026-10-13T09:15:00Z" } ] } } ``` `GET /signals/{signalId}` returns one signal in the same shape. ## Pausing and resuming ```text POST https://api.linkedapi.io/signals/{signalId}/pause POST https://api.linkedapi.io/signals/{signalId}/resume ``` A paused signal is not checked and spends nothing. Its events stay available. Pausing an already paused signal, or resuming an active one, changes nothing and succeeds. On resume, the first check reports what appeared while the signal was paused, as far as LinkedIn still shows it – for example, posts the person published in the meantime. It is not a new baseline. ## Deleting a signal ```text DELETE https://api.linkedapi.io/signals/{signalId} ``` Checks stop immediately. Events the signal already produced stay available through [polling](/docs/signal-events#polling) until their 90 days run out. ## Signal status - `active` – checked on schedule. - `paused` – you paused it. - `stopped` – Linked API stopped checking because the target can no longer be watched. `stoppedReason` says why: - `targetNotFound` – the profile, company page or post no longer exists or is not visible to the account. A stopped signal cannot be resumed. To watch the target again once it is available, delete the stopped signal and create a new one – while the stopped signal exists, creating the same signal returns it. > 💡 If the account itself is not active – it needs reconnection or is frozen – signals keep the status `active` but are not checked. Checks resume on their own when the account is back. ## Errors ### invalidRequestPayload The body is missing a required field, names an unknown `type`, or carries target fields that do not belong to the type. `message` names the field. ```json { "success": false, "error": { "type": "invalidRequestPayload", "message": "'personUrl' is required for signals of type 'personPosts'." } } ``` ### signalLimitReached The account already has the maximum number of active signals of this type – see [Limits on signals](/docs/signals-overview#limits-on-signals). Delete or pause one of them first. ```json { "success": false, "error": { "type": "signalLimitReached", "message": "This LinkedIn account already has the maximum number of active personPosts signals (20)." } } ``` ### signalNotFound No signal with this ID exists on the account. ```json { "success": false, "error": { "type": "signalNotFound", "message": "Signal not found." } } ``` ### signalStopped You tried to resume a signal with the status `stopped`. ```json { "success": false, "error": { "type": "signalStopped", "message": "This signal was stopped and cannot be resumed: targetNotFound." } } ``` ## Receiving Events > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. Network monitoring – [`/network/poll`](/docs/monitoring-network) and the `network.*` webhook events – works today. Every new item a signal detects – a post, a comment, a reaction, a profile view, a connection change – is recorded once as an **event**. You can read events in two ways, and both return the same event objects: - **Polling** – ask for everything recorded since your last request. - **Webhooks** – Linked API sends each event to your endpoint as it is recorded. Events are kept for **90 days**, including after the signal that produced them is deleted. ## Polling ```text POST https://api.linkedapi.io/signals/poll ``` The request body is a single object. All fields are optional: ```json { "since": "2026-10-12T00:00:00Z", "signalId": "5b0d3c1e-8f2a-4c1b-9e7d-2a6f4c8b1d90", "signalType": "personPosts", "eventType": "postPublished" } ``` - `since` – return events detected after this timestamp. If omitted, all retained events are returned. - `signalId` – only events of this signal. - `signalType` – only events of signals of this type. - `eventType` – only events of this type, such as `postPublished` or `commentAdded`. Events of every signal on the account come back together unless you filter them: ```json { "success": true, "result": { "events": [ { "id": "c7e1a0f2-3d4b-4e5f-8a9b-0c1d2e3f4a5b", "signalId": "5b0d3c1e-8f2a-4c1b-9e7d-2a6f4c8b1d90", "signalType": "personPosts", "label": "lead-1042", "type": "postPublished", "detectedAt": "2026-10-12T16:40:00Z", "post": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2026-10-12T14:05:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/person1", "headline": "Product Marketing Lead" }, "text": "We are hiring our first sales engineer.", "reactionsCount": 4, "commentsCount": 1, "repostsCount": 0 } }, { "id": "f4d92946-8821-6da6-ff4c-c36f0671292a", "signalId": "0e6f1a2b-7c8d-4e9f-a0b1-c2d3e4f5a6b7", "signalType": "network", "label": null, "type": "connectionAccepted", "detectedAt": "2026-10-12T11:35:00Z", "personUrn": "urn:li:member:987654321", "personUrl": "https://www.linkedin.com/in/person2" } ] } } ``` - `events` – array of events, ordered newest first. - `id` – unique event identifier. The same event delivered as a webhook carries the same `id`. - `signalId`, `signalType`, `label` – the signal that produced the event and its label. - `type` – event type. Each signal type page lists its event types. - `detectedAt` – when Linked API recorded the event. It is the time of the check, not the time the thing happened on LinkedIn: a post published at 14:05 and found by the 16:40 check has `detectedAt` 16:40 and `post.time` 14:05. - The rest is the event's subject: `post`, `comment`, `reaction`, `viewer`, or the person fields of a `network` event. Their fields are described on each signal type page. **Recommended flow:** 1. Leave `since` empty on the first request to retrieve everything recorded so far. 2. On every next request, pass the `detectedAt` of the most recent event you have as `since`. 3. Process each event once by its `id`. > 💡 [`/network/poll`](/docs/monitoring-network) belongs to network monitoring and keeps working as before. Events of the `network` signal come from `/signals/poll`. ## Webhook events If you have a [webhook](/docs/webhooks) registered, every signal event is also delivered to it as it is recorded. Signal events share the `signal.*` namespace: | Webhook event | Produced by | Event `type` in polling | |---|---|---| | `signal.postPublished` | `personPosts`, `companyPosts` | `postPublished` | | `signal.postFound` | `postSearch` | `postFound` | | `signal.commentAdded` | `postEngagement` | `commentAdded` | | `signal.reactionAdded` | `postEngagement` | `reactionAdded` | | `signal.profileViewed` | `profileViewers` | `profileViewed` | | `signal.connectionAccepted` | `network` | `connectionAccepted` | | `signal.connectionAdded` | `network` | `connectionAdded` | | `signal.connectionRequestReceived` | `network` | `connectionRequestReceived` | The `network.*` events belong to [network monitoring](/docs/monitoring-network), which is separate from the `network` signal. See [The network signal and network monitoring](/docs/signal-network#the-network-signal-and-network-monitoring). `data` carries the event exactly as polling returns it, plus `accountId`: ```json { "id": "signal.commentAdded:9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "type": "signal.commentAdded", "createdAt": "2026-10-12T16:40:02.000Z", "data": { "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "accountId": "f9b4346a-...", "signalId": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "signalType": "postEngagement", "label": "launch-post", "type": "commentAdded", "detectedAt": "2026-10-12T16:40:00Z", "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "comment": { "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "author": { "type": "person", "name": "John Doe", "profileUrl": "https://www.linkedin.com/in/john-doe", "headline": "Head of Growth" }, "time": "2h", "text": "Interested – how does it handle multiple accounts?", "isReply": false } } } ``` To receive only some signal events, use [event selection](/docs/webhook-event-types#event-selection): `signal.*` for all of them, or exact types such as `signal.commentAdded`. > Events you deselect from the webhook are still recorded and still returned by polling. The webhook selection controls delivery, not what signals detect. ## Connections and Invitations > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. Network monitoring – [`/network/poll`](/docs/monitoring-network) and the `network.*` webhook events – works today. The `network` signal watches the account's own connections and invitations. Use it instead of re-checking each person with [`st.checkConnectionStatus`](/docs/action-st-check-connection-status) or re-reading the connection list to see what changed. ## Events | Event `type` | Webhook event | What happened | |---|---|---| | `connectionAccepted` | `signal.connectionAccepted` | Someone accepted an invitation **you** sent. | | `connectionAdded` | `signal.connectionAdded` | A new connection that is **not** from an invitation you sent – for example, you accepted someone's invitation, or the connection was made outside the API. | | `connectionRequestReceived` | `signal.connectionRequestReceived` | Someone sent **you** an invitation. | > 💡 Together, `connectionAccepted` and `connectionAdded` cover every new 1st-degree connection. If you only care that the network grew, handle both. ## Creating The signal watches the account itself and takes no target. An account has at most one. ```json { "type": "network" } ``` | Checked | Each check spends | |---|---| | every hour | nothing from account limits | The baseline is the account's connections and pending invitations at the moment of the first check. They are never reported as events. To get the current state – the full list, or what happened before the signal existed – use [`st.retrieveConnections`](/docs/action-st-retrieve-connections), [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations) and [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests). ## Event fields ```json { "id": "f4d92946-8821-6da6-ff4c-c36f0671292a", "signalId": "0e6f1a2b-7c8d-4e9f-a0b1-c2d3e4f5a6b7", "signalType": "network", "label": null, "type": "connectionAccepted", "detectedAt": "2026-10-12T11:35:00Z", "personUrn": "urn:li:member:987654321", "personUrl": "https://www.linkedin.com/in/person2" } ``` - `personUrn` – [URN](/docs/data-conventions#linkedin-urns) of the other person, or `null` if LinkedIn did not expose it. - `personUrl` – LinkedIn URL of the other person. The common fields – `id`, `signalId`, `signalType`, `label`, `type`, `detectedAt` – are described in [Receiving events](/docs/signal-events#polling). ## Accepted invitations: a common flow After outreach, the usual question is who accepted. With the `network` signal there is nothing to re-check: 1. Create the `network` signal once per account, **before** you start sending invitations – an acceptance that happens before the baseline is not reported. 2. Send invitations with [`st.sendConnectionRequest`](/docs/action-st-send-connection-request) as usual. 3. React to `connectionAccepted` events – for example, send the first message with [`st.sendMessage`](/docs/action-st-send-message). An invitation that was not accepted produces no event. To find invitations that have waited too long, read them with [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests) and withdraw them with [`st.withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request). ## The network signal and network monitoring Until signals launch, the same changes come from [network monitoring](/docs/monitoring-network), enabled with the [`st.syncNetwork`](/docs/action-st-sync-network) action. After launch, the two stay separate: - network monitoring keeps working as it does today: its events come from [`/network/poll`](/docs/monitoring-network) and as `network.*` webhook events; - the `network` signal is created with `POST /signals`, and its events come from [`/signals/poll`](/docs/signal-events#polling) and as `signal.*` webhook events, like those of every other signal. On an account with both, each change is reported by each of them: an accepted invitation arrives as `network.connectionAccepted` and as `signal.connectionAccepted`. If you move to the signal, handle only one of them – for example, leave `network.*` out of your webhook's [event selection](/docs/webhook-event-types#event-selection). New code should use the `network` signal, so all signals of the account are managed and read in one place. ## Posts by Person or Company > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. The `personPosts` and `companyPosts` signals watch one person or one company page and report every new post on it. Use them to engage with a prospect's post while it is fresh, or to follow what customers and competitors publish, instead of running [`st.retrievePersonPosts`](/docs/action-st-retrieve-person-posts) on the same people every day. ## Events | Event `type` | Webhook event | What happened | |---|---|---| | `postPublished` | `signal.postPublished` | A post appeared in the person's or company's activity that the signal has not seen before. | Reposts count: if the person reposts someone else's post, it appears in their activity and is reported with `post.type` `repost`. ## Creating One signal watches one person or one company. Create one signal per target. ```json { "type": "personPosts", "personUrl": "https://www.linkedin.com/in/person1", "label": "lead-1042" } ``` ```json { "type": "companyPosts", "companyUrl": "https://www.linkedin.com/company/company1" } ``` - `personUrl` – for `personPosts`: LinkedIn URL of the person, public or [hashed](/faq/what-are-hashed-url-and-public-url). - `companyUrl` – for `companyPosts`: LinkedIn URL of the company page. | Type | Checked | Each check spends | |---|---|---| | `personPosts` | at least twice a day, more often for people who post a lot | nothing from account limits | | `companyPosts` | at least twice a day, more often for companies that post a lot | nothing from account limits | The first check opens the person's profile or the company page once, to record who the signal watches. That visit counts as 1 of `stPersonProfileViews` or `stCompanyPageViews`, and the person may see the account among their profile viewers, as after any visit. Later checks find new posts through LinkedIn search, without opening the profile, so they spend neither limit. The baseline is the posts already there at the first check. ## Event fields ```json { "id": "c7e1a0f2-3d4b-4e5f-8a9b-0c1d2e3f4a5b", "signalId": "5b0d3c1e-8f2a-4c1b-9e7d-2a6f4c8b1d90", "signalType": "personPosts", "label": "lead-1042", "type": "postPublished", "detectedAt": "2026-10-12T16:40:00Z", "post": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "activityUrn": "urn:li:activity:1234567890123456789", "time": "2026-10-12T14:05:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "urn": "urn:li:member:123456789", "profileUrl": "https://www.linkedin.com/in/person1", "headline": "Product Marketing Lead" }, "reposter": null, "text": "We are hiring our first sales engineer.", "repostText": null, "hashtags": ["hiring"], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 4, "commentsCount": 1, "repostsCount": 0 } } ``` - `post` – the new post, with the same fields as an item of [`st.retrievePersonPosts`](/docs/action-st-retrieve-person-posts#result-options). `reactionsCount`, `commentsCount` and `repostsCount` are as they were at the check that found the post; they are not updated later. The common fields – `id`, `signalId`, `signalType`, `label`, `type`, `detectedAt` – are described in [Receiving events](/docs/signal-events#polling). ## Reacting to a new post A signal only reports; what to do is up to you. Every post URL in an event works as input for the engagement actions: - like it with [`st.reactToPost`](/docs/action-st-react-to-post); - comment with [`st.commentOnPost`](/docs/action-st-comment-on-post); - follow its discussion with a [`postEngagement`](/docs/signal-post-engagement) signal on the same `post.url`. ## Posts by Keyword > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. The `postSearch` signal searches the latest LinkedIn posts for your phrases on a schedule and reports every post it has not seen before. Use it to find conversations about your topic, your product or a competitor as they start. ## Events | Event `type` | Webhook event | What happened | |---|---|---| | `postFound` | `signal.postFound` | A post matching the search appeared that the signal has not reported before. | ## Creating ```json { "type": "postSearch", "term": ["user onboarding", "time to value"], "filter": { "authorIndustries": ["Software Development"] }, "label": "category-watch" } ``` - `term` (required) – a phrase, or an array of phrases, each from 1 to 50 characters. Linked API searches every phrase as an exact phrase, so you do not need to put it in quotes. With an array, a post that contains any of the phrases is reported: one signal covers several wordings of the same topic. - `filter` (optional) – narrows the search. When several fields are set, a post must match all of them. - `contentType` (optional) – one of `videos`, `images`, `jobPosts`, `liveVideos`, `documents`. - `postedBy` (optional) – array of `me`, `firstConnections`, `peopleYouFollow`. - `mentioningMembers` (optional) – people the post mentions. Matches if it mentions any of them. - `mentioningCompanies` (optional) – companies the post mentions. Matches if it mentions any of them. - `authorCompanies` (optional) – companies the author works at. Matches if the author works at any of them. - `authorIndustries` (optional) – industries the author works in, by the names LinkedIn shows. Matches if the author works in any of them. Each entry of `mentioningMembers`, `mentioningCompanies` and `authorCompanies` is a name, or an object with `name` and `id` – a URN or LinkedIn URL that pins the filter to exactly that person or company. A name alone may match a namesake. The signal always looks for the newest posts, so it takes no sort order and no date range. To follow what particular people or companies publish, use a [`personPosts` or `companyPosts`](/docs/signal-posts) signal for each of them. A LinkedIn search URL is not accepted either: the signal builds the search from your phrases itself. | Checked | Each check spends | |---|---| | once or twice a day, depending on how many posts the search brings | 1 of `stSearchQueries` | A check reads the 50 most recent matching posts. The baseline is the posts that match at the first check. > 💡 Searches are the scarcest limit: by default an account has 300 a month, and each `postSearch` signal spends 30 to 60 of them. A signal with several phrases still makes one search per check, so put the wordings of one topic into one signal rather than creating a signal per phrase. ## Event fields ```json { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "signalId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "signalType": "postSearch", "label": "category-watch", "type": "postFound", "detectedAt": "2026-10-12T09:50:00Z", "post": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:2345678901234567890", "activityUrn": "urn:li:activity:2345678901234567890", "time": "2026-10-12T08:12:00Z", "type": "original", "author": { "type": "person", "name": "Example Person", "profileUrl": "https://www.linkedin.com/in/person3", "headline": "Founder at Example Company" }, "reposter": null, "text": "Looking for a LinkedIn automation API that works with our own accounts. Any recommendations?", "repostText": null, "hashtags": [], "mentions": [], "externalLinks": [], "images": [], "documentSlides": [], "hasVideo": false, "videoThumbnail": null, "hasPoll": false, "reactionsCount": 2, "commentsCount": 5, "repostsCount": 0 } } ``` - `post` – the matching post: - `url` – URL of the post. - `activityUrn` – LinkedIn activity or UGC URN of the post, if available. - `time` – when the post was published. - `type` – `original`, or `repost` for a repost. - `author` – who wrote the post. A person has `type`, `name`, `profileUrl` and `headline`; a company has `type`, `name` and `companyUrl`. Search results carry no `urn` for the author. - `reposter` – the person or company that reposted it, with the same fields as `author`. Not `null` only for a `repost`. - `text` – the author's text, if available; `repostText` – the text the reposter added, if any. - `hashtags` – hashtags in the text, without the leading `#`. - `mentions` – profile URLs of the people and companies mentioned in the text. - `externalLinks` – outbound URLs in the text. - `images` – up to 3 preview image URLs; `documentSlides` – slide image URLs of a document or carousel. - `hasVideo`, `videoThumbnail`, `hasPoll` – whether the post has a video, its thumbnail URL, and whether it has a poll. - `reactionsCount`, `commentsCount`, `repostsCount` – as they were at the check that found the post; they are not updated later. The common fields – `id`, `signalId`, `signalType`, `label`, `type`, `detectedAt` – are described in [Receiving events](/docs/signal-events#polling). A post is reported once per signal, even if it contains several of its phrases, or drops out of the search and comes back later. Two `postSearch` signals that both match a post each report it. ## Comments and Reactions > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. The `postEngagement` signal watches a post and reports every new comment and reaction on it. Point it at your own posts to answer commenters and connect with the people who engaged, or at any other post – a competitor's launch, an industry discussion – to see who joins in. ## Events | Event `type` | Webhook event | What happened | |---|---|---| | `commentAdded` | `signal.commentAdded` | A new comment or a reply to a comment appeared under the post. | | `reactionAdded` | `signal.reactionAdded` | A new person or company reacted to the post. | ## Creating Watch one post by its URL: ```json { "type": "postEngagement", "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "label": "launch-post" } ``` Or watch the account's own posts: ```json { "type": "postEngagement", "myPosts": true } ``` - `postUrl` – LinkedIn URL of the post to watch. - `myPosts` – `true` to watch the posts the account itself publishes. The signal covers the account's posts from the last 7 days, and every post the account publishes later joins it automatically. An account has at most one `myPosts` signal. Use exactly one of the two. | Checked | Each check spends | |---|---| | by the post's age: about every 4 hours in its first 24 hours, once a day until it is 3 days old, once every 2 days until it is 7 days old, then very rarely | nothing from account limits | Almost all comments and reactions on LinkedIn arrive in the first three days after a post is published, so a post is checked often while it is fresh and less often as it ages. A post older than 7 days is still checked, but very rarely, so a late comment on it can be reported days after it was written. The same applies when you create a signal on a post that is already older than 7 days. The baseline is the comments and reactions already there at the first check. ## Event fields ### commentAdded ```json { "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "signalId": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "signalType": "postEngagement", "label": "launch-post", "type": "commentAdded", "detectedAt": "2026-10-12T16:40:00Z", "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "comment": { "commentUrn": "urn:li:comment:(urn:li:activity:1234567890123456789,9876543210)", "commentUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789/?dashCommentUrn=urn%3Ali%3Afsd_comment%3A(9876543210%2Curn%3Ali%3Aactivity%3A1234567890123456789)", "author": { "type": "person", "name": "John Doe", "profileUrl": "https://www.linkedin.com/in/john-doe", "headline": "Head of Growth", "viewerState": { "connectionDegree": "2nd" } }, "time": "2h", "text": "Interested – how does it handle multiple accounts?", "image": null, "isReply": false, "parentCommentUrn": null } } ``` - `postUrl` – the post the comment belongs to. For a `myPosts` signal, this tells you which of your posts it is. - `comment` – the new comment, with the same fields as an item of [`st.retrievePostComments`](/docs/action-st-retrieve-post-comments#result-options), plus `parentCommentUrn`. `time` is the relative age LinkedIn showed at the check. - `isReply` – `true` for a reply to another comment. - `parentCommentUrn` – for a reply, the `commentUrn` of the comment it answers; `null` for a top-level comment. Answer a comment by passing its `commentUrl` to [`st.replyToComment`](/docs/action-st-reply-to-comment), or like it with [`st.reactToComment`](/docs/action-st-react-to-comment). ### reactionAdded ```json { "id": "3e4f5a6b-7c8d-4e9f-a0b1-c2d3e4f5a6b7", "signalId": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "signalType": "postEngagement", "label": "launch-post", "type": "reactionAdded", "detectedAt": "2026-10-12T16:40:00Z", "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:1234567890123456789", "reaction": { "actor": { "type": "person", "name": "Jane Doe", "urn": null, "profileUrl": "https://www.linkedin.com/in/person1", "headline": "Helping companies to use AI", "viewerState": { "connectionDegree": "3rd+" } }, "type": "insightful" } } ``` - `reaction` – who reacted and how, with the same fields as an item of [`st.retrievePostReactions`](/docs/action-st-retrieve-post-reactions#result-options). LinkedIn shows no time for reactions, so `detectedAt` – the check that found it – is the only time you get. One person is reported once per post: changing the reaction type or removing it produces no event. The common fields – `id`, `signalId`, `signalType`, `label`, `type`, `detectedAt` – are described in [Receiving events](/docs/signal-events#polling). ## Turning engagement into connections A frequent use: invite everyone who engaged with your post. 1. Create a `myPosts` signal once. 2. For each `commentAdded` or `reactionAdded` event whose author has `connectionDegree` `2nd` or `3rd+`, send an invitation with [`st.sendConnectionRequest`](/docs/action-st-send-connection-request), mentioning the post in the note. 3. Watch for acceptances with the [`network`](/docs/signal-network) signal. Invitations still count against `stConnectionRequests`. A popular post can produce more engagers in a day than the account should invite, so queue them rather than inviting all at once. ## Profile Viewers > 🚧 **Coming soon.** Signals are not available yet. This page describes how they will work at launch – the `/signals` endpoints, `signal.*` events and the SDK, CLI and MCP signal commands do not work today. The `profileViewers` signal watches the account's "Who viewed your profile" list and reports every new viewer LinkedIn identifies. A profile view is often the first sign of interest – someone checked you out after your invitation, your comment or your post – and it is worth a follow-up while it is recent. ## Events | Event `type` | Webhook event | What happened | |---|---|---| | `profileViewed` | `signal.profileViewed` | A new identified viewer appeared in the list, or a person already reported viewed the profile again. | ## Creating The signal watches the account itself and takes no target. An account has at most one. ```json { "type": "profileViewers" } ``` | Checked | Each check spends | |---|---| | every 3 hours | nothing from account limits | A check reads the viewers list, the same as [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers). The baseline is the viewers already listed at the first check. > 💡 **How many viewers you get depends on LinkedIn, not on the signal.** On an account without LinkedIn Premium, LinkedIn shows only a few recent viewers, so the signal can report only those. With Premium, it reports every viewer LinkedIn lists. Only **identified** viewers produce events. Viewers who browse in semi-private mode appear in LinkedIn's list only as a description, such as "Someone at Example Company", with no profile to follow up with; read them with [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers) when you need them. Viewers in fully private mode are not shown by LinkedIn at all. ## Event fields ```json { "id": "5d6e7f8a-9b0c-4d1e-8f2a-3b4c5d6e7f8a", "signalId": "8f9a0b1c-2d3e-4f4a-9b5c-6d7e8f9a0b1c", "signalType": "profileViewers", "label": null, "type": "profileViewed", "detectedAt": "2026-10-12T13:00:00Z", "viewer": { "viewedAt": "2026-10-12T11:00:00Z", "viewedAgo": "2h", "name": "Nicolai Elmqvist", "publicUrl": "https://www.linkedin.com/in/elmqvist", "urn": "urn:li:member:123456789", "headline": "Turn live event audiences into video creators", "connectionDegree": 2, "avatarUrl": "https://media.licdn.com/dms/image/profile-photo.jpg" } } ``` - `viewer` – the viewer, with the same fields as an `identified` item of [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers#result-options). - `viewedAt` is an **estimate**: LinkedIn shows only a relative age such as `2h` or `3d`, so the time is as precise as that unit. The common fields – `id`, `signalId`, `signalType`, `label`, `type`, `detectedAt` – are described in [Receiving events](/docs/signal-events#polling). **Repeat views.** LinkedIn lists each viewer once, with their latest view. When a person already reported views the profile again, LinkedIn moves them up with a newer time, and the signal reports that as a new `profileViewed` event. Views by the same person within 24 hours are reported once. ## Following up with viewers 1. Create the `profileViewers` signal once per account. 2. For each `profileViewed` event, check `viewer.connectionDegree`: - `1` – already a connection: send a message with [`st.sendMessage`](/docs/action-st-send-message); - `2` or `3` – send an invitation with [`st.sendConnectionRequest`](/docs/action-st-send-connection-request). 3. Keep it human: someone who glanced at your profile did not ask to be contacted, so mention why you are writing and keep the volume within the account's daily limits. ## Webhooks Overview Receive workflow, account, and LinkedIn activity events in real time. Instead of [polling for a workflow result](/docs/executing-workflows), register a webhook once and Linked API delivers an event to your endpoint whenever a workflow changes state, a LinkedIn account changes status, a message is observed in a monitored inbox, or a connection changes in a monitored network. Once [signals](/docs/signals-overview) launch, the webhook will also receive an event whenever a signal detects something new. :::surface node In the Node SDK, `verifyWebhookSignature()` checks a delivery and `parseWebhookEvent()` turns it into a typed event – see [Receiving events](#receiving-events). The webhook itself is managed with `admin.webhooks` – see [Managing webhooks](/docs/admin-webhooks). ::: :::surface python In the Python SDK, `verify_webhook_signature()` checks a delivery and `parse_webhook_event()` turns it into a typed event – see [Receiving events](#receiving-events). The webhook itself is managed with `admin.webhooks` – see [Managing webhooks](/docs/admin-webhooks). ::: :::surface cli In the CLI you register and manage the webhook with `linkedin admin webhook` commands – see [Managing webhooks](/docs/admin-webhooks?surface=cli). Receiving deliveries is the job of your own endpoint. ::: ## How it works - You register a single endpoint URL that receives events. A client may hold one active webhook at a time. - Every event your webhook receives is delivered as an HTTP `POST` with a JSON body (the event envelope below). By default that is every event; you can [choose which events](/docs/webhook-event-types#event-selection) it receives. - Respond with any `2xx` status to acknowledge. A non-`2xx` response, or a timeout, is retried with exponential backoff for up to 8 attempts before the delivery is marked failed. - Two optional settings secure the delivery: [custom headers](/docs/webhook-custom-headers), so an endpoint that requires authentication lets us in, and a [signature](/docs/webhook-signature-verification), so you can prove a delivery came from Linked API. Both are off by default and neither changes anything until you turn it on. Set this up either way: in the [platform dashboard](https://app.linkedapi.io/webhook), or programmatically through the [Admin API](/docs/admin-webhooks). Both reach the same webhook, so you can register it in the dashboard and change it from code, or the other way round. :::surface node,python In the SDKs, the Admin API's webhook endpoints are the methods of `admin.webhooks` on the [admin client](/docs/admin-overview#authentication). Each call is shown on [Managing Webhooks](/docs/admin-webhooks). ::: :::surface cli In the CLI, they are the `linkedin admin webhook` commands. Each command is shown on [Managing Webhooks](/docs/admin-webhooks). ::: ## Event envelope Every delivery has the same shape: ```json { "id": "workflow.completed:wf-64835e7c-...", "type": "workflow.completed", "createdAt": "2026-06-25T12:00:00.000Z", "data": { "workflowId": "wf-64835e7c-...", "accountId": "f9b4346a-...", "status": "completed", "result": { } } } ``` - `id` – stable, unique event identifier. Use it to deduplicate: a retried delivery reuses the same `id`. - `type` – event type, one of the [event types](/docs/webhook-event-types). - `createdAt` – ISO 8601 timestamp of when the event was produced. - `data` – event-specific payload, described per event type on [Event Types](/docs/webhook-event-types). > Delivery order is best-effort and not guaranteed. Correlate events by `type`, `data.status`, and `createdAt` rather than by arrival order. ## Payload modes The payload mode controls how much data `workflow.completed` carries: - `fat` (default) – the full workflow result is inlined in `data.result`. - `thin` – `data.result` is omitted; fetch the result via the [workflow API](/docs/executing-workflows) using `data.workflowId`. You can switch the mode at any time when [managing the webhook](/docs/admin-webhooks). ## Receiving events Your endpoint should: 1. If signing is on, [verify the signature](/docs/webhook-signature-verification) against the **raw** body, before parsing it. 2. Read the JSON body and branch on `type`. 3. Respond `2xx` quickly to acknowledge. Do heavy work asynchronously – a slow endpoint will time out and be retried. 4. Deduplicate on the envelope `id`. Deliveries are at-least-once, so the same event can arrive more than once. :::surface node The Node SDK ships helpers for the first two steps: `verifyWebhookSignature` checks the [signature](/docs/webhook-signature-verification#verifying-a-delivery), and `parseWebhookEvent` turns the body into a typed event, so branching on `type` narrows `data`. Both take the raw body, so read it unparsed – in Express, with `express.raw()`: ::: :::surface python The Python SDK ships helpers for the first two steps: `verify_webhook_signature` checks the [signature](/docs/webhook-signature-verification#verifying-a-delivery), and `parse_webhook_event` turns the body into a typed event – branch on its class with `isinstance` to reach typed `data`. Both take the raw body, so read it unparsed – in FastAPI, with `await request.body()`: ::: :::surface http Without an SDK the endpoint is a few lines in any language. Here it is in Node.js with Express, reading the body unparsed with `express.raw()` and checking it with the reference `verifyWebhookSignature` from [Signature Verification](/docs/webhook-signature-verification#verifying-a-delivery): ::: :::surface cli The CLI configures webhooks but does not receive them: your endpoint is a small server in any language. Here it is in Node.js with Express, reading the body unparsed with `express.raw()` and checking it with the reference `verifyWebhookSignature` from [Signature Verification](/docs/webhook-signature-verification#verifying-a-delivery): ::: ```js tab=HTTP,CLI const express = require("express") // The reference implementation from Signature Verification, saved next to this file as verify-webhook-signature.js. const { verifyWebhookSignature } = require("./verify-webhook-signature") const app = express() app.use(express.raw({ type: "application/json" })) app.post("/hooks/linkedapi", (req, res) => { // Skip this check if signing is off for your webhook. const isAuthentic = verifyWebhookSignature({ rawBody: req.body, timestamp: req.headers["linked-api-signature-timestamp"], signature: req.headers["linked-api-signature"], secret: process.env.LINKED_API_WEBHOOK_SECRET, nowSeconds: Math.floor(Date.now() / 1000), }) if (!isAuthentic) { res.sendStatus(401) return } const event = JSON.parse(req.body.toString("utf8")) switch (event.type) { case "workflow.completed": console.log(`Workflow ${event.data.workflowId} finished: ${event.data.status}`) console.log("Result:", event.data.result) // present in fat mode only break case "account.reconnectionRequired": console.log(`Account ${event.data.accountId} needs reconnection.`) break case "inbox.messageReceived": // personHashedUrl is always present; personPublicUrl is null until the person is resolved. console.log(`New message from ${event.data.personPublicUrl ?? event.data.personHashedUrl}: ${event.data.text}`) break case "inbox.messageSent": console.log(`Outgoing message in thread ${event.data.threadId}`) break case "network.connectionAccepted": console.log(`${event.data.personUrl} accepted your connection request`) break case "network.connectionRequestReceived": console.log(`New connection request from ${event.data.personUrl}`) break case "webhook.test": console.log("Test event:", event.data.message) break } // Acknowledge fast. A non-2xx response makes Linked API retry with backoff. res.sendStatus(200) }) ``` ```typescript tab=Node import express from "express"; import { parseWebhookEvent, verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_TIMESTAMP_HEADER, } from "@linkedapi/node"; const app = express(); app.use(express.raw({ type: "application/json" })); app.post("/hooks/linkedapi", (req, res) => { // Skip this check if signing is off for your webhook. const isAuthentic = verifyWebhookSignature({ rawBody: req.body as Buffer, timestamp: req.headers[WEBHOOK_SIGNATURE_TIMESTAMP_HEADER], signature: req.headers[WEBHOOK_SIGNATURE_HEADER], secret: process.env.LINKED_API_WEBHOOK_SECRET, }); if (!isAuthentic) { res.sendStatus(401); return; } const event = parseWebhookEvent(req.body as Buffer); switch (event.type) { case "workflow.completed": console.log(`Workflow ${event.data.workflowId} finished: ${event.data.status}`); console.log("Result:", event.data.result); // present in fat mode only break; case "account.reconnectionRequired": console.log(`Account ${event.data.accountId} needs reconnection.`); break; case "inbox.messageReceived": // personHashedUrl is always present; personPublicUrl is null until the person is resolved. console.log(`New message from ${event.data.personPublicUrl ?? event.data.personHashedUrl}: ${event.data.text}`); break; case "inbox.messageSent": console.log(`Outgoing message in thread ${event.data.threadId}`); break; case "network.connectionAccepted": console.log(`${event.data.personUrl} accepted your connection request`); break; case "network.connectionRequestReceived": console.log(`New connection request from ${event.data.personUrl}`); break; case "webhook.test": console.log("Test event:", event.data.message); break; } // Acknowledge fast. A non-2xx response makes Linked API retry with backoff. res.sendStatus(200); }); ``` ```python tab=Python import os from fastapi import FastAPI, Request, Response from linkedapi import ( WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_TIMESTAMP_HEADER, AccountWebhookEvent, MessageWebhookEvent, NetworkWebhookEvent, UnknownWebhookEvent, WebhookTestEvent, WorkflowWebhookEvent, parse_webhook_event, verify_webhook_signature, ) app = FastAPI() @app.post("/hooks/linkedapi") async def receive(request: Request) -> Response: raw_body = await request.body() # Skip this check if signing is off for your webhook. is_authentic = verify_webhook_signature( raw_body=raw_body, timestamp=request.headers.get(WEBHOOK_SIGNATURE_TIMESTAMP_HEADER), signature=request.headers.get(WEBHOOK_SIGNATURE_HEADER), secret=os.environ.get("LINKED_API_WEBHOOK_SECRET"), ) if not is_authentic: return Response(status_code=401) event = parse_webhook_event(raw_body) if isinstance(event, WorkflowWebhookEvent): if event.type == "workflow.completed": print(f"Workflow {event.data.workflow_id} finished: {event.data.status}") print("Result:", event.data.result) # present in fat mode only elif isinstance(event, AccountWebhookEvent): if event.type == "account.reconnectionRequired": print(f"Account {event.data.account_id} needs reconnection.") elif isinstance(event, MessageWebhookEvent): if event.type == "inbox.messageReceived": # person_hashed_url is always present; person_public_url is None until the person is resolved. person = event.data.person_public_url or event.data.person_hashed_url print(f"New message from {person}: {event.data.text}") elif event.type == "inbox.messageSent": print(f"Outgoing message in thread {event.data.thread_id}") elif isinstance(event, NetworkWebhookEvent): if event.type == "network.connectionAccepted": print(f"{event.data.person_url} accepted your connection request") elif event.type == "network.connectionRequestReceived": print(f"New connection request from {event.data.person_url}") elif isinstance(event, WebhookTestEvent): print("Test event:", event.data.message) elif isinstance(event, UnknownWebhookEvent): # A type this SDK version doesn't know yet. Acknowledge it so it isn't retried. print(f"Ignoring event type {event.type}") # Acknowledge fast. A non-2xx response makes Linked API retry with backoff. return Response(status_code=200) ``` :::surface node `parseWebhookEvent` throws an `Error` when the body is not valid JSON or lacks the envelope `id` or `type`. It checks only that `type` is a string, so an event type added after your SDK version reaches your code as it is. It returns `TWebhookEvent`, a union of one type per area – see [Event Types](/docs/webhook-event-types). ::: :::surface python `parse_webhook_event` raises `ValueError` when the body is not valid JSON or lacks the envelope `id` or `type`. Since `linkedapi` 1.4.7, an event type the installed version doesn't know yet no longer raises: it is returned as `UnknownWebhookEvent`, with its payload as a plain dict in `data`. It returns `WebhookEvent`, a union of one model per area plus `UnknownWebhookEvent`, with snake_case `data` fields – see [Event Types](/docs/webhook-event-types). Every event's `type` is an open string, so branch with `isinstance` on the event classes, as above, rather than on `type` alone. ::: ## Webhook Event Types Every event arrives in the same [envelope](/docs/webhooks#event-envelope). This page lists the event types by area and explains how to choose which of them your webhook receives. :::surface node,python In the SDKs, the [parse helper](/docs/webhooks#receiving-events) returns one type per area on this page. In Node the union is discriminated on `type`, so checking `type` narrows `data`; in Python, branch on the event class with `isinstance`, since every event's `type` is an open string: | Events | Node | Python | | --- | --- | --- | | `workflow.*` | `TWorkflowWebhookEvent` | `WorkflowWebhookEvent` | | `account.*` | `TAccountWebhookEvent` | `AccountWebhookEvent` | | `inbox.*` | `TInboxMessageWebhookEvent` | `MessageWebhookEvent` | | `network.*` | `TNetworkWebhookEvent` | `NetworkWebhookEvent` | | `webhook.test` | `TWebhookTestEvent` | `WebhookTestEvent` | Their union is `TWebhookEvent` in Node and `WebhookEvent` in Python; in Python it also includes `UnknownWebhookEvent`, returned for an event type the installed version doesn't know yet. Signal events are not part of the union yet. In Python, `data` fields are snake_case, such as `workflow_id` and `person_hashed_url`, and a field that is absent is `None`. ::: :::surface cli In the CLI, `linkedin admin webhook event-types` lists every event type you can choose, and `linkedin admin webhook set-events ` chooses which of them the webhook receives – a comma-separated list such as `"workflow.completed,inbox.*"`, or `--all`. See [Managing webhooks](/docs/admin-webhooks?surface=cli#webhook-setevents). ::: ## Workflow events `data` carries `workflowId`, `accountId`, and `status`. All events for one run share the same `workflowId`, so you can correlate them. - `workflow.created` – your request was accepted and queued. `status` is `pending`. - `workflow.started` – the workflow actually started running. `status` is `running`. - `workflow.completed` – the workflow reached a terminal state. `status` is `completed` or `failed`. `data.result` is included only on `workflow.completed`, and only in `fat` payload mode. Cancelled workflows do not emit a webhook. ## Account events `data` carries `accountId` and `status`, emitted when a connected LinkedIn account changes status. - `account.reconnectionRequired` – the account needs the user to reconnect. `status` is `reconnection_required`. - `account.active` – the account (re)connected and is operational. `status` is `active`. - `account.frozen` – the account was frozen, for example due to an unpaid subscription. `status` is `frozen`. - `account.deleted` – the account was deleted. `status` is `deleted`. ## Inbox events The `inbox.*` namespace covers messages observed in a connected account's inbox, as opposed to the state of your own Linked API entities. These events fire for accounts that have [inbox monitoring](/docs/monitoring-inbox) enabled via `st.syncInbox` / `nv.syncInbox`, one event per inbox message. History loaded with [`st.syncConversation`](/docs/action-st-sync-conversation) / [`nv.syncConversation`](/docs/action-nv-sync-conversation) does not produce events. - `inbox.messageReceived` – an incoming message was observed in the inbox. - `inbox.messageSent` – an outgoing message from the account was observed. This covers messages sent through the LinkedIn UI **and** messages sent through the Linked API, so an API-sent message emits `inbox.messageSent` in addition to its `workflow.completed`. `data` carries the message with the following fields: - `accountId` – the connected LinkedIn account the message belongs to. - `type` – inbox type the message belongs to (`st` or `nv`). - `threadId` – identifier of the conversation thread. Pass it to [`st.sendMessage`](/docs/action-st-send-message) / [`nv.sendMessage`](/docs/action-nv-send-message) to reply. - `personUrn` – [URN](/docs/data-conventions#linkedin-urns) of the other participant, or `null` if LinkedIn does not expose it. - `personHashedUrl` – [hashed](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the other participant. This is the form the inbox exposes; every action that takes a person URL accepts it. - `personPublicUrl` – [public](/faq/what-are-hashed-url-and-public-url) LinkedIn URL of the other participant, or `null` if it has not been resolved. It can be `null` on **any** message, including the first one in a thread, so never treat it as guaranteed. See [inbox polling](/docs/monitoring-inbox) for how it is resolved. - `personUrl` – **deprecated**, replaced by `personHashedUrl`, which carries the same value. Still sent so existing subscribers keep working; do not use it in new code. - `messageId` – unique identifier of the message, matching the `id` returned by [inbox polling](/docs/monitoring-inbox). - `sender` – `us` for `inbox.messageSent`, `them` for `inbox.messageReceived`. - `text` – message text. - `time` – ISO 8601 timestamp of the message. > Because `inbox.messageSent` also fires for outbound messages your own automations send, filter by `data.sender` and `data.type` rather than assuming every event is a fresh inbound reply. ## Network events The `network.*` namespace covers changes to a connected account's connection graph. These events fire for accounts that have [network monitoring](/docs/monitoring-network) enabled via `st.syncNetwork`, one event per connection change. - `network.connectionRequestReceived` – someone sent the account a connection request. A new incoming pending invitation was observed. - `network.connectionAccepted` – a new connection that matches a request the account sent. The other person accepted an outgoing invitation. - `network.connectionAdded` – a new connection that is not attributable to a request the account sent, for example an incoming request the account accepted, or a connection formed outside the API. `data` carries the connection event with the following fields: - `accountId` – the connected LinkedIn account the event belongs to. - `personUrn` – [URN](/docs/data-conventions#linkedin-urns) of the other person, or `null` if LinkedIn did not expose an identifier on the row that produced the event. - `personUrl` – LinkedIn URL of the other person. - `detectedAt` – ISO 8601 timestamp of when Linked API observed the event, matching the `detectedAt` returned by [network polling](/docs/monitoring-network). ## Signal events > 🚧 **Coming soon.** Signals are not available yet. These events will be delivered once they launch. The `signal.*` namespace covers what your [signals](/docs/signals-overview) detect, one event per new item. `data` carries the event exactly as [`/signals/poll`](/docs/signal-events#polling) returns it, plus `accountId`. - `signal.postPublished` – a watched person or company published a post. See [Posts by person or company](/docs/signal-posts). - `signal.postFound` – a new post matched a keyword signal. See [Posts by keyword](/docs/signal-keyword-posts). - `signal.commentAdded` – a new comment under a watched post. See [Comments and reactions](/docs/signal-post-engagement). - `signal.reactionAdded` – a new reaction on a watched post. See [Comments and reactions](/docs/signal-post-engagement). - `signal.profileViewed` – a new identified viewer of the account's profile. See [Profile viewers](/docs/signal-profile-viewers). - `signal.connectionAccepted`, `signal.connectionAdded`, `signal.connectionRequestReceived` – a change in the account's network found by the [`network`](/docs/signal-network) signal. The `network.*` events above come from [network monitoring](/docs/monitoring-network); an account with both receives each change once from each. ## Event selection By default a webhook receives every event on this page. You can narrow it to the events you handle. The choice is optional and per webhook, and a webhook that never makes one keeps receiving every event, so existing webhooks are unchanged. In the [dashboard](https://app.linkedapi.io/webhook), open **Advanced → Events** on your endpoint, or in the dialog that creates it, and switch off **All events** to choose. The endpoint's **Events** row shows the current selection. Programmatically, pass `events` to [`webhook.set`](/docs/admin-webhooks#webhook-set), change it later with [`webhook.setEvents`](/docs/admin-webhooks#webhook-setevents), and list the types you can choose with [`webhook.eventTypes`](/docs/admin-webhooks#webhook-eventtypes). A selection is either `null`, meaning every event, or a list whose entries are each: - an exact event type, such as `workflow.completed`; - a namespace wildcard `.*`, such as `inbox.*`. The namespace is the part before the first `.`: `workflow`, `account`, `inbox`, `network` or, once signals launch, `signal`. **A wildcard follows the family; an exact type pins it.** `inbox.*` matches every inbox event, including event types added later. `inbox.messageReceived` matches only itself and never widens. In the dashboard, choosing a whole group stores its wildcard, while ticking each of its types one by one stores those exact types. A selection is checked when you save it. An unknown type or namespace, `webhook.test`, `webhook.*`, a bare `*`, any other pattern, or an empty list is rejected with `invalidRequestPayload` naming the entry, so a typo never becomes a webhook that silently receives nothing. A selection holds up to 50 entries of up to 64 characters each. It is stored de-duplicated and sorted, and reading the webhook returns it as `events`. Delivered whatever you select: - `webhook.test`, so [`webhook.sendTest`](/docs/admin-webhooks#webhook-sendtest) works on any webhook; - a [replayed delivery](/docs/admin-webhooks#webhook-replaydelivery), even of a type you have deselected since. **An event you do not select is never recorded.** Filtering happens when the event is produced, so a deselected event creates no delivery at all. It does not appear in [recent deliveries](/docs/admin-webhooks#webhook-deliveries), cannot be replayed, and does not come back if you select its type later. **A change applies from the moment you save it.** Events produced after that follow the new selection. Deliveries that were already queued, pending or being retried, are still delivered. ## Signature Verification Turn signing on and every delivery to your endpoint carries an HMAC-SHA256 signature you can verify. It is off by default, per webhook, and enabling it changes nothing for anyone else. :::surface node In the Node SDK, `verifyWebhookSignature()` does this check for you – see [Verifying a delivery](#verifying-a-delivery). ::: :::surface python In the Python SDK, `verify_webhook_signature()` does this check for you – see [Verifying a delivery](#verifying-a-delivery). ::: :::surface cli In the CLI, `linkedin admin webhook set-signing --on` turns signing on, and `reveal-secret --print-secret` writes the secret to stdout – capture it with `$(...)`, as in the example below, so it goes into your endpoint's environment instead of onto the screen. ::: In the [dashboard](https://app.linkedapi.io/webhook), open **Advanced** on your endpoint and turn on **Sign deliveries**. The signing secret appears there, with buttons to copy and to rotate it; after a reload it is masked behind **Reveal**, so it is never left on screen. Programmatically, enable it with [`webhook.setSigning`](/docs/admin-webhooks#webhook-setsigning), which returns the secret. The secret already exists from the moment you register the webhook, so you can also retrieve it later with [`webhook.revealSecret`](/docs/admin-webhooks#webhook-revealsecret), and replace a leaked one with [`webhook.rotateSecret`](/docs/admin-webhooks#webhook-rotatesecret). :::surface cli The CLI keeps the signing secret out of your terminal history and logs: - `set --signing`, `set-signing --on` and `rotate-secret` without a flag show the secret as `[REDACTED]`, with `--json` and `--fields` too, and say on stderr how to read it. - `reveal-secret` needs one of two flags. `--print-secret` writes only the secret and a newline to stdout, so it fits `$(...)`. `--secret-file ` creates a new file with mode `0600`, refuses a path that already exists, and prints the path rather than the secret. - `rotate-secret` warns on stderr that the old secret stops working at once, and accepts the same two flags. With `--secret-file`, the file is created before the secret rotates, so a path that already exists stops the command before anything changes. Without a flag, read the new secret with `reveal-secret`. ```bash # Hand the secret to your endpoint's environment without printing it export LINKED_API_WEBHOOK_SECRET="$(linkedin admin webhook reveal-secret whs-... --print-secret)" ``` ::: Signed deliveries carry two extra headers: - `linked-api-signature-timestamp` – unix seconds when the delivery was signed. - `linked-api-signature` – `HMAC-SHA256(secret, ".")`, hex encoded. ## How the signature is computed Four details decide whether your implementation agrees with ours. Three are conventional; the first is not, and it is the one that silently breaks a reasonable implementation. 1. **The secret is used as a UTF-8 string – do not hex-decode it.** The secret is 64 hexadecimal characters, and the natural assumption is that it encodes 32 key bytes. It does not: those 64 characters **are** the key, passed to the hash as text. Decoding them first produces a different signature, and every delivery then fails verification with no clue as to why. 2. **The timestamp** is the decimal unix time in **seconds**, exactly as it appears in the header. 3. **The separator** between timestamp and body is a single period: `"."`. 4. **The body is the raw bytes we sent.** Verify before parsing – a body that has been parsed and re-serialized will not verify, because key order and whitespace change the bytes. ## Test vectors Check your implementation against these before trusting it in production. Both come from the signer Linked API actually runs. | | | | --- | --- | | secret | `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f` | | timestamp | `1700000000` | | raw body | `{"id":"event-1","type":"webhook.test","data":{"message":"Hello"}}` | | **expected signature** | `8f93c19766fe42f27da56fcb5ae11a909c406683ccc0766bd29edc52c71fd209` | If you get `cf8a7cbd4da0130448492ced3d1aaffd608738b7fff2623e27eaecd4dd54a236` instead, you hex-decoded the secret – see point 1 above. That is the single most likely mistake, and this is how you recognise it in one comparison. A second vector, because the first is pure ASCII and would not catch an implementation that hashes characters instead of bytes. Same secret and timestamp: | | | | --- | --- | | raw body | `{"id":"event-2","type":"webhook.test","data":{"message":"Zoë ♥ 日本"}}` | | **expected signature** | `87f5bc51d542ce4d8b61350b35807420dce7b3473bd7ecec87211b544c299c69` | That body is 68 characters and **75 UTF-8 bytes**. Hash the bytes. ## Verifying a delivery :::surface http,cli Without an SDK, copy `verifyWebhookSignature` from the reference implementation below – or port it to your language – and call it with the raw body and the two headers: ::: :::surface node,python The SDKs ship this check, with the two header names exported as `WEBHOOK_SIGNATURE_HEADER` and `WEBHOOK_SIGNATURE_TIMESTAMP_HEADER`: ::: ```js tab=HTTP,CLI const isAuthentic = verifyWebhookSignature({ rawBody, // the raw body: string, Buffer or TypedArray timestamp: request.headers["linked-api-signature-timestamp"], signature: request.headers["linked-api-signature"], secret: process.env.LINKED_API_WEBHOOK_SECRET, nowSeconds: Math.floor(Date.now() / 1000), }) ``` ```typescript tab=Node import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_TIMESTAMP_HEADER, } from "@linkedapi/node"; const isAuthentic = verifyWebhookSignature({ rawBody, // the raw body: string, Buffer, Uint8Array or ArrayBuffer timestamp: request.headers[WEBHOOK_SIGNATURE_TIMESTAMP_HEADER], signature: request.headers[WEBHOOK_SIGNATURE_HEADER], secret: process.env.LINKED_API_WEBHOOK_SECRET, }); ``` ```python tab=Python import os from linkedapi import ( WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_TIMESTAMP_HEADER, verify_webhook_signature, ) is_authentic = verify_webhook_signature( raw_body=raw_body, # the raw body: str, bytes, bytearray or memoryview timestamp=request.headers.get(WEBHOOK_SIGNATURE_TIMESTAMP_HEADER), signature=request.headers.get(WEBHOOK_SIGNATURE_HEADER), secret=os.environ.get("LINKED_API_WEBHOOK_SECRET"), ) ``` :::surface node `verifyWebhookSignature` returns `true` or `false` and never throws: a missing or malformed header, an empty secret or a body of the wrong type is simply `false`. It uses the secret as the literal text you received, never hex-decoded, and accepts a timestamp up to 300 seconds away from your clock in either direction. Change that window with `toleranceSeconds`, and pass `nowSeconds` to check a stored delivery against the time it arrived. A complete endpoint that verifies and then parses a delivery is in [Receiving events](/docs/webhooks#receiving-events); the code below is the same check, for any other language. ::: :::surface python `verify_webhook_signature` takes keyword arguments only, returns `True` or `False` and never raises: a missing or malformed header, an empty secret or a body of the wrong type is simply `False`. It uses the secret as the literal text you received, never hex-decoded, and accepts a timestamp up to 300 seconds away from your clock in either direction. Change that window with `tolerance_seconds`, and pass `now_seconds` to check a stored delivery against the time it arrived. A complete endpoint that verifies and then parses a delivery is in [Receiving events](/docs/webhooks#receiving-events); the code below is the same check, for any other language. ::: ```js const { createHmac, timingSafeEqual } = require("node:crypto") const TOLERANCE_SECONDS = 300 function verifyWebhookSignature({ rawBody, timestamp, signature, secret, nowSeconds }) { const sentAt = Number(timestamp) if (!Number.isInteger(sentAt)) { return false } // Number.isFinite also closes the case where nowSeconds was not passed: NaN > TOLERANCE // is false, so without this check a missing clock would accept any timestamp. if (!Number.isFinite(nowSeconds) || Math.abs(nowSeconds - sentAt) > TOLERANCE_SECONDS) { return false } // Validate shape and length BEFORE comparing: timingSafeEqual throws on a length // mismatch, so a malformed signature must be rejected here, not by an exception. if (typeof signature !== "string" || !/^[0-9a-f]{64}$/.test(signature)) { return false } // The same rule for the body, and it is not hypothetical: the sender picks its // Content-Type, so a body parser can hand you {} or undefined. update() would throw // on those, and an exception here is a crash rather than a rejection. // ArrayBuffer.isView covers Buffer, every TypedArray and DataView. if (typeof rawBody !== "string" && !ArrayBuffer.isView(rawBody)) { return false } // Hashed in two steps so rawBody is never stringified: a Uint8Array would become // "123,34,105,..." and an ArrayBuffer "[object ArrayBuffer]". update() takes a string, // a Buffer, a TypedArray or a DataView – wrap a bare ArrayBuffer in new Uint8Array(...). const expected = createHmac("sha256", secret).update(`${sentAt}.`).update(rawBody).digest("hex") return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex")) } module.exports = { verifyWebhookSignature } // At the call site, nowSeconds is your own clock. Pass it in rather than reading the // clock inside, so the function stays testable against the fixed vectors above: // // verifyWebhookSignature({ // rawBody, // raw body: string, Buffer or TypedArray // timestamp: request.headers["linked-api-signature-timestamp"], // signature: request.headers["linked-api-signature"], // secret: process.env.LINKED_API_WEBHOOK_SECRET, // nowSeconds: Math.floor(Date.now() / 1000), // }) ``` The Node and Python SDKs ship this check as `verifyWebhookSignature` / `verify_webhook_signature`; this is the reference implementation for any other language. Two things in it are not decoration: the comparison is constant-time, and the signature's shape is validated **before** that comparison. `TOLERANCE_SECONDS` is yours to choose; 300 is a reasonable default. Reject a timestamp outside the window in **both** directions – too old replays a captured delivery, too far in the future means a clock you cannot trust. > Keep your clock in sync, and do not tighten the window to save effort. Each retry is re-signed with a fresh timestamp, but a timestamp is only ever stale from your side, so a skewed clock rejects every attempt alike: all 8 are spent in about three minutes and the delivery ends as failed. **Rotation takes effect immediately and there is no overlap period.** The old secret stops signing the moment the new one is stored, so rotate when you are ready to deploy the new value, not before. ## Custom Headers If your endpoint requires authentication, put its credential here and Linked API sends it with every delivery – retries, test events and replays included. Up to 10 headers per webhook. Add them in the [dashboard](https://app.linkedapi.io/webhook) under **Advanced**, or through [`webhook.setHeader`](/docs/admin-webhooks#webhook-setheader). In the dashboard a saved header shows its name with the value masked; **Replace value** opens an empty field, because the value cannot be read back. :::surface http Each call of [`webhook.setHeader`](/docs/admin-webhooks#webhook-setheader) adds or replaces one header; [`webhook.deleteHeader`](/docs/admin-webhooks#webhook-deleteheader) removes one and [`webhook.setHeaders`](/docs/admin-webhooks#webhook-setheaders) replaces or clears them all: ::: :::surface node In the Node SDK the header endpoints are methods of `admin.webhooks`: `setHeader()` adds or replaces one header, `deleteHeader()` removes one and `setHeaders()` replaces or clears them all: ::: :::surface python In the Python SDK the header endpoints are methods of `admin.webhooks`: `set_header()` adds or replaces one header, `delete_header()` removes one and `set_headers()` replaces or clears them all: ::: :::surface cli The CLI keeps header values out of your terminal history and logs – they never go on the command line: - `set-header` reads the value from stdin with `--value-stdin`, removing one trailing newline, or from the environment variable named by `--value-env`; exactly one is required. - `set --headers-stdin` and `set-headers --stdin` read a JSON object of names and values from stdin. - An empty value is refused with exit code `5` before anything is sent, whether it comes from stdin, the environment variable or the JSON object, and so is an environment variable that is not set. Remove a header with `delete-header`, or clear them all with `set-headers --clear` or `{}`. ::: ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.setHeader \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-...", "name": "Authorization", "value": "Bearer your_endpoint_token" }' ``` ```typescript tab=Node await admin.webhooks.setHeader({ id: "whs-...", name: "Authorization", value: "Bearer your_endpoint_token", }); ``` ```python tab=Python from linkedapi import SetWebhookHeaderParams admin.webhooks.set_header( SetWebhookHeaderParams( id="whs-...", name="Authorization", value="Bearer your_endpoint_token", ) ) ``` ```bash tab=CLI cat endpoint-token.txt | linkedin admin webhook set-header whs-... Authorization --value-stdin linkedin admin webhook set-header whs-... Authorization --value-env ENDPOINT_TOKEN ``` A webhook with no headers configured sends exactly what it always sent: `content-type: application/json`, plus the signature headers if signing is on. **Values are write-only.** Reading the webhook returns `headerNames` – the names you configured – and never the values. That is also why headers are set one at a time: replacing the whole map would force you to re-enter every credential you were not changing. Limits, all enforced when you save: | Rule | Value | | --- | --- | | headers per webhook | 10 | | value length | 1024 characters | | all headers, serialized | 4096 bytes | | name | an RFC 7230 token | | value | tab and printable ASCII only | Reserved names, matched case-insensitively and rejected: `content-type`, `content-length`, `host`, `connection`, `transfer-encoding`, `expect`, `upgrade`, and any name beginning `linked-api-`. Also reserved: `get`, `post`, `put`, `patch`, `delete`, `head` and `common` – our HTTP client treats those as its own configuration keys rather than as headers, so one of them would be sent under the wrong name or dropped. Two names differing only in case are rejected for the same reason. > Header values are stored unencrypted, and our API responses never return them. One exception is worth knowing before you choose what to put here: if a request body is **invalid JSON**, the parser's error quotes about ten characters around the fault, and that text is both returned in the `400` and written to our logs. The usual way to hit it is a quoting slip in `curl` that sends the value unquoted. So prefer a credential scoped to webhook ingestion, and rotate it on your side as you would any shared secret. We never follow redirects, so your credential cannot be forwarded to another host by a `3xx` from your endpoint. ## Managing Webhooks Register and manage the webhook that receives [workflow and account events](/docs/webhook-event-types). For general Admin API information, see the [Admin overview](/docs/admin-overview). A client may hold one active webhook at a time. Everything on this page can also be done in the [platform dashboard](https://app.linkedapi.io/webhook) – registering the endpoint, switching payload mode, choosing events, signing, custom headers, and inspecting recent deliveries. Use these endpoints when you want it in code; the two reach the same webhook. :::surface node In the Node SDK these endpoints are methods of `admin.webhooks` on the [admin client](/docs/admin-overview#authentication), named after the endpoint: `webhook.setPayloadMode` is `admin.webhooks.setPayloadMode()`. Each method returns the content of `result` unwrapped: the webhook (`TWebhookSubscription`) from `set`, the other `set…` methods, `deleteHeader`, `revealSecret` and `rotateSecret`; an array from `get`, `eventTypes` and `deliveries` (`TWebhookDelivery`); and nothing from `delete`, `replayDelivery` and `sendTest`. A failed call throws `LinkedApiError` – see [error handling](/docs/admin-overview#authentication). ::: :::surface python In the Python SDK these endpoints are methods of `admin.webhooks` on the [admin client](/docs/admin-overview#authentication), in snake_case: `webhook.setPayloadMode` is `admin.webhooks.set_payload_mode()`. A method that takes parameters takes them as a params model exported from `linkedapi`, such as `SetWebhookPayloadModeParams`. Each method returns the content of `result` unwrapped: a `WebhookSubscription` from `set`, the other `set_…` methods, `delete_header`, `reveal_secret` and `rotate_secret`; a list from `get`, `event_types` and `deliveries` (`WebhookDelivery`); and `None` from `delete`, `replay_delivery` and `send_test`. Fields are snake_case, such as `payload_mode` and `header_names`. A failed call raises `LinkedApiError` – see [error handling](/docs/admin-overview#authentication). ::: :::surface cli In the CLI these endpoints are the `linkedin admin webhook` commands, in kebab-case: `webhook.setPayloadMode` is `set-payload-mode`, and `webhook.replayDelivery` is `replay`. The webhook `id` and the other required values are positional arguments. The signing secret and header values never appear on screen or on the command line unless you ask – see [secrets in the CLI](/docs/webhook-signature-verification) and [header values in the CLI](/docs/webhook-custom-headers). ::: ## webhook.set Register the webhook. Enforces a maximum of one active webhook per client. There is no endpoint that changes the destination `url` – for that, delete the webhook and set a new one. Payload mode, the event selection, signing and custom headers are all changeable in place. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.set \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "url": "https://example.com/hooks/linkedapi", "payloadMode": "fat" }' ``` ```typescript tab=Node const webhook = await admin.webhooks.set({ url: "https://example.com/hooks/linkedapi", payloadMode: "fat", }); ``` ```python tab=Python from linkedapi import SetWebhookParams webhook = admin.webhooks.set( SetWebhookParams(url="https://example.com/hooks/linkedapi", payload_mode="fat") ) ``` ```bash tab=CLI linkedin admin webhook set https://example.com/hooks/linkedapi --payload-mode fat --json ``` :::surface cli In the CLI this is `linkedin admin webhook set`: ```bash linkedin admin webhook set [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `--payload-mode` | string | `fat` (default) or `thin` | | `--signing` | boolean | Sign every delivery. The returned secret is shown as `[REDACTED]` | | `--events` | string | Comma-separated event types and `.*` wildcards. Omitted means every event | | `--headers-stdin` | boolean | Read the custom headers from stdin, as a JSON object of names and values | ```bash cat headers.json | linkedin admin webhook set https://example.com/hooks \ --signing --events "workflow.*,inbox.messageReceived" --headers-stdin ``` ::: **Body:** - `url` – HTTPS endpoint that will receive deliveries. Required. - `payloadMode` – `fat` (default) or `thin`. See [payload modes](/docs/webhooks#payload-modes). Optional. - `signingEnabled` – `true` to sign every delivery. Defaults to `false`. When `true`, the response includes `secret`. See [signature verification](/docs/webhook-signature-verification). Optional. - `headers` – custom headers sent with every delivery, as a name-to-value object. See [custom delivery headers](/docs/webhook-custom-headers). Optional. - `events` – the events to deliver: exact event types and `.*` wildcards, such as `["workflow.completed", "inbox.*"]`. Omitted or `null` means every event. See [event selection](/docs/webhook-event-types#event-selection). Optional. **Response:** ```json { "success": true, "result": { "webhook": { "id": "whs-...", "url": "https://example.com/hooks/linkedapi", "payloadMode": "fat", "signingEnabled": false, "headerNames": [], "events": null, "isActive": true, "createdAt": "2026-06-25T12:00:00.000Z" } } } ``` - `id` – webhook identifier, used by the other endpoints. - `url` – the registered destination. - `payloadMode` – current payload mode. - `signingEnabled` – whether deliveries are signed. - `headerNames` – names of the configured [custom headers](/docs/webhook-custom-headers), never their values. - `events` – the stored [event selection](/docs/webhook-event-types#event-selection), de-duplicated and sorted, or `null` for every event. - `isActive` – whether the webhook is active. - `createdAt` – ISO 8601 timestamp. - `secret` – the signing secret. Present **only** on a `webhook.set` that enabled signing, on `webhook.setSigning` when enabling, on `webhook.revealSecret` and on `webhook.rotateSecret`. Every other response omits it. > A `url` that is a bare IP address in a private or internal range is rejected. Deliveries are only ever sent to public addresses. ## webhook.get List the active webhook for this client. Returns an array with at most one entry. Never returns the signing secret – use `webhook.revealSecret` for that. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.get \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const webhooks = await admin.webhooks.get(); ``` ```python tab=Python webhooks = admin.webhooks.get() ``` ```bash tab=CLI linkedin admin webhook get --json ``` **Response:** ```json { "success": true, "result": { "webhooks": [ { "id": "whs-...", "url": "https://example.com/hooks/linkedapi", "payloadMode": "fat", "signingEnabled": false, "headerNames": [], "events": null, "isActive": true, "createdAt": "2026-06-25T12:00:00.000Z" } ] } } ``` ## webhook.setPayloadMode Switch the [payload mode](/docs/webhooks#payload-modes) between `fat` and `thin`. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.setPayloadMode \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-...", "payloadMode": "thin" }' ``` ```typescript tab=Node await admin.webhooks.setPayloadMode({ id: "whs-...", payloadMode: "thin" }); ``` ```python tab=Python from linkedapi import SetWebhookPayloadModeParams admin.webhooks.set_payload_mode( SetWebhookPayloadModeParams(id="whs-...", payload_mode="thin") ) ``` ```bash tab=CLI linkedin admin webhook set-payload-mode whs-... thin --json ``` **Body:** - `id` – webhook identifier. Required. - `payloadMode` – `fat` or `thin`. Required. ## webhook.setEvents Replace the [event selection](/docs/webhook-event-types#event-selection) in place. `null` goes back to every event. Saving the selection the webhook already has, even reordered or with duplicates, changes nothing. Returns the updated webhook. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.setEvents \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-...", "events": ["workflow.completed", "inbox.*"] }' ``` ```typescript tab=Node const webhook = await admin.webhooks.setEvents({ id: "whs-...", events: ["workflow.completed", "inbox.*"], }); ``` ```python tab=Python from linkedapi import SetWebhookEventsParams webhook = admin.webhooks.set_events( SetWebhookEventsParams(id="whs-...", events=["workflow.completed", "inbox.*"]) ) ``` ```bash tab=CLI linkedin admin webhook set-events whs-... "workflow.completed,inbox.*" --json ``` :::surface node `events` is typed as `Array | null`, so TypeScript flags an unknown event type or wildcard at compile time. An entry that Linked API rejects throws `LinkedApiError` with type `invalidRequestPayload`. The same applies to `events` in `set`. ::: :::surface python The params model checks each entry against the `WebhookEventSelector` values as you build it and raises `pydantic.ValidationError` for an unknown one, before any request is sent. An entry that Linked API rejects raises `LinkedApiError` with type `invalidRequestPayload`. The same applies to `events` in `SetWebhookParams`. ::: :::surface cli In the CLI this is `linkedin admin webhook set-events`. It takes the selection as one comma-separated argument, or `--all` for every event (`null`) – exactly one of the two: ```bash linkedin admin webhook set-events linkedin admin webhook set-events --all ``` An empty entry, such as one left by a trailing comma, is refused with exit code `5` before anything is sent. `set --events` takes the same list. ::: **Body:** - `id` – webhook identifier. Required. - `events` – exact event types and `.*` wildcards, or `null` for every event. Required, so a request without it never clears a selection by accident. An invalid entry is rejected with `invalidRequestPayload` naming it, and the stored selection stays as it was. ## webhook.eventTypes List the event types a selection can name, in the order Linked API defines them. `webhook.test` is not among them, because it is always delivered. It works before you register a webhook, so you can build a selection first. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.eventTypes \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const eventTypes = await admin.webhooks.eventTypes(); ``` ```python tab=Python event_types = admin.webhooks.event_types() ``` ```bash tab=CLI linkedin admin webhook event-types --json ``` **Response:** ```json { "success": true, "result": { "eventTypes": [ "workflow.created", "workflow.started", "workflow.completed", "account.active", "account.reconnectionRequired", "account.frozen", "account.deleted", "inbox.messageReceived", "inbox.messageSent", "network.connectionAccepted", "network.connectionAdded", "network.connectionRequestReceived" ] } } ``` The wildcards are not listed: each namespace in this list, the part before the first `.`, can be selected as `.*`. ## webhook.setSigning Turn [signature verification](/docs/webhook-signature-verification) on or off for this webhook. Returns the signing secret when enabling, so you can configure your verifier in the same step. No other endpoint changes the signing state. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.setSigning \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-...", "signingEnabled": true }' ``` ```typescript tab=Node const { secret } = await admin.webhooks.setSigning({ id: "whs-...", signingEnabled: true }); ``` ```python tab=Python from linkedapi import SetWebhookSigningParams secret = admin.webhooks.set_signing( SetWebhookSigningParams(id="whs-...", signing_enabled=True) ).secret ``` ```bash tab=CLI linkedin admin webhook set-signing whs-... --on --json ``` :::surface cli In the CLI this is `linkedin admin webhook set-signing ` with `--on` or `--off`, exactly one of the two. When enabling, the returned secret is shown as `[REDACTED]`; read it with [`reveal-secret`](#webhook-revealsecret). ::: **Body:** - `id` – webhook identifier. Required. - `signingEnabled` – `true` to sign deliveries, `false` to stop. Required. ## webhook.setHeader Add or replace one [custom header](/docs/webhook-custom-headers), leaving the others untouched. This is the endpoint to use when editing an existing configuration, because header values cannot be read back. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.setHeader \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-...", "name": "Authorization", "value": "Bearer your_endpoint_token" }' ``` ```typescript tab=Node await admin.webhooks.setHeader({ id: "whs-...", name: "Authorization", value: "Bearer your_endpoint_token", }); ``` ```python tab=Python from linkedapi import SetWebhookHeaderParams admin.webhooks.set_header( SetWebhookHeaderParams( id="whs-...", name="Authorization", value="Bearer your_endpoint_token", ) ) ``` ```bash tab=CLI printf 'Bearer %s' "$ENDPOINT_TOKEN" \ | linkedin admin webhook set-header whs-... Authorization --value-stdin --json ``` :::surface cli In the CLI this is `linkedin admin webhook set-header`. The value is never an argument: it comes from stdin or from an environment variable, exactly one of the two. ```bash linkedin admin webhook set-header --value-stdin linkedin admin webhook set-header --value-env ``` | Flag | Type | Description | | --- | --- | --- | | `--value-stdin` | boolean | Read the value from stdin. One trailing newline is removed | | `--value-env` | string | Name of the environment variable that holds the value | An empty value, or an environment variable that is not set, is refused with exit code `5` before anything is sent. See [header values in the CLI](/docs/webhook-custom-headers). ::: **Body:** - `id` – webhook identifier. Required. - `name` – header name. An existing header with the same name, ignoring case, is replaced. Required. - `value` – header value. Required. ## webhook.deleteHeader Remove one custom header by name. The name is matched ignoring case. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.deleteHeader \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-...", "name": "Authorization" }' ``` ```typescript tab=Node await admin.webhooks.deleteHeader({ id: "whs-...", name: "Authorization" }); ``` ```python tab=Python from linkedapi import DeleteWebhookHeaderParams admin.webhooks.delete_header(DeleteWebhookHeaderParams(id="whs-...", name="Authorization")) ``` ```bash tab=CLI linkedin admin webhook delete-header whs-... Authorization --json ``` **Body:** - `id` – webhook identifier. Required. - `name` – header name to remove. Required. ## webhook.setHeaders Replace **all** custom headers at once. `{}` or `null` removes every header. Use this only when you hold every value – it is the programmatic path; for editing one header, use `webhook.setHeader`. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.setHeaders \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-...", "headers": { "Authorization": "Bearer your_endpoint_token" } }' ``` ```typescript tab=Node await admin.webhooks.setHeaders({ id: "whs-...", headers: { Authorization: "Bearer your_endpoint_token" }, }); ``` ```python tab=Python from linkedapi import SetWebhookHeadersParams admin.webhooks.set_headers( SetWebhookHeadersParams( id="whs-...", headers={"Authorization": "Bearer your_endpoint_token"}, ) ) ``` ```bash tab=CLI # headers.json: { "Authorization": "Bearer your_endpoint_token" } cat headers.json | linkedin admin webhook set-headers whs-... --stdin --json ``` :::surface cli In the CLI this is `linkedin admin webhook set-headers ` with exactly one of two flags: | Flag | Type | Description | | --- | --- | --- | | `--stdin` | boolean | Read the headers from stdin, as a JSON object of names and values | | `--clear` | boolean | Remove every header (sends `null`) | An empty value in the JSON object is refused with exit code `5` before anything is sent; an empty object `{}` clears every header, like `--clear`. ::: **Body:** - `id` – webhook identifier. Required. - `headers` – name-to-value object, or `null` to clear. Required. ## webhook.revealSecret Return the current signing secret. Use it when you need the secret again after enabling signing – for example when configuring a second environment. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.revealSecret \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-..." }' ``` ```typescript tab=Node const { secret } = await admin.webhooks.revealSecret({ id: "whs-..." }); ``` ```python tab=Python from linkedapi import RevealWebhookSecretParams secret = admin.webhooks.reveal_secret(RevealWebhookSecretParams(id="whs-...")).secret ``` ```bash tab=CLI linkedin admin webhook reveal-secret whs-... --print-secret linkedin admin webhook reveal-secret whs-... --secret-file webhook-secret.txt ``` :::surface cli In the CLI this is `linkedin admin webhook reveal-secret `, and it needs exactly one of two flags that say where the secret goes: | Flag | Type | Description | | --- | --- | --- | | `--print-secret` | boolean | Write only the secret and a newline to stdout, ignoring `--json` and `--fields`, so it fits `$(...)` | | `--secret-file` | string | Create a new file with mode `0600` holding the secret, and print its path instead of the secret. A path that already exists is refused | ::: **Body:** - `id` – webhook identifier. Required. ## webhook.rotateSecret Generate a new signing secret, store it and return it. This replaces a leaked secret without deleting the webhook, which would change its `id`. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.rotateSecret \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-..." }' ``` ```typescript tab=Node const { secret } = await admin.webhooks.rotateSecret({ id: "whs-..." }); ``` ```python tab=Python from linkedapi import RotateWebhookSecretParams secret = admin.webhooks.rotate_secret(RotateWebhookSecretParams(id="whs-...")).secret ``` ```bash tab=CLI linkedin admin webhook rotate-secret whs-... --secret-file new-webhook-secret.txt ``` :::surface cli In the CLI this is `linkedin admin webhook rotate-secret `. It warns on stderr that the old secret stops working at once, and takes the same two flags as [`reveal-secret`](#webhook-revealsecret), both optional. With `--secret-file`, the file is created before the secret rotates, so a path that already exists stops the command before anything changes. Without either flag, the new secret is shown as `[REDACTED]`; read it with `reveal-secret`. ::: **Body:** - `id` – webhook identifier. Required. > Rotation takes effect immediately and there is no overlap period: the old secret stops signing the moment the new one is stored, so every delivery fails verification until your endpoint holds the new value. Rotate when you are ready to deploy it. ## webhook.delete Delete the webhook. This is a soft delete: the delivery history is preserved and any still-pending deliveries are dropped. Because only active webhooks count toward the one-per-client limit, you can register a fresh webhook afterwards. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.delete \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "id": "whs-..." }' ``` ```typescript tab=Node await admin.webhooks.delete({ id: "whs-..." }); ``` ```python tab=Python from linkedapi import DeleteWebhookParams admin.webhooks.delete(DeleteWebhookParams(id="whs-...")) ``` ```bash tab=CLI linkedin admin webhook delete whs-... ``` **Body:** - `id` – webhook identifier. Required. ## webhook.deliveries Return the most recent deliveries (newest first) as a debug feed – useful for confirming an endpoint is receiving and acknowledging events. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.deliveries \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const deliveries = await admin.webhooks.deliveries(); ``` ```python tab=Python deliveries = admin.webhooks.deliveries() ``` ```bash tab=CLI linkedin admin webhook deliveries --json ``` **Response:** ```json { "success": true, "result": { "deliveries": [ { "id": "whd-...", "eventType": "workflow.completed", "eventId": "workflow.completed:wf-...", "status": "success", "attempts": 1, "responseStatusCode": 200, "lastError": null, "createdAt": "2026-06-25T12:00:00.000Z", "updatedAt": "2026-06-25T12:00:01.000Z" } ] } } ``` - `id` – delivery identifier, passed to `webhook.replayDelivery`. - `eventType` – the event `type` that was delivered. - `eventId` – the envelope `id` of the delivered event. - `status` – `pending`, `delivering`, `success`, or `failed`. - `attempts` – delivery attempts made so far. - `responseStatusCode` – HTTP status your endpoint returned on the last attempt, or `null`. - `lastError` – error text from the last failed attempt, or `null`. - `createdAt` / `updatedAt` – ISO 8601 timestamps. ## webhook.replayDelivery Re-arm an already-settled (`success` or `failed`) delivery for redelivery. The same `eventId` is reused, so consumer-side deduplication still applies – this is a true redelivery, not a new event. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.replayDelivery \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "deliveryId": "whd-..." }' ``` ```typescript tab=Node await admin.webhooks.replayDelivery({ deliveryId: "whd-..." }); ``` ```python tab=Python from linkedapi import ReplayWebhookDeliveryParams admin.webhooks.replay_delivery(ReplayWebhookDeliveryParams(delivery_id="whd-...")) ``` ```bash tab=CLI linkedin admin webhook replay whd-... ``` **Body:** - `deliveryId` – delivery identifier from `webhook.deliveries`. Required. ## webhook.sendTest Emit a synthetic `webhook.test` event to the active webhook through the normal delivery path. Its `data` carries a single `message` field. Use it to verify a freshly registered endpoint without waiting for a real event. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/webhook.sendTest \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node await admin.webhooks.sendTest(); ``` ```python tab=Python admin.webhooks.send_test() ``` ```bash tab=CLI linkedin admin webhook send-test ``` ## Overview Admin endpoints let you programmatically manage your Linked API subscription status, seats, connected LinkedIn accounts, and rate limits. :::surface node In the Node SDK the Admin API is its own client, `LinkedApiAdmin`, with the namespaces `admin.subscription`, `admin.accounts`, `admin.limits` and `admin.webhooks` – see [Authentication](#authentication) and [Endpoints](#endpoints). ::: :::surface python In the Python SDK the Admin API is its own client, `LinkedApiAdmin`, with the namespaces `admin.subscription`, `admin.accounts`, `admin.limits` and `admin.webhooks` – see [Authentication](#authentication) and [Endpoints](#endpoints). ::: :::surface cli In the CLI the Admin API is the `linkedin admin` commands – `subscription`, `accounts`, `limits` and `webhook` – see [Authentication](#authentication) and [Endpoints](#endpoints). ::: While the main API endpoints ([workflows](/docs/executing-workflows), [inbox](/docs/monitoring-inbox), [statistics](/docs/checking-api-usage-statistics)) operate on behalf of a specific LinkedIn account, admin endpoints manage your Linked API account itself. They are synchronous (direct request-response, no workflows) and need only your Linked API token – no identification token. You can find the token in the [platform dashboard](https://app.linkedapi.io). The Admin API keeps its names on every surface: the endpoint `subscription.getStatus` is the SDK method `admin.subscription.getStatus`, the CLI command `linkedin admin subscription status` and the MCP tool `admin_get_subscription_status`. Pick your surface with the switch above. ## Authentication :::surface http Send the `linked-api-token` header – the same token used for all other API requests. Account-specific operations take `accountId` in the request body instead of the `identification-token` header. ::: :::surface node,python Admin endpoints are not methods of the main `LinkedApi` client. They have their own client, `LinkedApiAdmin`, created with your Linked API token only: ::: :::surface cli Admin commands use the Linked API token that `linkedin setup` saved for the current account and send no identification token, so they do not depend on the state of that LinkedIn account. They take no `--account` flag: to use the token of another saved account, switch to it with `linkedin account switch`. Account-specific commands take the account ID as an argument. ::: ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/subscription.getStatus \ -H "Content-Type: application/json" \ -H "linked-api-token: your-linked-api-token" ``` ```typescript tab=Node import { LinkedApiAdmin } from '@linkedapi/node'; const admin = new LinkedApiAdmin({ linkedApiToken: 'your-linked-api-token', }); ``` ```python tab=Python from linkedapi import AdminConfig, LinkedApiAdmin admin = LinkedApiAdmin(AdminConfig(linked_api_token="your-linked-api-token")) ``` ```bash tab=CLI linkedin setup # saves the Linked API token with the account linkedin admin subscription status # sends only that token ``` :::surface node,python Account-specific methods take `accountId` in their parameters instead of an identification token. ::: ## Request format :::surface http All endpoints use the **POST** method with a JSON body. The URL pattern is: ```text POST https://api.linkedapi.io/admin/. ``` | Header | Required | Description | |--------|----------|-------------| | `linked-api-token` | Yes | Your Linked API token | | `Content-Type` | Yes | `application/json` | ::: :::surface node,python The endpoint `.` is the method `` of the `admin.` namespace: `admin.subscription`, `admin.accounts` (accounts and connection sessions), `admin.limits` and `admin.webhooks`. ::: :::surface http For example, `subscription.setSeats`: ::: :::surface node A method takes the request body as one object: ::: :::surface python Methods are in snake_case, and a method that takes parameters takes them as a params model imported from `linkedapi`: ::: :::surface cli The endpoint `.` is a command under `linkedin admin `, and the body fields are flags or arguments: ::: ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/subscription.setSeats \ -H "Content-Type: application/json" \ -H "linked-api-token: your-linked-api-token" \ -d '{"quantity": 5, "billingPeriod": "year", "seatType": "plus"}' ``` ```typescript tab=Node const { status, paymentLink } = await admin.subscription.setSeats({ quantity: 5, billingPeriod: 'year', seatType: 'plus', }); ``` ```python tab=Python from linkedapi import SetSeatsParams result = admin.subscription.set_seats( SetSeatsParams(quantity=5, billing_period="year", seat_type="plus") ) ``` ```bash tab=CLI linkedin admin subscription set-seats --quantity 5 --period year --type plus ``` ## Response format :::surface http **Success:** ```json { "success": true, "result": { ... } } ``` **Error:** ```json { "success": false, "error": { "type": "errorType", "message": "Human-readable error description" } } ``` A request with an invalid token, and the response it gets: ::: :::surface node,python Admin methods are direct request-response, not workflows: there is no `execute()` / `result()`. A method returns the endpoint's `result`, or nothing when that result is empty, and throws `LinkedApiError` with the `type` and `message` of the error response. A network failure throws it with the type `httpError`. ::: :::surface cli Admin commands accept `--json`, `--fields`, `--quiet` and `--no-color`, and print the endpoint's `result` as `data`, except that `subscription seats` and `limits defaults`, `get` and `usage` print the array itself, `accounts session` prints the `session` object, and commands with an empty result print a confirmation, or `"data": null` in JSON. Errors are written to stderr as a JSON object with `error` and `message`, with the usual [exit codes](/cli/flags-and-output#exit-codes): `2` for token errors, `5` for `accountIdRequired`, `accountNotFound`, `sessionNotFound`, `noAvailableSeats`, `dailyConnectionAttemptsExceeded` and `invalidRequestPayload`, and `6` for `tooManyRequests`. ::: ```bash tab=HTTP curl -i -X POST https://api.linkedapi.io/admin/subscription.getStatus \ -H "Content-Type: application/json" \ -H "linked-api-token: invalid-token" # HTTP/2 401 # {"success":false,"error":{"type":"invalidLinkedApiToken","message":"The provided 'linked-api-token' is invalid."}} ``` ```typescript tab=Node import { LinkedApiError } from '@linkedapi/node'; try { const status = await admin.subscription.getStatus(); } catch (e) { if (e instanceof LinkedApiError) { console.error(`${e.type}: ${e.message}`); } } ``` ```python tab=Python from linkedapi import LinkedApiError try: status = admin.subscription.get_status() except LinkedApiError as e: print(f"{e.type}: {e.message}") ``` ```bash tab=CLI linkedin admin subscription status echo $? # stderr: {"error":"invalidLinkedApiToken","message":"The provided 'linked-api-token' is invalid."} # stderr: Run "linkedin account update" to refresh your tokens. # 2 ``` ### Common errors The error type is the same on every surface: `error.type` in the HTTP response, `type` of `LinkedApiError` in the SDKs, `error` in the CLI output. | Type | HTTP Status | Description | |------|-------------|-------------| | `linkedApiTokenRequired` | 401 | Missing `linked-api-token` header | | `invalidLinkedApiToken` | 401 | Token is invalid or expired | | `accountIdRequired` | 401 | Missing or invalid `accountId` | | `accountNotFound` | 401 | Account does not exist or does not belong to you | | `sessionNotFound` | 404 | Connection session not found | | `tooManyRequests` | 429 | Rate limit exceeded (100 requests per 60 seconds) | ## Endpoints :::surface http | Endpoint | What it does | |----------|--------------| | [`subscription.getStatus`](/docs/admin-subscription#subscription-getstatus) | Get the subscription status | | [`subscription.getSeats`](/docs/admin-subscription#subscription-getseats) | Get the active seats | | [`subscription.setSeats`](/docs/admin-subscription#subscription-setseats) | Set the number of seats, or get a checkout link | | [`accounts.getAll`](/docs/admin-accounts#accounts-getall) | List connected accounts and pending connection sessions | | [`accounts.reparseAccountInfo`](/docs/admin-accounts#accounts-reparseaccountinfo) | Refresh an account's stored profile information | | [`accounts.disconnect`](/docs/admin-accounts#accounts-disconnect) | Disconnect an account | | [`accounts.regenerateIdentificationToken`](/docs/admin-accounts#accounts-regenerateidentificationtoken) | Issue a new identification token | | [`accounts.createConnectionSession`](/docs/admin-connection-sessions#accounts-createconnectionsession) | Start connecting a new account | | [`accounts.createReconnectionSession`](/docs/admin-connection-sessions#accounts-createreconnectionsession) | Start reconnecting an account | | [`accounts.getConnectionSession`](/docs/admin-connection-sessions#accounts-getconnectionsession) | Check a connection session | | [`accounts.cancelConnectionSession`](/docs/admin-connection-sessions#accounts-cancelconnectionsession) | Cancel a connection session | | [`limits.getDefaults`](/docs/admin-limits#limits-getdefaults) | Get the system default limits | | [`limits.get`](/docs/admin-limits#limits-get) | Get an account's limits | | [`limits.getUsage`](/docs/admin-limits#limits-getusage) | Get an account's usage against its limits | | [`limits.set`](/docs/admin-limits#limits-set) | Set or update an account's limits | | [`limits.delete`](/docs/admin-limits#limits-delete) | Remove an account's limits | | [`limits.resetToDefaults`](/docs/admin-limits#limits-resettodefaults) | Reset an account's limits to the defaults | [Webhook](/docs/admin-webhooks) endpoints are listed on their own page. ::: :::surface node | Method | What it does | |--------|--------------| | [`admin.subscription.getStatus()`](/docs/admin-subscription#subscription-getstatus) | Get the subscription status | | [`admin.subscription.getSeats()`](/docs/admin-subscription#subscription-getseats) | Get the active seats | | [`admin.subscription.setSeats()`](/docs/admin-subscription#subscription-setseats) | Set the number of seats, or get a checkout link | | [`admin.accounts.getAll()`](/docs/admin-accounts#accounts-getall) | List connected accounts and pending connection sessions | | [`admin.accounts.reparseAccountInfo()`](/docs/admin-accounts#accounts-reparseaccountinfo) | Refresh an account's stored profile information | | [`admin.accounts.disconnect()`](/docs/admin-accounts#accounts-disconnect) | Disconnect an account | | [`admin.accounts.regenerateIdentificationToken()`](/docs/admin-accounts#accounts-regenerateidentificationtoken) | Issue a new identification token | | [`admin.accounts.createConnectionSession()`](/docs/admin-connection-sessions#accounts-createconnectionsession) | Start connecting a new account | | [`admin.accounts.createReconnectionSession()`](/docs/admin-connection-sessions#accounts-createreconnectionsession) | Start reconnecting an account | | [`admin.accounts.getConnectionSession()`](/docs/admin-connection-sessions#accounts-getconnectionsession) | Check a connection session | | [`admin.accounts.cancelConnectionSession()`](/docs/admin-connection-sessions#accounts-cancelconnectionsession) | Cancel a connection session | | [`admin.limits.getDefaults()`](/docs/admin-limits#limits-getdefaults) | Get the system default limits | | [`admin.limits.get()`](/docs/admin-limits#limits-get) | Get an account's limits | | [`admin.limits.getUsage()`](/docs/admin-limits#limits-getusage) | Get an account's usage against its limits | | [`admin.limits.set()`](/docs/admin-limits#limits-set) | Set or update an account's limits | | [`admin.limits.delete()`](/docs/admin-limits#limits-delete) | Remove an account's limits | | [`admin.limits.resetToDefaults()`](/docs/admin-limits#limits-resettodefaults) | Reset an account's limits to the defaults | The [webhook](/docs/admin-webhooks) methods are under `admin.webhooks`. ::: :::surface python | Method | What it does | |--------|--------------| | [`admin.subscription.get_status()`](/docs/admin-subscription#subscription-getstatus) | Get the subscription status | | [`admin.subscription.get_seats()`](/docs/admin-subscription#subscription-getseats) | Get the active seats | | [`admin.subscription.set_seats()`](/docs/admin-subscription#subscription-setseats) | Set the number of seats, or get a checkout link | | [`admin.accounts.get_all()`](/docs/admin-accounts#accounts-getall) | List connected accounts and pending connection sessions | | [`admin.accounts.reparse_account_info()`](/docs/admin-accounts#accounts-reparseaccountinfo) | Refresh an account's stored profile information | | [`admin.accounts.disconnect()`](/docs/admin-accounts#accounts-disconnect) | Disconnect an account | | [`admin.accounts.regenerate_identification_token()`](/docs/admin-accounts#accounts-regenerateidentificationtoken) | Issue a new identification token | | [`admin.accounts.create_connection_session()`](/docs/admin-connection-sessions#accounts-createconnectionsession) | Start connecting a new account | | [`admin.accounts.create_reconnection_session()`](/docs/admin-connection-sessions#accounts-createreconnectionsession) | Start reconnecting an account | | [`admin.accounts.get_connection_session()`](/docs/admin-connection-sessions#accounts-getconnectionsession) | Check a connection session | | [`admin.accounts.cancel_connection_session()`](/docs/admin-connection-sessions#accounts-cancelconnectionsession) | Cancel a connection session | | [`admin.limits.get_defaults()`](/docs/admin-limits#limits-getdefaults) | Get the system default limits | | [`admin.limits.get()`](/docs/admin-limits#limits-get) | Get an account's limits | | [`admin.limits.get_usage()`](/docs/admin-limits#limits-getusage) | Get an account's usage against its limits | | [`admin.limits.set()`](/docs/admin-limits#limits-set) | Set or update an account's limits | | [`admin.limits.delete()`](/docs/admin-limits#limits-delete) | Remove an account's limits | | [`admin.limits.reset_to_defaults()`](/docs/admin-limits#limits-resettodefaults) | Reset an account's limits to the defaults | The [webhook](/docs/admin-webhooks) methods are under `admin.webhooks`. ::: :::surface cli | Command | What it does | |---------|--------------| | [`linkedin admin subscription status`](/docs/admin-subscription#subscription-getstatus) | Get the subscription status | | [`linkedin admin subscription seats`](/docs/admin-subscription#subscription-getseats) | Get the active seats | | [`linkedin admin subscription set-seats`](/docs/admin-subscription#subscription-setseats) | Set the number of seats, or get a checkout link | | [`linkedin admin accounts list`](/docs/admin-accounts#accounts-getall) | List connected accounts and pending connection sessions | | [`linkedin admin accounts reparse`](/docs/admin-accounts#accounts-reparseaccountinfo) | Refresh an account's stored profile information | | [`linkedin admin accounts disconnect`](/docs/admin-accounts#accounts-disconnect) | Disconnect an account | | [`linkedin admin accounts regenerate-token`](/docs/admin-accounts#accounts-regenerateidentificationtoken) | Issue a new identification token | | [`linkedin admin accounts connect`](/docs/admin-connection-sessions#accounts-createconnectionsession) | Start connecting a new account | | [`linkedin admin accounts reconnect`](/docs/admin-connection-sessions#accounts-createreconnectionsession) | Start reconnecting an account | | [`linkedin admin accounts session`](/docs/admin-connection-sessions#accounts-getconnectionsession) | Check a connection session | | [`linkedin admin accounts cancel-session`](/docs/admin-connection-sessions#accounts-cancelconnectionsession) | Cancel a connection session | | [`linkedin admin limits defaults`](/docs/admin-limits#limits-getdefaults) | Get the system default limits | | [`linkedin admin limits get`](/docs/admin-limits#limits-get) | Get an account's limits | | [`linkedin admin limits usage`](/docs/admin-limits#limits-getusage) | Get an account's usage against its limits | | [`linkedin admin limits set`](/docs/admin-limits#limits-set) | Set or update an account's limits | | [`linkedin admin limits delete`](/docs/admin-limits#limits-delete) | Remove an account's limits | | [`linkedin admin limits reset`](/docs/admin-limits#limits-resettodefaults) | Reset an account's limits to the defaults | The [webhook](/docs/admin-webhooks) commands are under `linkedin admin webhook`. ::: In the [MCP server](/mcp/available-tools#administration) each of these endpoints is an `admin_*` tool; the MCP server has no webhook tools. ## Next steps - [Subscription](/docs/admin-subscription) – manage subscription status and seats - [Accounts](/docs/admin-accounts) – list, refresh, disconnect, and monitor LinkedIn accounts - [Connection Sessions](/docs/admin-connection-sessions) – connect and reconnect accounts, and customize the completion screen - [Limits](/docs/admin-limits) – configure and monitor rate limits - [Webhooks](/docs/admin-webhooks) – register an endpoint to receive workflow and account events ## Subscription Manage your Linked API subscription programmatically. For general Admin API information, see the [Admin overview](/docs/admin-overview). ## subscription.getStatus Get current subscription status. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/subscription.getStatus \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const status = await admin.subscription.getStatus(); ``` ```python tab=Python status = admin.subscription.get_status() ``` ```bash tab=CLI linkedin admin subscription status ``` **Response:** ```json { "success": true, "result": { "status": "active", "eligibleForTrial": false, "cancelAtPeriodEnd": false } } ``` | Field | Type | Description | |-------|------|-------------| | `status` | `string` or `undefined` | `active`, `trialing`, `past_due`, `canceled`, or `undefined` if no subscription | | `eligibleForTrial` | `boolean` | Whether a 7-day free trial is available for a one-seat first checkout | | `cancelAtPeriodEnd` | `boolean` | Whether the subscription is scheduled to cancel at the end of the current billing period | ## subscription.getSeats Get active subscription seats. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/subscription.getSeats \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const { seats } = await admin.subscription.getSeats(); ``` ```python tab=Python seats = admin.subscription.get_seats().seats ``` ```bash tab=CLI linkedin admin subscription seats ``` **Response:** ```json { "success": true, "result": { "seats": [ { "seatType": "plus", "quantity": 10, "billingPeriod": "year" } ] } } ``` | Field | Type | Description | |-------|------|-------------| | `seatType` | `"core"` \| `"plus"` | Seat tier. `plus` unlocks Sales Navigator actions (`nv.*`) | | `quantity` | `number` | Number of seats. Each seat allows one connected LinkedIn account | | `billingPeriod` | `"month"` \| `"year"` | Billing cycle | ## subscription.setSeats Set the number of subscription seats. If you already have an active subscription, the quantity is updated immediately. If not, a Stripe checkout link is returned. New users can start with a 7-day free trial when they do not have any previous trial or subscription history; a card is required to start it. The trial covers a single seat: a first checkout for more than one seat starts a regular paid subscription. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/subscription.setSeats \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "quantity": 5, "billingPeriod": "year", "seatType": "plus" }' ``` ```typescript tab=Node const { status, paymentLink } = await admin.subscription.setSeats({ quantity: 5, billingPeriod: 'year', seatType: 'plus', }); ``` ```python tab=Python from linkedapi import SetSeatsParams result = admin.subscription.set_seats( SetSeatsParams(quantity=5, billing_period="year", seat_type="plus") ) ``` ```bash tab=CLI linkedin admin subscription set-seats --quantity 5 --period year --type plus ``` :::surface cli In the CLI the body fields are flags, all required: | Flag | Body field | Values | | --- | --- | --- | | `--quantity` | `quantity` | 1–1000 | | `--period` | `billingPeriod` | `month`, `year` | | `--type` | `seatType` | `core`, `plus` | ::: **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `quantity` | `number` | Yes | Number of seats (1–1000) | | `billingPeriod` | `"month"` \| `"year"` | Yes | Billing cycle | | `seatType` | `"core"` \| `"plus"` | Yes | Seat tier | **Response (subscription exists):** ```json { "success": true, "result": { "status": "complete" } } ``` **Response (no subscription, checkout required):** ```json { "success": true, "result": { "status": "processing", "paymentLink": "https://checkout.stripe.com/..." } } ``` > **Note:** When reducing seats below the number of connected accounts, excess accounts will be automatically frozen. Changes are applied in Stripe immediately but may take a few seconds to reflect in the API due to webhook processing. ## Accounts Manage your connected LinkedIn accounts programmatically. For general Admin API information, see the [Admin overview](/docs/admin-overview). To connect a new account or reconnect an existing one, see [Connection Sessions](/docs/admin-connection-sessions). ## accounts.getAll Get all connected LinkedIn accounts and pending connection sessions. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.getAll \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const { accounts, pendingConnectionSessions } = await admin.accounts.getAll(); ``` ```python tab=Python result = admin.accounts.get_all() accounts = result.accounts pending_connection_sessions = result.pending_connection_sessions ``` ```bash tab=CLI linkedin admin accounts list ``` **Response:** ```json { "success": true, "result": { "accounts": [ { "id": "f9b4346a-...", "name": "John Doe", "url": "https://www.linkedin.com/in/johndoe/", "avatarUrl": "https://media.licdn.com/dms/image/...", "headline": "Founder at Example", "countryCode": "US", "identificationToken": "id_...", "status": "active", "connectedAt": "2026-02-20T11:11:51.732Z" }, { "id": "a91c20f8-...", "name": "Jane Doe", "url": "https://www.linkedin.com/in/janedoe/", "avatarUrl": null, "headline": null, "countryCode": "US", "identificationToken": "id_...", "status": "reconnection_required", "connectedAt": "2026-01-12T09:30:00.000Z", "reconnectionSessionId": "d90ac1f6-...", "reconnectionLink": "https://app.linkedapi.io/connection/d90ac1f6-..." } ], "pendingConnectionSessions": [ { "sessionId": "990eef7a-...", "status": "pending" } ] } } ``` **Account fields:** | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Account UUID | | `name` | `string` | LinkedIn account name | | `url` | `string` | Public LinkedIn profile URL for the connected account | | `avatarUrl` | `string \| null` | LinkedIn profile image URL, or `null` when it has not been parsed yet | | `headline` | `string \| null` | LinkedIn headline shown below the account name, or `null` when it has not been parsed yet | | `countryCode` | `string` | Country code selected during connection | | `identificationToken` | `string` | The account's identification token, used by every non-admin request | | `status` | `string` | `active`, `frozen`, or `reconnection_required` | | `connectedAt` | `string` | ISO 8601 timestamp | | `reconnectionSessionId` | `string` | Present only when `status` is `reconnection_required`; session UUID for reconnecting the account | | `reconnectionLink` | `string` | Present only when `status` is `reconnection_required`; URL to open in a browser to reconnect the account | When an account is `reconnection_required`, `accounts.getAll` returns an active reconnection session. If no active reconnection session exists, one is created automatically before the response is returned. **Account statuses:** | Status | Description | |--------|-------------| | `active` | Account is connected and operational | | `frozen` | Account is frozen (subscription downgraded or expired) | | `reconnection_required` | LinkedIn session expired, account needs to be reconnected | ## accounts.reparseAccountInfo Refresh the stored profile information for a connected LinkedIn account. This starts a background workflow that opens the account's own LinkedIn session and reparses the account name, public profile URL, avatar URL, and headline. The refreshed values are returned by `accounts.getAll` after the workflow completes. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.reparseAccountInfo \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"accountId": "f9b4346a-..."}' ``` ```typescript tab=Node const { workflowId } = await admin.accounts.reparseAccountInfo({ accountId: 'f9b4346a-...', }); ``` ```python tab=Python from linkedapi import ReparseAccountInfoParams result = admin.accounts.reparse_account_info( ReparseAccountInfoParams(account_id="f9b4346a-...") ) ``` ```bash tab=CLI linkedin admin accounts reparse f9b4346a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | UUID of the account to refresh | **Response:** ```json { "success": true, "result": { "workflowId": "reparseAccountInfoWorkflow-..." } } ``` **Notes:** - The response confirms that the reparse workflow was started; it does not include the refreshed account object. - Call `accounts.getAll` after the workflow completes to read the updated `url`, `avatarUrl`, and `headline` fields. - `avatarUrl` and `headline` can remain `null` if LinkedIn does not render those values or the account session needs reconnection. ## accounts.disconnect Disconnect a LinkedIn account. This permanently removes the account, its browser profile, and proxy. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.disconnect \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"accountId": "f9b4346a-..."}' ``` ```typescript tab=Node await admin.accounts.disconnect({ accountId: 'f9b4346a-...' }); ``` ```python tab=Python from linkedapi import DisconnectParams admin.accounts.disconnect(DisconnectParams(account_id="f9b4346a-...")) ``` ```bash tab=CLI linkedin admin accounts disconnect f9b4346a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | UUID of the account to disconnect | **Response:** ```json { "success": true, "result": {} } ``` > **Warning:** This action is irreversible. The account must be reconnected from scratch. ## accounts.regenerateIdentificationToken Generate a new `identification-token` for an account. The old token becomes invalid immediately. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.regenerateIdentificationToken \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"accountId": "f9b4346a-..."}' ``` ```typescript tab=Node const { token } = await admin.accounts.regenerateIdentificationToken({ accountId: 'f9b4346a-...', }); ``` ```python tab=Python from linkedapi import RegenerateTokenParams result = admin.accounts.regenerate_identification_token( RegenerateTokenParams(account_id="f9b4346a-...") ) ``` ```bash tab=CLI linkedin admin accounts regenerate-token f9b4346a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | UUID of the account | **Response:** ```json { "success": true, "result": { "token": "id_new_token_here" } } ``` > **Important:** Replace the identification token in all your integrations immediately after regeneration: in the `identification-token` header, the SDK client config, or the CLI (`linkedin account update`). :::surface node,python In the SDK, that means every `LinkedApi` client created with the old token: pass the new one as its `identificationToken`. ::: :::surface cli `regenerate-token` does not change the token the CLI saved for that account. If the account is saved in the CLI, store the new token with `linkedin account update`. ::: ## Connection Sessions A connection session is a short-lived link that opens the Linked API connection page, where the end user logs into LinkedIn. Use it to connect a new account or to reconnect one whose LinkedIn session has expired. For the accounts themselves, see [Accounts](/docs/admin-accounts). ## accounts.createConnectionSession Create a new connection session to connect a LinkedIn account. Returns a link that opens the connection page where the user logs into LinkedIn. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.createConnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const { sessionId, connectionLink } = await admin.accounts.createConnectionSession(); ``` ```python tab=Python created = admin.accounts.create_connection_session() ``` ```bash tab=CLI linkedin admin accounts connect ``` **Body:** The body is optional. Send no body at all to get the default behavior, or pass `completion` to control what the user sees after a successful connection – see [Customizing the completion screen](/docs/admin-completion-screen). | Field | Type | Required | Description | |-------|------|----------|-------------| | `completion` | `object` | No | Post-connection behavior of the connection page | :::surface node,python The SDK's `createConnectionSession` takes no parameters, so it always creates a session with the default completion screen. To pass `completion`, call the HTTP endpoint. ::: :::surface cli `linkedin admin accounts connect` sends no body, so it always creates a session with the default completion screen. To pass `completion`, call the HTTP endpoint. ::: **Response:** ```json { "success": true, "result": { "sessionId": "990eef7a-...", "connectionLink": "https://app.linkedapi.io/connection/990eef7a-..." } } ``` | Field | Type | Description | |-------|------|-------------| | `sessionId` | `string` | Session UUID for tracking | | `connectionLink` | `string` | URL to open in a browser to complete the LinkedIn login | **Errors:** | Type | Description | |------|-------------| | `subscriptionRequired` | No subscription with a seat yet – buy seats with [subscription.setSeats](/docs/admin-subscription#subscription-setseats) first | | `noAvailableSeats` | No available seats – all seats are occupied by active accounts or pending connection sessions | | `dailyConnectionAttemptsExceeded` | Too many connection attempts in the last 24 hours | | `invalidRequestPayload` | The `completion` object is malformed – the message names the offending field | > **Flow:** Create session → open `connectionLink` → user logs into LinkedIn → poll `accounts.getConnectionSession` until status is `success` → account appears in `accounts.getAll`. :::surface http The whole flow over HTTP – create the session, check it until it leaves the in-progress statuses, then read the accounts: ::: :::surface node,python The whole flow with the SDK – create the session, poll it until it leaves the in-progress statuses, then read the accounts: ::: :::surface cli The same flow in the CLI. `connect` only creates the session and prints `sessionId` and `connectionLink` – it neither opens the link nor waits, so check the session yourself: ::: ```bash tab=HTTP # 1. Create the session and open the returned connectionLink in a browser curl -X POST https://api.linkedapi.io/admin/accounts.createConnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" # 2. After the user logs into LinkedIn, check the session until status is "success" curl -X POST https://api.linkedapi.io/admin/accounts.getConnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"sessionId": ""}' # 3. Verify the account appeared curl -X POST https://api.linkedapi.io/admin/accounts.getAll \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node // 1. Create a connection session const { sessionId, connectionLink } = await admin.accounts.createConnectionSession(); console.log('Open this link to connect:', connectionLink); // 2. Poll until the session completes const POLL_INTERVAL_MS = 3000; const MAX_WAIT_MS = 2 * 60 * 1000; const deadline = Date.now() + MAX_WAIT_MS; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const inProgressStatuses = ['pending', 'preparing', 'serving', 'streaming']; let session; do { if (Date.now() >= deadline) { throw new Error('Timed out waiting for connection session completion'); } await sleep(POLL_INTERVAL_MS); ({ session } = await admin.accounts.getConnectionSession({ sessionId })); } while (inProgressStatuses.includes(session.status)); if (session.status === 'success') { // 3. Get the new account details const { accounts } = await admin.accounts.getAll(); console.log('Total accounts:', accounts.length); } ``` ```python tab=Python import time from linkedapi import GetConnectionSessionParams # 1. Create a connection session created = admin.accounts.create_connection_session() print("Open this link to connect:", created.connection_link) # 2. Poll until the session completes POLL_INTERVAL_SECONDS = 3 MAX_WAIT_SECONDS = 2 * 60 deadline = time.monotonic() + MAX_WAIT_SECONDS in_progress_statuses = {"pending", "preparing", "serving", "streaming"} while True: if time.monotonic() >= deadline: raise TimeoutError("Timed out waiting for connection session completion") time.sleep(POLL_INTERVAL_SECONDS) session = admin.accounts.get_connection_session( GetConnectionSessionParams(session_id=created.session_id) ).session if session.status not in in_progress_statuses: break if session.status == "success": # 3. Get the new account details accounts = admin.accounts.get_all().accounts print("Total accounts:", len(accounts)) ``` ```bash tab=CLI # 1. Create the session and open the printed connectionLink in a browser linkedin admin accounts connect # 2. After the user logs into LinkedIn, check the session until status is "success" linkedin admin accounts session --json # 3. Verify the account appeared linkedin admin accounts list ``` ## accounts.createReconnectionSession Create a new reconnection session for an account whose status is `reconnection_required`. If the account already has an active reconnection session, it is cancelled and replaced with a new one. If an in-progress reconnection session is applying a pending proxy change, Linked API returns that existing session instead so the proxy change state is preserved. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.createReconnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"accountId": "a91c20f8-..."}' ``` ```typescript tab=Node const { reconnectionSessionId, reconnectionLink } = await admin.accounts.createReconnectionSession({ accountId: 'a91c20f8-...' }); ``` ```python tab=Python from linkedapi import CreateReconnectionSessionParams created = admin.accounts.create_reconnection_session( CreateReconnectionSessionParams(account_id="a91c20f8-...") ) ``` ```bash tab=CLI linkedin admin accounts reconnect a91c20f8-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | UUID of the account to reconnect | | `completion` | `object` | No | Post-connection behavior of the connection page – see [Customizing the completion screen](/docs/admin-completion-screen) | :::surface node,python The SDK's `createReconnectionSession` takes only `accountId`. To pass `completion`, call the HTTP endpoint. ::: :::surface cli `linkedin admin accounts reconnect` sends only `accountId`. To pass `completion`, call the HTTP endpoint. ::: **Response:** ```json { "success": true, "result": { "reconnectionSessionId": "d90ac1f6-...", "reconnectionLink": "https://app.linkedapi.io/connection/d90ac1f6-..." } } ``` | Field | Type | Description | |-------|------|-------------| | `reconnectionSessionId` | `string` | Session UUID for tracking | | `reconnectionLink` | `string` | URL to open in a browser to complete LinkedIn reconnection | **Errors:** | Type | Description | |------|-------------| | `invalidRequestPayload` | The account is not in `reconnection_required` status or cannot be reconnected | > **Flow:** Create reconnection session → open `reconnectionLink` → user logs into LinkedIn → poll `accounts.getConnectionSession` with `reconnectionSessionId` until status is `success`. :::surface cli The same flow in the CLI: ```bash # 1. Find accounts that require reconnection – they carry a reconnectionLink linkedin admin accounts list # 2. Create a fresh reconnection session if you need to replace the link linkedin admin accounts reconnect # 3. Open the printed reconnectionLink, complete the LinkedIn login, then check the session linkedin admin accounts session --json ``` ::: ## accounts.getConnectionSession Check the status of a connection session. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.getConnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"sessionId": "990eef7a-..."}' ``` ```typescript tab=Node const { session } = await admin.accounts.getConnectionSession({ sessionId: '990eef7a-...' }); ``` ```python tab=Python from linkedapi import GetConnectionSessionParams session = admin.accounts.get_connection_session( GetConnectionSessionParams(session_id="990eef7a-...") ).session ``` ```bash tab=CLI linkedin admin accounts session 990eef7a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `sessionId` | `string` | Yes | Session UUID | **Response:** ```json { "success": true, "result": { "session": { "sessionId": "990eef7a-...", "status": "pending", "type": "initial" } } } ``` **Session statuses:** | Status | Description | |--------|-------------| | `pending` | Session created, waiting for user to open the link | | `preparing` | Browser is being provisioned | | `serving` | Browser is ready, waiting for user to connect | | `streaming` | User is connected and logging in | | `success` | Login completed, account is being created | | `expired` | Session timed out | | `error` | An error occurred | | `cancelled` | Session was cancelled | ## accounts.cancelConnectionSession Cancel an active connection session. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/accounts.cancelConnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"sessionId": "990eef7a-..."}' ``` ```typescript tab=Node await admin.accounts.cancelConnectionSession({ sessionId: '990eef7a-...' }); ``` ```python tab=Python from linkedapi import CancelConnectionSessionParams admin.accounts.cancel_connection_session( CancelConnectionSessionParams(session_id="990eef7a-...") ) ``` ```bash tab=CLI linkedin admin accounts cancel-session 990eef7a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `sessionId` | `string` | Yes | Session UUID | **Response:** ```json { "success": true, "result": {} } ``` ## Customizing the Completion Screen After the user finishes logging into LinkedIn, the connection page shows a completion screen. The optional `completion` object accepted by `accounts.createConnectionSession` and `accounts.createReconnectionSession` controls what happens there – which is what you need when the connection page is embedded into your own product. Every field is optional, and omitting `completion` entirely keeps the default behavior. :::surface node,python,cli The SDKs and the CLI create connection and reconnection sessions without `completion`, so everything on this page is available only through the HTTP endpoints. ::: ```json { "completion": { "limits": { "mode": "ask" }, "successUrl": "https://app.acme.com/connected?session={SESSION_ID}", "failureUrl": "https://app.acme.com/failed?session={SESSION_ID}" } } ``` | Field | Type | Description | |-------|------|-------------| | `limits.mode` | `string` | How account limits are handled: `ask` (default), `defaults`, or `skip`. Ignored on reconnection, which never changes an account's limits | | `button.text` | `string` | Label of a custom button shown on the completion screen (1–40 characters). Not allowed together with `successUrl` | | `button.url` | `string` | Where that button leads. `https` only, no placeholders | | `successUrl` | `string` | Redirect the user here after a successful connection, without any click | | `failureUrl` | `string` | Redirect the user here when the session ends in `error` or `expired` | **Limits modes:** | Mode | Behavior | |------|----------| | `ask` | Default. The user is shown the limits editor and chooses the values themselves | | `defaults` | Recommended limits are applied automatically, the editor is not shown | | `skip` | No limits are configured and the editor is not shown. The account runs without limits until you set them via [`limits.set`](/docs/admin-limits) | **Default button:** sessions created through this API do not show the "Continue to platform" button, because it leads to the Linked API dashboard, which your users have no access to. Instead, the completion screen tells the user the account is connected and the tab can be closed. To send the user somewhere, pass either `button` (they leave by clicking) or `successUrl` (they leave immediately). Passing both is rejected: with an instant redirect the button would never be visible. **Placeholders:** `successUrl` and `failureUrl` may contain placeholders that are substituted before the redirect. Values are URL-encoded. | Placeholder | Available in | Value | |-------------|--------------|-------| | `{SESSION_ID}` | `successUrl`, `failureUrl` | UUID of the connection session | | `{ACCOUNT_ID}` | `successUrl` | UUID of the account that was just connected | | `{ERROR_TYPE}` | `failureUrl` | Error type of the failed session, empty for `expired` | Any other placeholder, an unbalanced brace, or a non-`https` URL is rejected with `invalidRequestPayload` when the session is created – not later, when the user is already on the page. **When the redirect happens:** - `successUrl` fires after the limits step. With `mode: "ask"` that means after the user saves or skips the limits editor; with `defaults` or `skip` it fires as soon as the connection succeeds. - `failureUrl` covers the `error` and `expired` statuses. Sessions you cancel yourself through `accounts.cancelConnectionSession` do not redirect, and neither does an invalid or unknown session link, since there is no configuration to read for it. > **Do not rely on the redirect for fulfillment.** The user may close the tab or lose connectivity before it happens. Treat arriving on `successUrl` as a hint and confirm the real outcome with `accounts.getConnectionSession`. **Examples:** Fully embedded – no questions asked, the user is returned to your product in both outcomes: ```bash curl -X POST https://api.linkedapi.io/admin/accounts.createConnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "completion": { "limits": { "mode": "defaults" }, "successUrl": "https://app.acme.com/connected?session={SESSION_ID}&account={ACCOUNT_ID}", "failureUrl": "https://app.acme.com/failed?session={SESSION_ID}&error={ERROR_TYPE}" } }' ``` Keep the limits editor, but finish with your own button instead of an automatic redirect: ```bash curl -X POST https://api.linkedapi.io/admin/accounts.createConnectionSession \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "completion": { "limits": { "mode": "ask" }, "button": { "text": "Back to Acme", "url": "https://app.acme.com/accounts" } } }' ``` ## Limits Rate limits control how many actions each LinkedIn account can perform per time period. For general Admin API information, see the [Admin overview](/docs/admin-overview). :::surface node In the Node SDK these endpoints are methods of `admin.limits` on the [admin client](/docs/admin-overview#authentication): `getDefaults()`, `get()`, `getUsage()`, `set()`, `delete()` and `resetToDefaults()`. Each section below shows its call in the Node tab. ::: :::surface python In the Python SDK these endpoints are methods of `admin.limits` on the [admin client](/docs/admin-overview#authentication): `get_defaults()`, `get()`, `get_usage()`, `set()`, `delete()` and `reset_to_defaults()`. Each section below shows its call in the Python tab. ::: :::surface cli In the CLI these endpoints are the commands `linkedin admin limits defaults`, `get`, `usage`, `set`, `delete` and `reset`. Each section below shows its command in the CLI tab. ::: ## Limit categories | Category | Description | |----------|-------------| | `stPersonProfileViews` | Standard LinkedIn profile views | | `stCompanyPageViews` | Standard LinkedIn company page views | | `stConnectionRequests` | Connection requests sent | | `stMessages` | Messages sent | | `stSearchQueries` | LinkedIn search queries | | `stReactions` | Post reactions | | `stEndorsements` | Skill endorsements | | `stComments` | Post comments | | `stPosts` | Posts and reposts created | | `nvPersonProfileViews` | Sales Navigator profile views | | `nvCompanyPageViews` | Sales Navigator company page views | | `nvMessages` | Sales Navigator messages | **Periods:** `daily`, `weekly`, `monthly` ### Endorsement limits `stEndorsements` counts every [st.endorsePersonSkill](/docs/action-st-endorse-person-skill) execution, including each skill endorsed through [st.doForSkills](/docs/action-st-do-for-skills). Only successful executions count, and an execution where the skill was already endorsed is successful too. Its default limits are: | Period | `maxValue` | `isEnabled` | |--------|------------|-------------| | `daily` | 50 | `true` | | `weekly` | 350 | `false` | | `monthly` | 1500 | `false` | These defaults are applied wherever default limits are: when an account is connected with the `defaults` [limits mode](/docs/admin-completion-screen), and when its limits are reset with [limits.resetToDefaults](#limits-resettodefaults). **An account has no endorsement limit unless [limits.get](#limits-get) returns an enabled `stEndorsements` entry for it.** This is the case for accounts connected before endorsements became available, which have no `stEndorsements` entries at all. It can also be the case for accounts connected in the `ask` [limits mode](/docs/admin-completion-screen) while the limits editor did not show endorsement limits: it saved them with `isEnabled: false` unless the user chose the recommended limits. To cap endorsements, set them with [limits.set](#limits-set), or reset the account to defaults, which also replaces its custom limits in every other category. ## limits.getDefaults Get the system default limits. These are applied when an account is connected with the `defaults` limits mode, and when its limits are reset to defaults. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/limits.getDefaults \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" ``` ```typescript tab=Node const { limits } = await admin.limits.getDefaults(); ``` ```python tab=Python limits = admin.limits.get_defaults().limits ``` ```bash tab=CLI linkedin admin limits defaults ``` **Response:** ```json { "success": true, "result": { "limits": [ { "category": "stMessages", "period": "daily", "maxValue": 50, "isEnabled": true }, { "category": "stConnectionRequests", "period": "daily", "maxValue": 10, "isEnabled": true } ] } } ``` ## limits.get Get the current limits for a specific account. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/limits.get \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"accountId": "f9b4346a-..."}' ``` ```typescript tab=Node const { limits } = await admin.limits.get({ accountId: 'f9b4346a-...' }); ``` ```python tab=Python from linkedapi import GetLimitsParams limits = admin.limits.get(GetLimitsParams(account_id="f9b4346a-...")).limits ``` ```bash tab=CLI linkedin admin limits get f9b4346a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | Account UUID | **Response:** ```json { "success": true, "result": { "limits": [ { "category": "stMessages", "period": "daily", "maxValue": 25, "isEnabled": true } ] } } ``` ## limits.getUsage Get the current usage against limits for a specific account. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/limits.getUsage \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"accountId": "f9b4346a-..."}' ``` ```typescript tab=Node const { usage } = await admin.limits.getUsage({ accountId: 'f9b4346a-...' }); ``` ```python tab=Python from linkedapi import GetLimitsUsageParams usage = admin.limits.get_usage(GetLimitsUsageParams(account_id="f9b4346a-...")).usage ``` ```bash tab=CLI linkedin admin limits usage f9b4346a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | Account UUID | **Response:** ```json { "success": true, "result": { "usage": [ { "category": "stMessages", "period": "daily", "maxValue": 50, "currentUsage": 12, "isEnabled": true } ] } } ``` | Field | Type | Description | |-------|------|-------------| | `maxValue` | `number` | Maximum allowed actions | | `currentUsage` | `number` | Actions performed in the current period | | `isEnabled` | `boolean` | Whether this limit is enforced | ## limits.set Set or update limits for an account. Only the specified limits are created or updated; other limits remain unchanged. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/limits.set \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "accountId": "f9b4346a-...", "limits": [ { "category": "stMessages", "period": "daily", "maxValue": 25, "isEnabled": true }, { "category": "stConnectionRequests", "period": "weekly", "maxValue": 30 } ] }' ``` ```typescript tab=Node await admin.limits.set({ accountId: 'f9b4346a-...', limits: [ { category: 'stMessages', period: 'daily', maxValue: 25, isEnabled: true }, { category: 'stConnectionRequests', period: 'weekly', maxValue: 30 }, ], }); ``` ```python tab=Python from linkedapi import SetLimitEntry, SetLimitsParams admin.limits.set( SetLimitsParams( account_id="f9b4346a-...", limits=[ SetLimitEntry(category="stMessages", period="daily", max_value=25, is_enabled=True), SetLimitEntry(category="stConnectionRequests", period="weekly", max_value=30), ], ) ) ``` ```bash tab=CLI linkedin admin limits set f9b4346a-... --category stMessages --period daily --max 25 linkedin admin limits set f9b4346a-... --category stConnectionRequests --period weekly --max 30 ``` :::surface cli `linkedin admin limits set ` sets one limit per call, from these flags, all required: | Flag | Body field | Values | | --- | --- | --- | | `--category` | `limits[].category` | A limit category from the table above | | `--period` | `limits[].period` | `daily`, `weekly`, `monthly` | | `--max` | `limits[].maxValue` | An integer >= 0 | It has no flag for `isEnabled`, so the limit is always saved as enforced. ::: **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | Account UUID | | `limits` | `array` | Yes | Array of limit configurations | | `limits[].category` | `string` | Yes | Limit category (see table above) | | `limits[].period` | `string` | Yes | `daily`, `weekly`, or `monthly` | | `limits[].maxValue` | `number` | Yes | Maximum allowed actions (>= 0) | | `limits[].isEnabled` | `boolean` | No | Whether this limit is enforced (default: `true`) | **Response:** ```json { "success": true, "result": {} } ``` ## limits.delete Remove specific limits from an account. A removed limit is not replaced by its default: the account has no limit in that category for that period until you set one again with [limits.set](#limits-set). To restore the system defaults, use [limits.resetToDefaults](#limits-resettodefaults), which replaces all of the account's limits. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/limits.delete \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{ "accountId": "f9b4346a-...", "limits": [ {"category": "stMessages", "period": "daily"}, {"category": "stConnectionRequests", "period": "weekly"} ] }' ``` ```typescript tab=Node await admin.limits.delete({ accountId: 'f9b4346a-...', limits: [ { category: 'stMessages', period: 'daily' }, { category: 'stConnectionRequests', period: 'weekly' }, ], }); ``` ```python tab=Python from linkedapi import DeleteLimitEntry, DeleteLimitsParams admin.limits.delete( DeleteLimitsParams( account_id="f9b4346a-...", limits=[ DeleteLimitEntry(category="stMessages", period="daily"), DeleteLimitEntry(category="stConnectionRequests", period="weekly"), ], ) ) ``` ```bash tab=CLI linkedin admin limits delete f9b4346a-... --category stMessages --period daily linkedin admin limits delete f9b4346a-... --category stConnectionRequests --period weekly ``` :::surface cli `linkedin admin limits delete ` removes one limit per call, named by `--category` and `--period`, both required. ::: **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | Account UUID | | `limits` | `array` | Yes | Array of limits to remove | | `limits[].category` | `string` | Yes | Limit category | | `limits[].period` | `string` | Yes | Limit period | **Response:** ```json { "success": true, "result": {} } ``` ## limits.resetToDefaults Reset all limits for an account to the system defaults. ```bash tab=HTTP curl -X POST https://api.linkedapi.io/admin/limits.resetToDefaults \ -H "Content-Type: application/json" \ -H "linked-api-token: linked_your_token_here" \ -d '{"accountId": "f9b4346a-..."}' ``` ```typescript tab=Node await admin.limits.resetToDefaults({ accountId: 'f9b4346a-...' }); ``` ```python tab=Python from linkedapi import ResetLimitsParams admin.limits.reset_to_defaults(ResetLimitsParams(account_id="f9b4346a-...")) ``` ```bash tab=CLI linkedin admin limits reset f9b4346a-... ``` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | `string` | Yes | Account UUID | **Response:** ```json { "success": true, "result": {} } ``` --- # SDKs (Node.js and Python) ## Installation, authorization This page covers the installation and authorization process for Linked API SDKs. ## Installation To install the SDK, run following command for your package manager: ```bash lang=ts npm install -S @linkedapi/node ``` ```bash lang=python pip install linkedapi ``` ## Authorization To authorize your requests, you need to initialize the SDK with 2 tokens: ```typescript import LinkedApi from "@linkedapi/node"; const linkedapi = new LinkedApi({ linkedApiToken: "your-linked-api-token", identificationToken: "your-identification-token", }); ``` ```python from linkedapi import LinkedApi, LinkedApiConfig linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) ``` - `linkedApiToken` – your main token that enables overall Linked API access. - `identificationToken` – unique token specific to each managed LinkedIn account. You can obtain these tokens through [our platform](https://app.linkedapi.io/), as demonstrated below: ![](/images/docs/tokens-3.webp) ### Admin API To manage your subscription, accounts, and limits programmatically, use the separate `LinkedApiAdmin` class. It requires only the `linkedApiToken`: ```typescript import { LinkedApiAdmin } from '@linkedapi/node'; const admin = new LinkedApiAdmin({ linkedApiToken: 'your-linked-api-token', }); const status = await admin.subscription.getStatus(); const { accounts } = await admin.accounts.getAll(); ``` ```python from linkedapi import LinkedApiAdmin, AdminConfig admin = LinkedApiAdmin( AdminConfig(linked_api_token="your-linked-api-token") ) status = admin.subscription.get_status() accounts = admin.accounts.get_all().accounts ``` Every admin endpoint, with its call in the Node and Python tabs, is in the [Admin API](/docs/admin-overview?surface=sdk#endpoints). ### Multiple LinkedIn accounts If you need to manage **multiple LinkedIn accounts**, simply initialize a separate SDK instance for each account's identification token. ```typescript import LinkedApi from "@linkedapi/node"; const firstLinkedInAccount = new LinkedApi({ linkedApiToken: "your-linked-api-token", identificationToken: "your-first-identification-token", }); const secondLinkedInAccount = new LinkedApi({ linkedApiToken: "your-linked-api-token", identificationToken: "your-second-identification-token", }); ``` ```python from linkedapi import LinkedApi, LinkedApiConfig first_linkedin_account = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-first-identification-token", ) ) second_linkedin_account = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-second-identification-token", ) ) ``` ## Predefined vs. custom workflows There are two primary ways to execute automations with the SDKs: using **predefined methods** or building your own **custom workflows**. ## Predefined workflows Predefined workflows are the SDK's standard, ready-to-use methods, such as `sendMessage` or `fetchPerson`. Each of these helper methods automatically creates and runs a corresponding, pre-configured workflow. > Every predefined method, with the action it runs, is in the [Methods index](/sdks/methods). ### When to use them You should always start with predefined workflows. They are the fastest way to integrate Linked API because they are **strongly typed**. Both the input parameters and the result objects have defined types, which provides auto-completion in your IDE and helps prevent errors. As long as a predefined method meets your needs, it's the recommended approach. ```typescript // Predefined method to send a message const sendMessageWorkflow = await linkedapi.sendMessage.execute({ personUrl: "https://www.linkedin.com/in/john-doe", text: "Hello! I saw your post about AI and wanted to connect.", }); const sendMessageResult = await linkedapi.sendMessage.result(sendMessageWorkflow.workflowId); // Predefined method to check connection status const connectionStatusWorkflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/john-doe", }); const connectionStatusResult = await linkedapi.checkConnectionStatus.result(connectionStatusWorkflow.workflowId); ``` ```python from linkedapi import CheckConnectionStatusParams, SendMessageParams # Predefined method to send a message send_message_workflow = linkedapi.send_message.execute( SendMessageParams( person_url="https://www.linkedin.com/in/john-doe", text="Hello! I saw your post about AI and wanted to connect.", ) ) send_message_result = linkedapi.send_message.result(send_message_workflow.workflow_id) # Predefined method to check connection status connection_status_workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/john-doe") ) connection_status_result = linkedapi.check_connection_status.result( connection_status_workflow.workflow_id ) ``` ## Custom workflows For tasks requiring more flexibility or complex, multi-step logic, you can build a custom workflow. This approach allows you to directly interact with the Linked API workflow engine by constructing an **object that defines the workflow's steps and logic**. You are responsible for correctly structuring the **workflow definition object** and then parsing the resulting **completion object** returned upon execution. The [building workflows](/docs/building-workflows) page provides the complete specification for both of these object structures. ### When to use them Switch to custom workflows when the predefined methods are not sufficient for your use case. For example: ```typescript // Custom workflow to find managers at a company, // retrieve their education, and send connection requests const customWorkflow = await linkedapi.customWorkflow.execute({ "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "basicInfo": true, "then": { "actionType": "st.retrieveCompanyEmployees", "filter": { "position": "Manager" }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": true, "then": [ { "actionType": "st.retrievePersonEducation" }, { "actionType": "st.sendConnectionRequest" } ] } } } }); const customWorkflowResult = await linkedapi.customWorkflow.result(customWorkflow.workflowId); ``` ```python # Custom workflow to find managers at a company, # retrieve their education, and send connection requests custom_workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openCompanyPage", "companyUrl": "https://www.linkedin.com/company/techcorp", "basicInfo": True, "then": { "actionType": "st.retrieveCompanyEmployees", "filter": { "position": "Manager", }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.openPersonPage", "basicInfo": True, "then": [ { "actionType": "st.retrievePersonEducation", }, { "actionType": "st.sendConnectionRequest", }, ], }, }, }, } ) custom_workflow_result = linkedapi.custom_workflow.result(custom_workflow.workflow_id) ``` ## customWorkflow This method allows you to execute a custom Linked API workflow using a raw workflow definition for complex multi-step operations. ```typescript import { LinkedApiError } from '@linkedapi/node'; try { const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.searchCompanies", term: "AI startup", filter: { locations: ["San Francisco", "New York"], sizes: ["11-50", "51-200"] }, limit: 10, then: { actionType: "st.doForCompanies", then: { actionType: "st.openCompanyPage", basicInfo: true, then: { actionType: "st.retrieveCompanyEmployees", limit: 5, filter: { position: "manager" }, then: { actionType: "st.doForPeople", then: { actionType: "st.sendConnectionRequest", note: "Hi! I'd love to connect and discuss opportunities." } } } } } }); const { data } = await linkedapi.customWorkflow.result(workflow.workflowId); console.log('Custom workflow executed successfully.'); console.log('Workflow result:', data); } catch (e) { // A list of all critical errors can be found here: // https://linkedapi.io/sdks/handling-results-and-errors/#handling-critical-errors if (e instanceof LinkedApiError) { console.error(`Critical Error - Type: ${e.type}, Message: ${e.message}`); } else { console.error('An unexpected, non-API error occurred:', e); } } ``` ```python from linkedapi import LinkedApiError try: workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchCompanies", "term": "AI startup", "filter": { "locations": ["San Francisco", "New York"], "sizes": ["11-50", "51-200"], }, "limit": 10, "then": { "actionType": "st.doForCompanies", "then": { "actionType": "st.openCompanyPage", "basicInfo": True, "then": { "actionType": "st.retrieveCompanyEmployees", "limit": 5, "filter": { "position": "manager", }, "then": { "actionType": "st.doForPeople", "then": { "actionType": "st.sendConnectionRequest", "note": "Hi! I'd love to connect and discuss opportunities.", }, }, }, }, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) data = result.data print("Custom workflow executed successfully.") print("Workflow result:", data) except LinkedApiError as e: # A list of all critical errors can be found here: # https://linkedapi.io/sdks/handling-results-and-errors/#handling-critical-errors print(f"Critical Error - Type: {e.type}, Message: {e.message}") except Exception as error: print("An unexpected, non-API error occurred:", error) ``` ## Params Pass a workflow definition object that follows our [HTTP API workflow format](/docs/building-workflows). ## Data Completion object that matches our [HTTP API completion format](/docs/executing-workflows). ## Errors The method has no execution errors (`errors` is always `[]`). ## Iterating over post comments Reacting to a single comment is available directly via [`reactToComment`](/docs/action-st-react-to-comment?surface=sdk) and [`replyToComment`](/docs/action-st-reply-to-comment?surface=sdk). To react or reply to **every** comment on a post without knowing their URLs upfront, use a custom workflow: retrieve the comments with `st.retrievePostComments`, iterate over them with `st.doForComments`, and either react/reply directly or open each comment first with `st.openComment` to run several comment-scoped actions. ```typescript const workflow = await linkedapi.customWorkflow.execute({ actionType: "st.retrievePostComments", postUrl: "https://www.linkedin.com/posts/username_activity-id", limit: 3, sort: "mostRelevant", then: { actionType: "st.doForComments", then: { actionType: "st.openComment", then: [ { actionType: "st.reactToComment", type: "celebrate" }, { actionType: "st.replyToComment", text: "Great point, thanks for sharing!" } ] } } }); ``` ```python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.retrievePostComments", "postUrl": "https://www.linkedin.com/posts/username_activity-id", "limit": 3, "sort": "mostRelevant", "then": { "actionType": "st.doForComments", "then": { "actionType": "st.openComment", "then": [ {"actionType": "st.reactToComment", "type": "celebrate"}, {"actionType": "st.replyToComment", "text": "Great point, thanks for sharing!"}, ], }, }, } ) ``` > The `st.openComment` wrapper is optional — `st.reactToComment` and `st.replyToComment` can be nested directly under `st.doForComments`. Similarly, `st.doForPosts` accepts `st.reactToPost`, `st.commentOnPost`, `st.retrievePostComments`, and `st.retrievePostReactions` directly, so you can act on each iterated post without wrapping it in `st.openPost`. ## Handling results and errors When you initiate any workflow, the `execute()` method immediately returns workflow tracking details: - `workflowId` – unique workflow identifier. - `workflowStatus` – initial workflow status, either `pending` or `running`. - `pendingReason` – why the workflow has not started yet, when it is pending. See [working hours](/docs/working-hours). - `message` – human-readable queue or duration message, when available. You can then use the `result()` method with this ID to get the workflow results when it's complete. ```typescript const workflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/john-doe" }); const result = await linkedapi.checkConnectionStatus.result(workflow.workflowId); ``` ```python from linkedapi import CheckConnectionStatusParams workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/john-doe") ) result = linkedapi.check_connection_status.result(workflow.workflow_id) ``` The structure of the object returned by `result()` depends on whether you use a [predefined workflow](/sdks/predefined-vs-custom) or a [custom workflow](/sdks/predefined-vs-custom). ## For predefined workflows When using a standard, predefined method, `result()` returns a convenient object with two key fields: - `data` – typed object containing the successful result of the workflow. - `errors` – array of non-critical **workflow execution errors**, that may have occurred during the run, but did not stop the entire workflow. Each error object contains `type` and `message` fields. ```typescript const workflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/john-doe" }); const result = await linkedapi.checkConnectionStatus.result(workflow.workflowId); const data = result.data; const errors = result.errors; ``` ```python from linkedapi import CheckConnectionStatusParams workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/john-doe") ) result = linkedapi.check_connection_status.result(workflow.workflow_id) data = result.data errors = result.errors ``` > For each predefined method, the structure of its `data` and the possible `errors` are on its action page in the API reference – see the [Methods index](/sdks/methods). ## For custom workflows When using `customWorkflow`, `result()` returns the raw **completion object** in `data`, unchanged, and `errors` stays empty. The completion contains the full execution context of the workflow, including the errors of failed actions, and you are responsible for parsing it according to the specification on the [building workflows](/docs/building-workflows) page. ```typescript const workflow = await linkedapi.customWorkflow.execute({ "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/john-doe", "basicInfo": true, "then": { "actionType": "st.checkConnectionStatus", } }); const { data: workflowCompletion } = await linkedapi.customWorkflow.result(workflow.workflowId); ``` ```python workflow = linkedapi.custom_workflow.execute( { "actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/john-doe", "basicInfo": True, "then": { "actionType": "st.checkConnectionStatus", }, } ) workflow_completion = linkedapi.custom_workflow.result(workflow.workflow_id).data ``` ## Checking status without waiting Use `status()` when you want to check a workflow once without waiting for it to finish. If the workflow is still `pending` or `running`, the SDK returns its current `workflowStatus`, its `pendingReason` and, when provided, `message`. The message can change between polling requests, for example when a workflow moves from `pending` to `running`. ```typescript const workflowId = "YOUR_WORKFLOW_ID"; const status = await linkedapi.checkConnectionStatus.status(workflowId); if ('workflowStatus' in status) { showProgress(status.workflowStatus, status.message); } ``` ```python from linkedapi import WorkflowInProgressResponse workflow_id = "YOUR_WORKFLOW_ID" status = linkedapi.check_connection_status.status(workflow_id) if isinstance(status, WorkflowInProgressResponse): show_progress(status.workflow_status, status.message) ``` ### Pending reasons A pending workflow is not always simply queued. `pendingReason` tells the two cases apart: - `queued` – waiting its turn behind other work on the same account. Minutes, typically. - `outsideWorkingHours` – parked until the account [working hours](/docs/working-hours) reopen, which can be the next working day. - `null` – the workflow is not pending. Back off your polling when the reason is `outsideWorkingHours`; the `message` names the time the window opens. ```typescript import { LINKED_API_WORKFLOW_PENDING_REASON } from '@linkedapi/node'; const status = await linkedapi.checkConnectionStatus.status(workflowId); if ('workflowStatus' in status) { if (status.pendingReason === LINKED_API_WORKFLOW_PENDING_REASON.outsideWorkingHours) { notifyUser(status.message); } else { scheduleNextPoll(); } } ``` ```python from linkedapi import WorkflowInProgressResponse status = linkedapi.check_connection_status.status(workflow_id) if isinstance(status, WorkflowInProgressResponse): if status.pending_reason == "outsideWorkingHours": notify_user(status.message) else: schedule_next_poll() ``` Two error types relate to working hours as well: - `outsideWorkingHours` – raised on submission when the account rejects off-hours requests. - `workingHoursWaitExpired` – raised as a workflow failure when a parked workflow was never started within the wait limit. ## Receiving results via webhooks Instead of polling `result()` or `status()`, you can register a webhook and let Linked API notify your endpoint when a workflow completes – and when a connected account changes status. This is the better fit for long-running workflows and for reacting to account events (like a reconnection becoming required) that no single `result()` call would surface. Each delivery is an HTTP `POST` with a typed event in its body. Parse the raw request body with `parseWebhookEvent` / `parse_webhook_event`, then branch on the event's `type`: ```typescript import { parseWebhookEvent } from '@linkedapi/node'; const event = parseWebhookEvent(rawRequestBody); if (event.type === 'workflow.completed') { console.log(event.data.workflowId, event.data.status, event.data.result); } ``` ```python from linkedapi import WorkflowWebhookEvent, parse_webhook_event event = parse_webhook_event(raw_request_body) if isinstance(event, WorkflowWebhookEvent) and event.type == "workflow.completed": print(event.data.workflow_id, event.data.status, event.data.result) ``` `workflow.completed` carries the full workflow result in `fat` payload mode; in `thin` mode fetch it with `result()` using `data.workflowId`. Deliveries are at-least-once, so deduplicate on the event `id`. See [Webhook Events](/docs/webhooks) for the full event model, and [Webhooks](/docs/webhooks?surface=sdk) to register and manage one with the SDK. ## Handling critical errors Separate from the non-critical `errors` array [returned by pre-defined workflows](#for-predefined-workflows), the SDK can throw **critical errors**. These are exceptions that immediately stop execution and indicate a fundamental problem with the request or your account. These exceptions, always of the type `LinkedApiError`, can be thrown when calling `execute()` to initiate a workflow or when calling `result()` to get the results. You must handle them using a `try...catch` block to inspect the error's `type` field and determine the cause. ```typescript import { LINKED_API_ERROR, LinkedApiError } from '@linkedapi/node'; try { const workflow = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/john-doe" }); const { data, errors } = await linkedapi.checkConnectionStatus.result(workflow.workflowId); // 1. Handling non-critical execution errors if (errors && errors.length > 0) { console.warn('Workflow completed with execution errors:', errors); } // Handling the successful result if (data) { console.log('Connection Status:', data.connectionStatus); } } catch (e) { // 2. Handling critical errors (exceptions) if (e instanceof LinkedApiError) { if (e.type === LINKED_API_ERROR.invalidLinkedApiToken) { console.error('Authentication failed. Please check your API token.'); } else if (e.type === LINKED_API_ERROR.subscriptionRequired) { console.error('This action needs an active subscription:', e.message); } else { // Handle other types of critical API errors console.error(`A different API error occurred: ${e.type}`); } } else { // Handle other unexpected errors (e.g., network issues) console.error('An unexpected, non-API error occurred:', e); } } ``` ```python from linkedapi import CheckConnectionStatusParams, LinkedApiError try: workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/john-doe") ) result = linkedapi.check_connection_status.result(workflow.workflow_id) data = result.data errors = result.errors # 1. Handling non-critical execution errors if errors: print("Workflow completed with execution errors:", errors) # Handling the successful result if data: print("Connection Status:", data.connection_status) except LinkedApiError as e: # 2. Handling critical errors (exceptions) if e.type == "invalidLinkedApiToken": print("Authentication failed. Please check your API token.") elif e.type == "subscriptionRequired": print("This action needs an active subscription:", e.message) else: print(f"A different API error occurred: {e.type}") except Exception as error: # Handle other unexpected errors (e.g., network issues) print("An unexpected, non-API error occurred:", error) ``` **Here are all possible critical errors:** - `invalidLinkedApiToken` – the provided `linked-api-token` is invalid. - `invalidIdentificationToken` – the provided `identification-token` is invalid. - `subscriptionRequired` – no purchased subscription seats available for this LinkedIn account. - `plusPlanRequired` – to execute this workflow you need the [Plus plan](/pricing). - `invalidWorkflow` – workflow configuration is not valid due to violated [action constraints](/docs/actions-overview) or invalid [action parameters](/docs/actions-overview), including a parameter the root action does not define: {validation_details}. Predefined methods throw it too when a parameter fails validation, for example a URL that is not a LinkedIn URL or an empty `skillNamePatterns`. - `invalidRequestPayload` – invalid request body/parameters: {validation_details}. - `tooManyRequests` – too many requests have been made in the last minute. - `linkedinAccountSignedOut` – your LinkedIn account has been signed out in our cloud browser. This occasionally happens as LinkedIn may sign out accounts after an extended period. You'll need to visit [our platform](https://app.linkedapi.io/) and reconnect your account. - `languageNotSupported` – your LinkedIn account uses a language other than English, which is currently the only supported option. If you encounter this issue, please contact our support. We prioritize adding new languages based on user requests, so your feedback is important. ## Persisting and cancelling workflows This page explains how to persist a workflow for later restoration and how to cancel its execution while it is running. ## Persisting workflows You can save a workflow ID to retrieve its result later. This is especially useful for long-running automations that might be interrupted by an application restart. The process involves these steps: 1. When you initiate a workflow with `execute()`, save the returned `workflowId`. 2. After a restart, retrieve the saved `workflowId`. 3. Call the `result()` method on the same SDK method, passing the saved `workflowId` to get the result. ```typescript // --- Step 1 & 2: Initiate a workflow and save its references --- async function startAndSaveWorkflow(taskId) { try { const { workflowId } = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/john-doe" }); // Save the workflow ID to your database await db.saveTask({ taskId: taskId, workflowId: workflowId }); console.log(`Workflow ${workflowId} started and saved for task ${taskId}.`); } catch (e) { console.error('Failed to start workflow:', e.message); } } // --- Step 3 & 4: Restore the workflow and get the result --- async function restoreAndGetResult(taskId) { try { // Retrieve the saved workflow ID from your database const task = await db.getTask(taskId); if (!task || !task.workflowId) { console.log(`No pending workflow found for task ${taskId}.`); return; } // Get the result using the saved workflow ID // You call the same method you used to start the workflow const { data, errors } = await linkedapi.checkConnectionStatus.result(task.workflowId); if (data) { console.log(`Task ${taskId} complete. Result:`, data); await db.updateTaskStatus(taskId, 'complete'); } } catch (e) { console.error(`Failed to get result for task ${taskId}:`, e.message); await db.updateTaskStatus(taskId, 'failed'); } } ``` ```python from linkedapi import CheckConnectionStatusParams, LinkedApiError # --- Step 1 & 2: Initiate a workflow and save its references --- def start_and_save_workflow(task_id): try: workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/john-doe") ) # Save the workflow ID to your database db.save_task(task_id=task_id, workflow_id=workflow.workflow_id) print(f"Workflow {workflow.workflow_id} started and saved for task {task_id}.") except LinkedApiError as e: print("Failed to start workflow:", e.message) # --- Step 3 & 4: Restore the workflow and get the result --- def restore_and_get_result(task_id): try: # Retrieve the saved workflow ID from your database task = db.get_task(task_id) if not task or not task.workflow_id: print(f"No pending workflow found for task {task_id}.") return # Get the result using the saved workflow ID # You call the same method you used to start the workflow result = linkedapi.check_connection_status.result(task.workflow_id) if result.data: print(f"Task {task_id} complete. Result:", result.data) db.update_task_status(task_id, "complete") except LinkedApiError as e: print(f"Failed to get result for task {task_id}:", e.message) db.update_task_status(task_id, "failed") ``` ## Cancelling workflows You can cancel any running workflow when you no longer need its results. This is useful for stopping long-running automations or when conditions in your application change. To cancel a workflow, call the `cancel()` function with the `workflowId`: ```typescript // --- Start a workflow and then cancel it --- async function startAndCancelWorkflow() { try { // Step 1: Initiate the workflow const { workflowId } = await linkedapi.checkConnectionStatus.execute({ personUrl: "https://www.linkedin.com/in/john-doe" }); console.log(`Workflow ${workflowId} started.`); // Step 2: Cancel the workflow using its ID // You call cancel() on the same method you used to start the workflow const cancelled = await linkedapi.checkConnectionStatus.cancel(workflowId); if (cancelled) { console.log(`Workflow ${workflowId} cancelled successfully.`); } } catch (e) { console.error('Failed to process workflow:', (e as Error).message); } } ``` ```python from linkedapi import CheckConnectionStatusParams, LinkedApiError # --- Start a workflow and then cancel it --- def start_and_cancel_workflow(): try: # Step 1: Initiate the workflow workflow = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url="https://www.linkedin.com/in/john-doe") ) print(f"Workflow {workflow.workflow_id} started.") # Step 2: Cancel the workflow using its ID # You call cancel() on the same method you used to start the workflow cancelled = linkedapi.check_connection_status.cancel(workflow.workflow_id) if cancelled: print(f"Workflow {workflow.workflow_id} cancelled successfully.") except LinkedApiError as e: print("Failed to process workflow:", e.message) ``` **Important notes:** - **Partial execution.** The workflow is cancelled at its current execution point. Any actions that have already been completed cannot be undone. For example, if the workflow has already sent messages or connection requests from your LinkedIn account, these actions will remain executed. - **No intermediate data.** Once a workflow is cancelled, no data is preserved or returned. You will not receive any intermediate results or partial data that may have been collected before cancellation. ## getChangelog This function reads the public [Linked API changelog](/changelog): what was released and when. Use it to learn what is new since you last checked, or whether something you need has shipped. It is exported from the package root and needs no client and no token. ```typescript import { getChangelog } from '@linkedapi/node'; try { // Releases for the week of October 1, 2026 and later const changelog = await getChangelog({ since: '2026-10-01' }); for (const release of changelog.entries) { console.log(release.date); for (const item of release.items) { console.log(` ${item.title}: https://linkedapi.io${item.docs}`); } } } catch (e) { // A plain Error carrying the server's message, e.g. for an invalid since console.error('Could not read the changelog:', e); } ``` ```python from linkedapi import LinkedApiError, get_changelog try: # Releases for the week of October 1, 2026 and later changelog = get_changelog("2026-10-01") for release in changelog.entries: print(release.date) for item in release.items: print(f" {item.title}: https://linkedapi.io{item.docs}") except LinkedApiError as e: print(f"Could not read the changelog: {e.message}") ``` ## Params - `since` – optional. Return only the releases for the week containing this point and later. Either an ISO 8601 date, such as `2026-10-01`, or an instant with a time zone, such as `2026-10-01T12:00:00Z`. Node also accepts a `Date`; Python accepts a `date` or a timezone-aware `datetime`. Omit it to get the whole changelog. In Node, `since` is a field of the options object; in Python, it is the first argument. ## Data - `since` – the `since` you passed, or `null` when you passed none. - `count` – the number of releases returned. - `entries` – the releases, newest first. Each has: - `date` – the Monday of the release's week, as `YYYY-MM-DD`; - `items` – what shipped, each with a `title`, a `body` and `docs`, the path of its documentation on `https://linkedapi.io`. ## Releases are weekly A release covers a week and is dated by its Monday. It counts as shipped at the end of that week, so `since` returns every release whose week ends after it – including the week `since` falls in, even when its date is a few days earlier. That way a check in the middle of a week never misses what ships later the same week. The price is that a later check in the same week returns that release again, and items can be added to it during its week. Deduplicate releases by `date`, and keep the copy you received last. ## Errors - **Node** – throws a plain `Error`, not a `LinkedApiError`, with the server's message when the response is not `2xx` – for example, an invalid `since`. A network failure is thrown as the underlying `fetch` error. - **Python** – raises a `LinkedApiError` of type `httpError`, both for a non-`2xx` response, with the server's message, and for a network failure. The same changelog is available from the terminal with [`linkedin changelog`](/cli/getting-started#changelog) and to AI agents through the MCP tool [`get_changelog`](/mcp/available-tools#changelog). ## Methods Each SDK method runs one API action or reads one endpoint. Its page in the API reference shows the call in the Node and Python tabs, with the parameters, the result and the errors. Methods without their own row run any workflow through [`customWorkflow`](/sdks/custom-workflow). ## Messages | Method | Runs | | --- | --- | | `sendMessage` | [`st.sendMessage`](/docs/action-st-send-message?surface=sdk) | | `syncInbox` | [`st.syncInbox`](/docs/action-st-sync-inbox?surface=sdk) | | `pollInbox` | [`POST /inbox/poll`](/docs/monitoring-inbox?surface=sdk) | | `syncConversation` | [`st.syncConversation`](/docs/action-st-sync-conversation?surface=sdk) | | `manageConversation` | [`st.manageConversation`](/docs/action-st-manage-conversation?surface=sdk) | ## Connections | Method | Runs | | --- | --- | | `checkConnectionStatus` | [`st.checkConnectionStatus`](/docs/action-st-check-connection-status?surface=sdk) | | `sendConnectionRequest` | [`st.sendConnectionRequest`](/docs/action-st-send-connection-request?surface=sdk) | | `withdrawConnectionRequest` | [`st.withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request?surface=sdk) | | `acceptInvitation` | [`st.acceptInvitation`](/docs/action-st-accept-invitation?surface=sdk) | | `ignoreInvitation` | [`st.ignoreInvitation`](/docs/action-st-ignore-invitation?surface=sdk) | | `retrievePendingRequests` | [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests?surface=sdk) | | `retrieveInvitations` | [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations?surface=sdk) | | `retrieveConnections` | [`st.retrieveConnections`](/docs/action-st-retrieve-connections?surface=sdk) | | `removeConnection` | [`st.removeConnection`](/docs/action-st-remove-connection?surface=sdk) | | `syncNetwork` | [`st.syncNetwork`](/docs/action-st-sync-network?surface=sdk) | | `pollNetwork` | [`POST /network/poll`](/docs/monitoring-network?surface=sdk) | ## People | Method | Runs | | --- | --- | | `searchPeople` | [`st.searchPeople`](/docs/action-st-search-people?surface=sdk) | | `fetchPerson` | [`st.openPersonPage`](/docs/action-st-open-person-page?surface=sdk) | | `endorsePersonSkill` | [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill?surface=sdk) | | `endorsePersonSkills` | [`st.doForSkills`](/docs/action-st-do-for-skills?surface=sdk) with [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill?surface=sdk) – see [Endorsing skills](/docs/endorsing-skills?surface=sdk) | ## Companies | Method | Runs | | --- | --- | | `searchCompanies` | [`st.searchCompanies`](/docs/action-st-search-companies?surface=sdk) | | `fetchCompany` | [`st.openCompanyPage`](/docs/action-st-open-company-page?surface=sdk) | ## Posts and Comments | Method | Runs | | --- | --- | | `searchPosts` | [`st.searchPosts`](/docs/action-st-search-posts?surface=sdk) | | `fetchPost` | [`st.openPost`](/docs/action-st-open-post?surface=sdk) | | `createPost` | [`st.createPost`](/docs/action-st-create-post?surface=sdk) | | `createRepost` | [`st.createRepost`](/docs/action-st-create-repost?surface=sdk) | | `reactToPost` | [`st.reactToPost`](/docs/action-st-react-to-post?surface=sdk) | | `commentOnPost` | [`st.commentOnPost`](/docs/action-st-comment-on-post?surface=sdk) | | `reactToComment` | [`st.reactToComment`](/docs/action-st-react-to-comment?surface=sdk) | | `replyToComment` | [`st.replyToComment`](/docs/action-st-reply-to-comment?surface=sdk) | ## Jobs | Method | Runs | | --- | --- | | `searchJobs` | [`st.searchJobs`](/docs/action-st-search-jobs?surface=sdk) | | `fetchJob` | [`st.openJob`](/docs/action-st-open-job?surface=sdk) | ## Your Account | Method | Runs | | --- | --- | | `retrieveSSI` | [`st.retrieveSSI`](/docs/action-st-retrieve-ssi?surface=sdk) | | `retrievePerformance` | [`st.retrievePerformance`](/docs/action-st-retrieve-performance?surface=sdk) | | `retrieveFeed` | [`st.retrieveFeed`](/docs/action-st-retrieve-feed?surface=sdk) | | `retrieveProfileViewers` | [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers?surface=sdk) | | `getApiUsage` | [`GET /stats/actions`](/docs/checking-api-usage-statistics?surface=sdk) | | `getAccountInfo` | [`GET /account`](/docs/making-requests?surface=sdk#checking-your-tokens) | ## Sales Navigator | Method | Runs | | --- | --- | | `nvSendMessage` | [`nv.sendMessage`](/docs/action-nv-send-message?surface=sdk) | | `nvSyncInbox` | [`nv.syncInbox`](/docs/action-nv-sync-inbox?surface=sdk) | | `nvSyncConversation` | [`nv.syncConversation`](/docs/action-nv-sync-conversation?surface=sdk) | | `nvManageConversation` | [`nv.manageConversation`](/docs/action-nv-manage-conversation?surface=sdk) | | `nvSearchPeople` | [`nv.searchPeople`](/docs/action-nv-search-people?surface=sdk) | | `nvFetchPerson` | [`nv.openPersonPage`](/docs/action-nv-open-person-page?surface=sdk) | | `nvSearchCompanies` | [`nv.searchCompanies`](/docs/action-nv-search-companies?surface=sdk) | | `nvFetchCompany` | [`nv.openCompanyPage`](/docs/action-nv-open-company-page?surface=sdk) | ## Admin API The admin client, `LinkedApiAdmin`, is created with your Linked API token only; see [Authentication](/docs/admin-overview?surface=sdk#authentication). | Method | Runs | | --- | --- | | `admin.subscription.getStatus` | [`subscription.getStatus`](/docs/admin-subscription?surface=sdk#subscription-getstatus) | | `admin.subscription.getSeats` | [`subscription.getSeats`](/docs/admin-subscription?surface=sdk#subscription-getseats) | | `admin.subscription.setSeats` | [`subscription.setSeats`](/docs/admin-subscription?surface=sdk#subscription-setseats) | | `admin.accounts.getAll` | [`accounts.getAll`](/docs/admin-accounts?surface=sdk#accounts-getall) | | `admin.accounts.reparseAccountInfo` | [`accounts.reparseAccountInfo`](/docs/admin-accounts?surface=sdk#accounts-reparseaccountinfo) | | `admin.accounts.disconnect` | [`accounts.disconnect`](/docs/admin-accounts?surface=sdk#accounts-disconnect) | | `admin.accounts.regenerateIdentificationToken` | [`accounts.regenerateIdentificationToken`](/docs/admin-accounts?surface=sdk#accounts-regenerateidentificationtoken) | | `admin.accounts.createConnectionSession` | [`accounts.createConnectionSession`](/docs/admin-connection-sessions?surface=sdk#accounts-createconnectionsession) | | `admin.accounts.createReconnectionSession` | [`accounts.createReconnectionSession`](/docs/admin-connection-sessions?surface=sdk#accounts-createreconnectionsession) | | `admin.accounts.getConnectionSession` | [`accounts.getConnectionSession`](/docs/admin-connection-sessions?surface=sdk#accounts-getconnectionsession) | | `admin.accounts.cancelConnectionSession` | [`accounts.cancelConnectionSession`](/docs/admin-connection-sessions?surface=sdk#accounts-cancelconnectionsession) | | `admin.limits.getDefaults` | [`limits.getDefaults`](/docs/admin-limits?surface=sdk#limits-getdefaults) | | `admin.limits.get` | [`limits.get`](/docs/admin-limits?surface=sdk#limits-get) | | `admin.limits.getUsage` | [`limits.getUsage`](/docs/admin-limits?surface=sdk#limits-getusage) | | `admin.limits.set` | [`limits.set`](/docs/admin-limits?surface=sdk#limits-set) | | `admin.limits.delete` | [`limits.delete`](/docs/admin-limits?surface=sdk#limits-delete) | | `admin.limits.resetToDefaults` | [`limits.resetToDefaults`](/docs/admin-limits?surface=sdk#limits-resettodefaults) | ## Webhooks | Method | Runs | | --- | --- | | `admin.webhooks.set` | [`webhook.set`](/docs/admin-webhooks?surface=sdk#webhook-set) | | `admin.webhooks.get` | [`webhook.get`](/docs/admin-webhooks?surface=sdk#webhook-get) | | `admin.webhooks.setPayloadMode` | [`webhook.setPayloadMode`](/docs/admin-webhooks?surface=sdk#webhook-setpayloadmode) | | `admin.webhooks.setEvents` | [`webhook.setEvents`](/docs/admin-webhooks?surface=sdk#webhook-setevents) | | `admin.webhooks.eventTypes` | [`webhook.eventTypes`](/docs/admin-webhooks?surface=sdk#webhook-eventtypes) | | `admin.webhooks.setSigning` | [`webhook.setSigning`](/docs/admin-webhooks?surface=sdk#webhook-setsigning) | | `admin.webhooks.setHeader` | [`webhook.setHeader`](/docs/admin-webhooks?surface=sdk#webhook-setheader) | | `admin.webhooks.deleteHeader` | [`webhook.deleteHeader`](/docs/admin-webhooks?surface=sdk#webhook-deleteheader) | | `admin.webhooks.setHeaders` | [`webhook.setHeaders`](/docs/admin-webhooks?surface=sdk#webhook-setheaders) | | `admin.webhooks.revealSecret` | [`webhook.revealSecret`](/docs/admin-webhooks?surface=sdk#webhook-revealsecret) | | `admin.webhooks.rotateSecret` | [`webhook.rotateSecret`](/docs/admin-webhooks?surface=sdk#webhook-rotatesecret) | | `admin.webhooks.delete` | [`webhook.delete`](/docs/admin-webhooks?surface=sdk#webhook-delete) | | `admin.webhooks.deliveries` | [`webhook.deliveries`](/docs/admin-webhooks?surface=sdk#webhook-deliveries) | | `admin.webhooks.replayDelivery` | [`webhook.replayDelivery`](/docs/admin-webhooks?surface=sdk#webhook-replaydelivery) | | `admin.webhooks.sendTest` | [`webhook.sendTest`](/docs/admin-webhooks?surface=sdk#webhook-sendtest) | --- # CLI ## LinkedIn CLI LinkedIn CLI gives AI agents and developers full LinkedIn capabilities through simple shell commands. Built for Claude Code, Cursor, Codex, and any AI agent that can execute shell commands. > Source code available on [GitHub](https://github.com/Linked-API/linkedin-cli). After setup: every command is listed in [Commands](/cli/commands); global flags, the JSON output format and exit codes are in [Flags, Output and Exit Codes](/cli/flags-and-output); running any workflow JSON is in [Custom Workflows](/cli/workflows). How results represent LinkedIn data – UTC timestamps, normalized URLs, `null` for missing fields – is in [Data Conventions](/docs/data-conventions). ## Quick start ```bash # 1. Install npm install -g @linkedapi/linkedin-cli # 2. Save your tokens (get them at app.linkedapi.io) linkedin setup # 3. Fetch a profile linkedin person fetch https://www.linkedin.com/in/vprudnikoff --json # 4. Search for people linkedin person search --term "revops engineer" --current-companies "Linked API" --json # 5. Send a message linkedin message send https://www.linkedin.com/in/vprudnikoff "Hey, loved your latest post!" ``` ## Install ```bash npm install -g @linkedapi/linkedin-cli ``` ## Setup ```bash linkedin setup ``` The CLI will ask for your **Linked API Token** and **Identification Token**. Get them at [app.linkedapi.io](https://app.linkedapi.io). For non-interactive environments (CI, scripts): ```bash linkedin setup --linked-api-token=xxx --identification-token=yyy ``` | Flag | Description | | --- | --- | | `--linked-api-token` | Linked API Token (skips prompt) | | `--identification-token` | Identification Token (skips prompt) | ## Multiple accounts Run `linkedin setup` again with different tokens to add more accounts. The last added account becomes active. ```bash # List all accounts (* marks active) linkedin account list # Switch active account linkedin account switch "Vlad" # Update tokens for active account (e.g. after regenerating tokens on the dashboard) linkedin account update # Update tokens for a specific account linkedin account update "Vlad" # Rename a saved account linkedin account rename "Vlad" --name "My Work Account" # Use a specific account for one command linkedin person fetch https://www.linkedin.com/in/... --account "Vlad" # Remove active account (auto-switches to next) linkedin reset # Remove all accounts linkedin reset --all ``` ## Updating tokens If your tokens were regenerated on the [dashboard](https://app.linkedapi.io), update them in the CLI without removing the account: ```bash linkedin account update ``` This prompts for new tokens, verifies them, and updates the saved account in place. To update a specific account: ```bash linkedin account update "Account Name" ``` For non-interactive environments: ```bash linkedin account update --linked-api-token=xxx --identification-token=yyy ``` | Flag | Description | | --- | --- | | `--linked-api-token` | New Linked API Token (skips prompt) | | `--identification-token` | New Identification Token (skips prompt) | ## Changelog Read the public [Linked API changelog](/changelog) from the terminal. It needs no setup and no tokens. ```bash # Every release, newest first linkedin changelog # Only the releases for the week of a date and later linkedin changelog --since 2026-10-01 # The raw changelog response as JSON linkedin changelog --since 2026-10-01T12:00:00Z --json ``` In a terminal it prints each release date followed by its items, each with a title, a description and a link to its documentation. With `--json`, or when stdout is not a terminal, it prints the changelog response itself rather than the usual `success` envelope. `--since` takes an ISO date or an instant with a time zone; an invalid value exits with code `5` and the server's message. Releases are weekly, so `--since` also returns the release for the week it falls in – see [how releases are dated](/sdks/get-changelog#releases-are-weekly). ## Feedback Send a bug report, a feature request or praise straight to the Linked API team. It reaches the team within a minute. ```bash # Report a defect with the run that shows it linkedin feedback "person fetch returns an empty experience list for public profiles" \ --type bug --severity high --surface "person fetch" \ --workflow-id account-12345-abcde --error "unexpectedError" # Ask for something that does not exist yet linkedin feedback "would be great to filter connections by company size" --type feature ``` | Flag | Description | | --- | --- | | `--type` | `bug`, `feature`, `praise` or `other` (default: `other`) | | `--severity` | `low`, `medium` or `high` – how badly it blocks you | | `--surface` | The command the feedback is about, e.g. `person fetch` | | `--workflow-id` | The `workflowId` of the run the feedback is about | | `--operation-name` | The `operationName` of the failing call, e.g. `st.sendMessage` | | `--error` | The error message or unexpected payload, verbatim | | `--context` | Any extra detail as a JSON object, e.g. `'{"attempts": 3}'` – never tokens or other secrets | Attach `--workflow-id` and `--error` when the feedback is about a failed or wrong result: that lets the team replay the exact run. Token, authentication and subscription errors are account settings rather than defects – fix them on the dashboard. ## Flags, Output and Exit Codes `linkedin` commands share global flags, an output format and exit codes. This page describes all three, with the few commands that differ. ## Global flags Every command that calls Linked API supports these flags. The local commands `setup`, `reset` and `account …` print plain text and take none of them: `account list` ignores them, and the others reject them as unknown flags. | Flag | Description | | --- | --- | | `--json` | Structured JSON output to stdout | | `--fields name,url,...` | Select specific fields in output | | `--quiet` / `-q` | Suppress stderr progress | | `--no-color` | Disable colors | | `--account "Name"` | Use a specific account for this command. Admin commands do not take it – they use the current account's Linked API token; switch with `linkedin account switch` | ## Output format **JSON mode** (`--json` or non-TTY stdout): ```json {"success": true, "data": {"name": "Vlad Prudnikov", "headline": "CEO at Linked API"}} ``` ```json {"success": false, "error": {"type": "personNotFound", "message": "Person not found"}} ``` **Human mode** (TTY stdout): key-value pairs for objects, tables for arrays. Request errors – invalid tokens, rate limits, a subscription that does not allow the action – are written to stderr as `{"error": "…", "message": "…"}` and set the exit codes below. `linkedin changelog` is the one command that prints its response as is, without this envelope and without `--fields` – see [Changelog](/cli/getting-started#changelog). ## Exit codes | Code | Meaning | | --- | --- | | `0` | Success (check `success` field – action may have returned an error like "person not found") | | `1` | General/unexpected error | | `2` | Missing or invalid tokens, or a usage error caught by the argument parser – a missing required argument or flag, an unknown flag, or a value outside the allowed list; the message is on stderr | | `3` | Subscription/plan required | | `4` | LinkedIn account issue | | `5` | Invalid arguments rejected by the command's own checks, or by the API as `invalidRequestPayload` or `invalidWorkflow` | | `6` | Rate limited | | `7` | Network error | | `8` | Workflow timeout (workflowId returned for recovery) | ## Custom Workflows ## workflow run Execute a custom workflow definition. Accepts JSON from a file or stdin. The command starts the workflow and waits for it to complete. While it waits, it writes the workflow ID and status to stderr as progress (hidden by `--quiet`); at the end it prints `{"success": true, "data": …}` with the whole workflow completion in `data`. If waiting times out, it exits with code `8` and an error that names the `workflowId`, so you can continue with `linkedin workflow status --wait`. Calls [`POST /workflows`](/docs/executing-workflows) – what each option means, the result fields and the errors are described there. ```bash linkedin workflow run [flags] ``` | Flag | Type | Description | | --- | --- | --- | | `-f` / `--file` | string | Path to workflow JSON file | ```bash # From file linkedin workflow run --file workflow.json # From stdin cat workflow.json | linkedin workflow run # Inline echo '{"actionType": "st.openPersonPage", "personUrl": "https://www.linkedin.com/in/person1", "basicInfo": true}' | linkedin workflow run ``` The definition is the request body of `POST /workflows`: one action object, or an array of actions that run one after another. See [Building Workflows](/docs/building-workflows) for the schema. ## workflow status Check status of a running workflow or wait for its completion. In-progress responses include `status` (`pending` or `running`) and `message`. Reads [`GET /workflows/{workflowId}`](/docs/executing-workflows#checking-for-result) – what each option means, the result fields and the errors are described there. ```bash linkedin workflow status [flags] ``` | Arg | Required | Description | | --- | --- | --- | | `id` | yes | Workflow ID | | Flag | Type | Description | | --- | --- | --- | | `--wait` | boolean | Block until the workflow completes | ```bash # Check current status linkedin workflow status abc123 # Wait for completion linkedin workflow status abc123 --wait --json ``` ## Commands Each command below Setup and Accounts runs one API action or reads one endpoint. Its page in the API reference shows the command in the CLI tab, with its flags, the result and the errors. Any other workflow runs through [`linkedin workflow run`](/cli/workflows); `linkedin --help` lists the commands of your installed version. ## Setup and Accounts | Command | What it does | | --- | --- | | `linkedin setup` | Saves the tokens of an account – [Setup](/cli/getting-started#setup) | | `linkedin account list` | Lists the saved accounts – [Multiple accounts](/cli/getting-started#multiple-accounts) | | `linkedin account switch` | Makes another saved account the active one – [Multiple accounts](/cli/getting-started#multiple-accounts) | | `linkedin account update` | Replaces an account's tokens – [Updating tokens](/cli/getting-started#updating-tokens) | | `linkedin account rename` | Renames a saved account – [Multiple accounts](/cli/getting-started#multiple-accounts) | | `linkedin reset` | Removes the active account, or all of them with `--all` – [Multiple accounts](/cli/getting-started#multiple-accounts) | | `linkedin workflow run` | Runs any workflow – [Custom Workflows](/cli/workflows#workflow-run) | | `linkedin workflow status` | Checks or waits for a workflow – [Custom Workflows](/cli/workflows#workflow-status) | | `linkedin changelog` | Prints the public changelog – [Changelog](/cli/getting-started#changelog) | | `linkedin feedback` | Sends feedback to the Linked API team – [Feedback](/cli/getting-started#feedback) | ## Messages | Command | Runs | | --- | --- | | `linkedin message send` | [`st.sendMessage`](/docs/action-st-send-message?surface=cli) | | `linkedin message get` | [`st.syncConversation`](/docs/action-st-sync-conversation?surface=cli) (reads the conversation; loads its earlier history first when needed) | | `linkedin message manage` | [`st.manageConversation`](/docs/action-st-manage-conversation?surface=cli) | | `linkedin inbox sync` | [`st.syncInbox`](/docs/action-st-sync-inbox?surface=cli) | | `linkedin inbox get` | [`POST /inbox/poll`](/docs/monitoring-inbox?surface=cli) | ## Connections | Command | Runs | | --- | --- | | `linkedin connection status` | [`st.checkConnectionStatus`](/docs/action-st-check-connection-status?surface=cli) | | `linkedin connection send` | [`st.sendConnectionRequest`](/docs/action-st-send-connection-request?surface=cli) | | `linkedin connection list` | [`st.retrieveConnections`](/docs/action-st-retrieve-connections?surface=cli) | | `linkedin connection pending` | [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests?surface=cli) | | `linkedin connection invitations` | [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations?surface=cli) | | `linkedin connection accept` | [`st.acceptInvitation`](/docs/action-st-accept-invitation?surface=cli) | | `linkedin connection ignore` | [`st.ignoreInvitation`](/docs/action-st-ignore-invitation?surface=cli) | | `linkedin connection withdraw` | [`st.withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request?surface=cli) | | `linkedin connection remove` | [`st.removeConnection`](/docs/action-st-remove-connection?surface=cli) | | `linkedin network sync` | [`st.syncNetwork`](/docs/action-st-sync-network?surface=cli) | | `linkedin network events` | [`POST /network/poll`](/docs/monitoring-network?surface=cli) | ## People | Command | Runs | | --- | --- | | `linkedin person fetch` | [`st.openPersonPage`](/docs/action-st-open-person-page?surface=cli) | | `linkedin person search` | [`st.searchPeople`](/docs/action-st-search-people?surface=cli) | | `linkedin person endorse` | [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill?surface=cli); `--pattern` / `--all` – see [Endorsing skills](/docs/endorsing-skills?surface=cli) | ## Companies | Command | Runs | | --- | --- | | `linkedin company fetch` | [`st.openCompanyPage`](/docs/action-st-open-company-page?surface=cli) | | `linkedin company search` | [`st.searchCompanies`](/docs/action-st-search-companies?surface=cli) | ## Posts and Comments | Command | Runs | | --- | --- | | `linkedin post fetch` | [`st.openPost`](/docs/action-st-open-post?surface=cli) | | `linkedin post search` | [`st.searchPosts`](/docs/action-st-search-posts?surface=cli) | | `linkedin post create` | [`st.createPost`](/docs/action-st-create-post?surface=cli) | | `linkedin post repost` | [`st.createRepost`](/docs/action-st-create-repost?surface=cli) | | `linkedin post react` | [`st.reactToPost`](/docs/action-st-react-to-post?surface=cli) | | `linkedin post comment` | [`st.commentOnPost`](/docs/action-st-comment-on-post?surface=cli) | | `linkedin comment react` | [`st.reactToComment`](/docs/action-st-react-to-comment?surface=cli) | | `linkedin comment reply` | [`st.replyToComment`](/docs/action-st-reply-to-comment?surface=cli) | ## Jobs | Command | Runs | | --- | --- | | `linkedin jobs search` | [`st.searchJobs`](/docs/action-st-search-jobs?surface=cli) | | `linkedin jobs fetch` | [`st.openJob`](/docs/action-st-open-job?surface=cli) | ## Your Account | Command | Runs | | --- | --- | | `linkedin feed retrieve` | [`st.retrieveFeed`](/docs/action-st-retrieve-feed?surface=cli) | | `linkedin stats ssi` | [`st.retrieveSSI`](/docs/action-st-retrieve-ssi?surface=cli) | | `linkedin stats performance` | [`st.retrievePerformance`](/docs/action-st-retrieve-performance?surface=cli) | | `linkedin stats viewers` | [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers?surface=cli) | | `linkedin stats usage` | [`GET /stats/actions`](/docs/checking-api-usage-statistics?surface=cli) | ## Sales Navigator | Command | Runs | | --- | --- | | `linkedin navigator person fetch` | [`nv.openPersonPage`](/docs/action-nv-open-person-page?surface=cli) | | `linkedin navigator person search` | [`nv.searchPeople`](/docs/action-nv-search-people?surface=cli) | | `linkedin navigator company fetch` | [`nv.openCompanyPage`](/docs/action-nv-open-company-page?surface=cli) | | `linkedin navigator company search` | [`nv.searchCompanies`](/docs/action-nv-search-companies?surface=cli) | | `linkedin navigator message send` | [`nv.sendMessage`](/docs/action-nv-send-message?surface=cli) | | `linkedin navigator message get` | [`nv.syncConversation`](/docs/action-nv-sync-conversation?surface=cli) (reads the conversation; loads its earlier history first when needed) | | `linkedin navigator message manage` | [`nv.manageConversation`](/docs/action-nv-manage-conversation?surface=cli) | ## Admin API Admin commands use the Linked API token of the current saved account; see [Authentication](/docs/admin-overview?surface=cli#authentication). | Command | Runs | | --- | --- | | `linkedin admin subscription status` | [`subscription.getStatus`](/docs/admin-subscription?surface=cli#subscription-getstatus) | | `linkedin admin subscription seats` | [`subscription.getSeats`](/docs/admin-subscription?surface=cli#subscription-getseats) | | `linkedin admin subscription set-seats` | [`subscription.setSeats`](/docs/admin-subscription?surface=cli#subscription-setseats) | | `linkedin admin accounts list` | [`accounts.getAll`](/docs/admin-accounts?surface=cli#accounts-getall) | | `linkedin admin accounts reparse` | [`accounts.reparseAccountInfo`](/docs/admin-accounts?surface=cli#accounts-reparseaccountinfo) | | `linkedin admin accounts disconnect` | [`accounts.disconnect`](/docs/admin-accounts?surface=cli#accounts-disconnect) | | `linkedin admin accounts regenerate-token` | [`accounts.regenerateIdentificationToken`](/docs/admin-accounts?surface=cli#accounts-regenerateidentificationtoken) | | `linkedin admin accounts connect` | [`accounts.createConnectionSession`](/docs/admin-connection-sessions?surface=cli#accounts-createconnectionsession) | | `linkedin admin accounts reconnect` | [`accounts.createReconnectionSession`](/docs/admin-connection-sessions?surface=cli#accounts-createreconnectionsession) | | `linkedin admin accounts session` | [`accounts.getConnectionSession`](/docs/admin-connection-sessions?surface=cli#accounts-getconnectionsession) | | `linkedin admin accounts cancel-session` | [`accounts.cancelConnectionSession`](/docs/admin-connection-sessions?surface=cli#accounts-cancelconnectionsession) | | `linkedin admin limits defaults` | [`limits.getDefaults`](/docs/admin-limits?surface=cli#limits-getdefaults) | | `linkedin admin limits get` | [`limits.get`](/docs/admin-limits?surface=cli#limits-get) | | `linkedin admin limits usage` | [`limits.getUsage`](/docs/admin-limits?surface=cli#limits-getusage) | | `linkedin admin limits set` | [`limits.set`](/docs/admin-limits?surface=cli#limits-set) | | `linkedin admin limits delete` | [`limits.delete`](/docs/admin-limits?surface=cli#limits-delete) | | `linkedin admin limits reset` | [`limits.resetToDefaults`](/docs/admin-limits?surface=cli#limits-resettodefaults) | ## Webhooks | Command | Runs | | --- | --- | | `linkedin admin webhook set` | [`webhook.set`](/docs/admin-webhooks?surface=cli#webhook-set) | | `linkedin admin webhook get` | [`webhook.get`](/docs/admin-webhooks?surface=cli#webhook-get) | | `linkedin admin webhook set-payload-mode` | [`webhook.setPayloadMode`](/docs/admin-webhooks?surface=cli#webhook-setpayloadmode) | | `linkedin admin webhook set-events` | [`webhook.setEvents`](/docs/admin-webhooks?surface=cli#webhook-setevents) | | `linkedin admin webhook event-types` | [`webhook.eventTypes`](/docs/admin-webhooks?surface=cli#webhook-eventtypes) | | `linkedin admin webhook set-signing` | [`webhook.setSigning`](/docs/admin-webhooks?surface=cli#webhook-setsigning) | | `linkedin admin webhook set-header` | [`webhook.setHeader`](/docs/admin-webhooks?surface=cli#webhook-setheader) | | `linkedin admin webhook delete-header` | [`webhook.deleteHeader`](/docs/admin-webhooks?surface=cli#webhook-deleteheader) | | `linkedin admin webhook set-headers` | [`webhook.setHeaders`](/docs/admin-webhooks?surface=cli#webhook-setheaders) | | `linkedin admin webhook reveal-secret` | [`webhook.revealSecret`](/docs/admin-webhooks?surface=cli#webhook-revealsecret) | | `linkedin admin webhook rotate-secret` | [`webhook.rotateSecret`](/docs/admin-webhooks?surface=cli#webhook-rotatesecret) | | `linkedin admin webhook delete` | [`webhook.delete`](/docs/admin-webhooks?surface=cli#webhook-delete) | | `linkedin admin webhook deliveries` | [`webhook.deliveries`](/docs/admin-webhooks?surface=cli#webhook-deliveries) | | `linkedin admin webhook replay` | [`webhook.replayDelivery`](/docs/admin-webhooks?surface=cli#webhook-replaydelivery) | | `linkedin admin webhook send-test` | [`webhook.sendTest`](/docs/admin-webhooks?surface=cli#webhook-sendtest) | --- # MCP Server ## LinkedIn MCP Server: Linked API MCP for Claude, ChatGPT, Cursor and Codex Linked API MCP server connects your LinkedIn account to AI assistants like Claude, Claude Code, ChatGPT, Codex, Cursor, VS Code, Windsurf, and more. Ask them to search for leads, send messages, analyze profiles, and much more – they'll handle it through our cloud browser, safely and automatically. > Source code available on [GitHub](https://github.com/Linked-API/linkedapi-mcp). ## Does LinkedIn have an official MCP server? No. LinkedIn does not publish an MCP server – its developer platform offers REST APIs, most of them gated behind partner approval. Linked API MCP is an unofficial LinkedIn MCP server: it runs LinkedIn actions through a dedicated cloud browser on an account you own, so your assistant searches, reads and messages the way you would, at a human pace, and Linked API never stores your password. Install it in [Claude, Claude Code, ChatGPT, Codex, Cursor, VS Code or Windsurf](/mcp/installation) and ask in plain language. ## Common use cases - **Sales automation assistant**. Ask your AI to find leads, check their profiles, and draft personalized outreach. It can search for "software engineers at companies with 50-200 employees in San Francisco", analyze their backgrounds, and suggest connection messages that actually make sense. - **Recruitment assistant**. Let your assistant search for candidates with specific skills, review their experience, and send initial outreach. It handles the time-consuming parts while you focus on actually talking to people. - **Conversation assistant**. Your AI can read your existing LinkedIn conversations and help you respond naturally. It understands the context of your chats, suggests relevant replies, and can even send follow-up messages. - **Market research assistant**. Need competitor analysis? Your assistant can gather data about companies, their employees, and recent activities. Get insights about industry trends without spending hours on LinkedIn. ## How to get started To start using Linked API MCP, spend 2 minutes reading these essential guides: 1. [Installation](/mcp/installation) – set up the LinkedIn MCP server in [Claude](/mcp/installation#claude), [Claude Desktop](/mcp/installation#claude-desktop), [Claude Code](/mcp/installation#claude-code), [ChatGPT](/mcp/installation#chatgpt), [Codex](/mcp/installation#codex), [Cursor](/mcp/installation#cursor), [VS Code](/mcp/installation#vs-code) or [Windsurf](/mcp/installation#windsurf). 2. [Available tools](/mcp/available-tools) – explore all the LinkedIn tools your assistant can call. 3. [Usage examples](/mcp/usage-examples) – see real-world examples to get you started quickly. ## LinkedIn MCP Server Installation Set up the Linked API MCP server in **Claude**, **Claude Desktop**, **Claude Code**, **ChatGPT**, **Codex**, **Cursor**, **VS Code** or **Windsurf (Devin Desktop)** – each client has its own section below. First the server needs tokens to access your LinkedIn account. Here's how to get them: 1. Sign up at [Linked API Platform](https://app.linkedapi.io/). 2. Choose your plan and start your 7-day free trial. 3. Connect your LinkedIn account (it takes about 2 minutes). 4. Copy your tokens from the dashboard (as shown below): ![](/images/docs/tokens-4.webp) **Multiple LinkedIn accounts:** If you have multiple LinkedIn accounts, you'll get a separate identification token for each one, and you can create multiple MCP server instances – one for each account. ## Claude Connect Linked API as a **custom connector**. One connector covers [claude.ai](https://claude.ai), the Claude Desktop app and Cowork – they share the same account. **Free, Pro and Max plans:** 1. Go to [Customize → Connectors](https://claude.ai/customize/connectors). 2. Click **+**, then **Add custom connector**. 3. Paste this URL (replace with your actual tokens) and click **Add**: ```text https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=claude ``` Leave "Advanced settings" empty – the tokens in the URL are the authentication, so no OAuth client is needed. Free plans are limited to one custom connector. **Team and Enterprise plans:** Only an organization Owner can add the connector, under [Organization settings → Connectors](https://claude.ai/admin-settings/connectors) → **Add** → hover **Custom** → **Web**. Everyone else then enables it for themselves under Customize → Connectors. ## Claude Desktop The custom connector above already covers Claude Desktop – add it once at [Customize → Connectors](https://claude.ai/customize/connectors) and it shows up in the desktop app. Run the server on your own machine instead only if you need to. Add this to your Claude Desktop config file: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` ```json { "mcpServers": { "linkedapi": { "command": "npx", "args": ["-y", "@linkedapi/mcp"], "env": { "LINKED_API_TOKEN": "{YOUR_LINKED_API_TOKEN}", "IDENTIFICATION_TOKEN": "{YOUR_IDENTIFICATION_TOKEN}" } } } } ``` Restart Claude Desktop after saving. ## Claude Code Add the remote MCP server with a single command: ```bash claude mcp add linkedapi \ --scope user \ --transport http \ "https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=claude-code" ``` `--scope user` makes the server available in every project. Without it Claude Code uses its default, `local` scope: private to you and active only in the project you added it from – the git repository root, or this directory if you are not in a repository. Keep the double quotes around the URL – unquoted, your shell splits the command at the first `&`. Claude Code answers `Added HTTP MCP server linkedapi with URL: https://mcp.linkedapi.io to user config`. It prints the URL without its query string, but your tokens are stored. `claude mcp list` then shows `linkedapi ✔ Connected`, which means the server is reachable rather than that your tokens are right, and the line it prints carries both tokens in full. To check the tokens themselves, ask for something read-only in a session, such as a profile lookup. Or add it locally via NPX: ```bash claude mcp add linkedapi \ --scope user \ -e LINKED_API_TOKEN={YOUR_LINKED_API_TOKEN} \ -e IDENTIFICATION_TOKEN={YOUR_IDENTIFICATION_TOKEN} \ -- npx -y @linkedapi/mcp ``` > **Tip:** For Claude Code, you may find it more convenient to use our [LinkedIn CLI](/cli/getting-started) or [Agent Skills](/skills) instead of MCP – they provide a more native integration with the agentic coding workflow. ## ChatGPT ChatGPT calls custom MCP servers **plugins**, and adding one needs developer mode: 1. Open **Settings → Security and login** and turn on **Developer mode**. Whether you have it depends on your plan, and in a managed workspace an admin may have to allow it first. 2. Go to [chatgpt.com/plugins](https://chatgpt.com/plugins) and click the **+** button. 3. Give the connection a name and a short description. 4. Under **Connection**, paste this URL (replace with your actual tokens): ```text https://mcp.linkedapi.io/mcp?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=chatgpt ``` 5. Click **Create**, then review the tools ChatGPT discovered. Add the connection from the tools menu in a new chat to use it. ## Codex Add the MCP server to your Codex config file at `~/.codex/config.toml`: ```toml [mcp_servers.linkedapi] url = "https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=codex" ``` Or add it via CLI: ```bash codex mcp add linkedapi \ --url "https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=codex" ``` Keep the double quotes around the URL – unquoted, your shell splits the command at the first `&`. Run `codex mcp list` to check the server is there, or type `/mcp` inside the Codex TUI. To scope it to one repository instead, put the same table in that project's `.codex/config.toml`; Codex loads it only in projects you have marked trusted. Accept the trust prompt the first time you run `codex` in the repository, or add `[projects."/absolute/path/to/repo"]` with `trust_level = "trusted"` to `~/.codex/config.toml` yourself – use the repository root, not a subdirectory. Until the project is trusted Codex simply omits the server, and `codex mcp list` says nothing about trust. > **Tip:** For Codex, you may find it more convenient to use our [LinkedIn CLI](/cli/getting-started) instead of MCP – it gives you shell-native LinkedIn commands that work naturally in any terminal-based agent. ## Cursor Cursor reads MCP servers from a config file: `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` in a project root for that project only. ```json { "mcpServers": { "linkedapi": { "url": "https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=cursor" } } } ``` Open **Customize** in the Cursor sidebar to see the server and switch it on or off. You can add and manage servers from there too. ## VS Code VS Code has built-in MCP support (no extension required). Add a `.vscode/mcp.json` file to your project: ```json { "servers": { "linkedapi": { "type": "http", "url": "https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=vscode" } } } ``` To have it in every project instead, run **MCP: Open User Configuration** from the Command Palette and put the same entry in that file. VS Code moved MCP servers out of `settings.json` into a profile-specific `mcp.json` in version 1.102 and migrates old entries automatically, so don't add them to `settings.json` any more. You can also add it from the command line: ```bash code --add-mcp "{\"name\":\"linkedapi\",\"type\":\"http\",\"url\":\"https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=vscode\"}" ``` ## Windsurf Windsurf was renamed **Devin Desktop** in June 2026, and the MCP setup moved with it. **Devin Local**, the agent new tabs open with: ```bash devin mcp add -s user linkedapi "https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=windsurf" ``` Or edit `~/.config/devin/mcp_config.json` (`%APPDATA%\devin\mcp_config.json` on Windows) directly: ```json { "mcpServers": { "linkedapi": { "url": "https://mcp.linkedapi.io?linked-api-token={YOUR_LINKED_API_TOKEN}&identification-token={YOUR_IDENTIFICATION_TOKEN}&client=windsurf" } } } ``` **Legacy Cascade agent:** open the `...` (Actions) menu at the top right of the Cascade panel, then **Open MCP config file** in the MCPs section. It is the same file, and `serverUrl` works there as well as `url`. ## Local installation For clients that support local MCP servers, you can run Linked API MCP server directly on your device using NPX: ```json { "mcpServers": { "linkedapi": { "command": "npx", "args": ["-y", "@linkedapi/mcp"], "env": { "LINKED_API_TOKEN": "{YOUR_LINKED_API_TOKEN}", "IDENTIFICATION_TOKEN": "{YOUR_IDENTIFICATION_TOKEN}" } } } } ``` ## Available tools The MCP server provides these tools for interacting with LinkedIn: ## Messages | Tool | Description | API docs | | --- | --- | --- | | `send_message` | Send message to person, or reply into an existing thread by `threadId`, with optional archive, star, or mute of the conversation right after sending | [`st.sendMessage`](/docs/action-st-send-message) | | `sync_inbox` | Recommended first step before reading messages: enable whole-inbox monitoring so every incoming conversation can be polled | [`st.syncInbox`](/docs/action-st-sync-inbox) | | `get_inbox` | Default way to read messages: new messages across all conversations, or with `personUrl` the whole known conversation with one person, including history loaded by `sync_conversation` | [Monitoring Inbox](/docs/monitoring-inbox) | | `sync_conversation` | Load a person's earlier message history; call once per person, not to check for new messages – repeating it within 24 hours returns quickly | [`st.syncConversation`](/docs/action-st-sync-conversation) | | `manage_conversation` | Archive, star, or mute a conversation thread by `threadId` | [`st.manageConversation`](/docs/action-st-manage-conversation) | ## Connections | Tool | Description | API docs | | --- | --- | --- | | `check_connection_status` | Check connection status with person | [`st.checkConnectionStatus`](/docs/action-st-check-connection-status) | | `send_connection_request` | Send connection request with optional note | [`st.sendConnectionRequest`](/docs/action-st-send-connection-request) | | `withdraw_connection_request` | Withdraw pending connection request | [`st.withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request) | | `accept_invitation` | Accept an incoming invitation by type and matching person, company, or newsletter URL | [`st.acceptInvitation`](/docs/action-st-accept-invitation) | | `ignore_invitation` | Ignore an incoming invitation by type and matching person, company, or newsletter URL | [`st.ignoreInvitation`](/docs/action-st-ignore-invitation) | | `retrieve_pending_requests` | Get all pending connection requests | [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests) | | `retrieve_invitations` | Get all received connection, company-follow, and newsletter-subscription invitations | [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations) | | `retrieve_connections` | Get your connections with filtering | [`st.retrieveConnections`](/docs/action-st-retrieve-connections) | | `remove_connection` | Remove person from connections | [`st.removeConnection`](/docs/action-st-remove-connection) | | `sync_network` | Enable network monitoring so connection events can be polled | [`st.syncNetwork`](/docs/action-st-sync-network) | | `get_network` | Get connection events from the monitored network | [Monitoring Network](/docs/monitoring-network) | ## People | Tool | Description | API docs | | --- | --- | --- | | `search_people` | Search for people with advanced filtering | [`st.searchPeople`](/docs/action-st-search-people) | | `fetch_person` | Get person page information with optional experience, education, skills, posts | [`st.openPersonPage`](/docs/action-st-open-person-page) | | `endorse_person_skill` | Endorse one skill of a 1st-degree connection by its exact name, in the profile owner's language | [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill) | | `endorse_person_skills` | Endorse every skill of a 1st-degree connection whose name matches any of the RE2 patterns, or all of their skills; returns the outcome for each selected skill | [Endorsing skills](/docs/endorsing-skills) | ## Companies | Tool | Description | API docs | | --- | --- | --- | | `search_companies` | Search for companies with advanced filtering | [`st.searchCompanies`](/docs/action-st-search-companies) | | `fetch_company` | Get company information with optional employees, decision makers, posts | [`st.openCompanyPage`](/docs/action-st-open-company-page) | ## Posts and Comments | Tool | Description | API docs | | --- | --- | --- | | `search_posts` | Search for posts with advanced filtering by sort, date, content type, author, and mentions | [`st.searchPosts`](/docs/action-st-search-posts) | | `fetch_post` | Get post information and engagement metrics | [`st.openPost`](/docs/action-st-open-post) | | `create_post` | Publish a post with optional media attachments and mentions; returns the new post's URL and URN | [`st.createPost`](/docs/action-st-create-post) | | `create_repost` | Repost a post as is or with your own commentary; returns the repost's own URL and URN | [`st.createRepost`](/docs/action-st-create-repost) | | `react_to_post` | React to post (like, love, support, celebrate, insightful, funny) | [`st.reactToPost`](/docs/action-st-react-to-post) | | `comment_on_post` | Leave comment on post; returns the new comment's URN and URL | [`st.commentOnPost`](/docs/action-st-comment-on-post) | | `react_to_comment` | React to a comment by `commentUrl` (like, love, support, celebrate, insightful, funny) | [`st.reactToComment`](/docs/action-st-react-to-comment) | | `reply_to_comment` | Reply to a comment by `commentUrl`; returns the reply's URN and URL | [`st.replyToComment`](/docs/action-st-reply-to-comment) | ## Jobs | Tool | Description | API docs | | --- | --- | --- | | `search_jobs` | Search for jobs with advanced filtering | [`st.searchJobs`](/docs/action-st-search-jobs) | | `fetch_job` | Get job details such as company, location, salary, description | [`st.openJob`](/docs/action-st-open-job) | ## Your Account | Tool | Description | API docs | | --- | --- | --- | | `retrieve_feed` | Get posts from your personalized home feed, with an optional limit from 1 to 100 | [`st.retrieveFeed`](/docs/action-st-retrieve-feed) | | `retrieve_profile_viewers` | Get the people who recently viewed your profile, with an optional limit from 1 to 300 and a `since` timestamp | [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers) | | `retrieve_ssi` | Get current SSI (Social Selling Index) | [`st.retrieveSSI`](/docs/action-st-retrieve-ssi) | | `retrieve_performance` | Get LinkedIn dashboard analytics | [`st.retrievePerformance`](/docs/action-st-retrieve-performance) | | `get_api_usage` | Get Linked API usage statistics | [API Usage Statistics](/docs/checking-api-usage-statistics) | ## LinkedIn Sales Navigator MCP tools These LinkedIn Sales Navigator MCP tools run on a Plus seat with a Sales Navigator subscription and cover people and company search, messaging and whole-inbox sync – the account-based route described in the [Sales Navigator API guide](/guides/linkedin-sales-navigator-api). | Tool | Description | API docs | | --- | --- | --- | | `nv_send_message` | Send message to person via Sales Navigator, or reply into an existing thread by `threadId` | [`nv.sendMessage`](/docs/action-nv-send-message) | | `nv_sync_inbox` | Recommended first step before reading Sales Navigator messages: enable whole-inbox monitoring in Sales Navigator so every incoming conversation can be polled | [`nv.syncInbox`](/docs/action-nv-sync-inbox) | | `nv_sync_conversation` | Load a person's earlier Sales Navigator message history; call once per person, not to check for new messages | [`nv.syncConversation`](/docs/action-nv-sync-conversation) | | `nv_manage_conversation` | Archive or unarchive a Sales Navigator conversation thread by `threadId` | [`nv.manageConversation`](/docs/action-nv-manage-conversation) | | `nv_search_companies` | Search for companies with advanced filtering via Sales Navigator | [`nv.searchCompanies`](/docs/action-nv-search-companies) | | `nv_search_people` | Search for people with advanced filtering via Sales Navigator | [`nv.searchPeople`](/docs/action-nv-search-people) | | `nv_fetch_company` | Get company information with optional employees and decision makers from Sales Navigator | [`nv.openCompanyPage`](/docs/action-nv-open-company-page) | | `nv_fetch_person` | Get person page information from Sales Navigator | [`nv.openPersonPage`](/docs/action-nv-open-person-page) | ## Custom Workflows | Tool | Description | API docs | | --- | --- | --- | | `execute_custom_workflow` | Execute custom workflow definition | [Building Workflows](/docs/building-workflows) | | `get_workflow_result` | Get workflow result by ID | [Executing Workflows](/docs/executing-workflows#checking-for-result) | ## Changelog | Tool | Description | API docs | | --- | --- | --- | | `get_changelog` | Read the public Linked API changelog, optionally only the releases since a date; needs no tokens | [Changelog](/changelog) | ## Feedback | Tool | Description | API docs | | --- | --- | --- | | `send_feedback` | Send a bug report, a feature request or praise straight to the Linked API team, with the workflow ID and error of a failing call; it starts no LinkedIn workflow | [Feedback in the CLI](/cli/getting-started#feedback) | ## Administration | Tool | Description | API docs | | --- | --- | --- | | `admin_get_subscription_status` | Get current subscription status and trial eligibility | [Subscription](/docs/admin-subscription) | | `admin_get_seats` | Get active subscription seats | [Subscription](/docs/admin-subscription) | | `admin_set_seats` | Set number of subscription seats | [Subscription](/docs/admin-subscription) | | `admin_get_accounts` | Get all connected LinkedIn accounts, pending sessions, and reconnection links | [Accounts](/docs/admin-accounts) | | `admin_connect_account` | Create connection session for a new LinkedIn account | [Connection Sessions](/docs/admin-connection-sessions) | | `admin_reparse_account_info` | Refresh stored profile URL, avatar, headline, and name for a connected account | [Accounts](/docs/admin-accounts) | | `admin_create_reconnection_session` | Create a fresh reconnection session and link for an account that requires reconnection | [Connection Sessions](/docs/admin-connection-sessions) | | `admin_get_connection_session` | Get connection or reconnection session status | [Connection Sessions](/docs/admin-connection-sessions) | | `admin_cancel_connection_session` | Cancel a pending connection or reconnection session | [Connection Sessions](/docs/admin-connection-sessions) | | `admin_disconnect_account` | Disconnect a LinkedIn account | [Accounts](/docs/admin-accounts#accounts-disconnect) | | `admin_regenerate_token` | Regenerate identification token for an account | [Accounts](/docs/admin-accounts) | | `admin_get_limits_defaults` | Get system default rate limits | [Limits](/docs/admin-limits) | | `admin_get_limits` | Get configured rate limits for an account | [Limits](/docs/admin-limits) | | `admin_get_limits_usage` | Get current usage against configured limits | [Limits](/docs/admin-limits) | | `admin_set_limits` | Set rate limits for an account | [Limits](/docs/admin-limits) | | `admin_delete_limits` | Delete specific account rate limits | [Limits](/docs/admin-limits) | | `admin_reset_limits` | Reset all limits to system defaults | [Limits](/docs/admin-limits) | ## Usage examples With Linked API MCP, you can ask your AI-assistant things like: > Find all decision makers at Acme Corp and send them connection requests. > Search for product managers at fintech companies in New York with 50-200 employees. > Pull all contacts labeled 'follow-up' from my CRM, review our conversation history, and send each person a personalized follow-up message. > Tell me about 'https://linkedin.com/in/jane-doe' including their work history and experience. > Send a connection request to 'https://linkedin.com/in/jane-doe' mentioning their recent article about AI in healthcare. > Get all my pending connection requests and withdraw each one of them. > Fetch the comments on 'https://linkedin.com/posts/jane-doe_activity-123', then like each one and reply to the most relevant with a thoughtful follow-up. These are just basic examples. Since your assistant can execute custom workflows combining multiple actions, the automation potential is truly limitless. --- # Agent Skills Ready-to-run LinkedIn workflows your AI agent installs in one command and runs in plain language. ## LinkedIn Growth Your agent finds leads from a search you define, qualifies each one against your criteria, invites the matches on a schedule, tracks who accepts, and withdraws what goes unanswered. Install: `npx @linkedapi/skills add linkedin-growth`. Works with Claude Code, Codex, Cursor, Windsurf. ### A loop that runs itself A continuous cycle, not a one-off blast. Set it once and it keeps going. - **Define your target** — A LinkedIn or Sales Navigator search plus a plain-language profile of your ideal lead. - **Auto-qualify every candidate** — Every profile is checked against your criteria; matches are kept, the rest dropped, with a reason recorded. - **Invite on a schedule** — Matches are spread across your accounts and invited during active hours, within safe limits. - **Track who accepts** — Connected, pending, and declined stay up to date on their own. - **Clean up automatically** — Unanswered invites past your threshold are withdrawn, and can be retried from another account. After that, inviting, tracking, and cleaning up run on a schedule on their own. To add new people, you run another import whenever you want. ### Safety Growth sends real connection requests, so pacing comes first. You stay inside LinkedIn-safe limits without thinking about it. - A per-account daily limit and a minimum gap between invites. - Invites only during the active hours you set. - Automatic backoff when LinkedIn signals a limit. - Stale pending requests withdrawn to keep your weekly budget clean. ### FAQ **Does it keep finding new people on its own?** No. You run an import when you want to add people – a search plus your criteria. After that, sending invites, tracking who accepts, and cleaning up run on a schedule on their own. Import again whenever you want more. **How does qualification work?** Each person from your search is checked against your criteria. Matches are kept, the rest are dropped, and a short reason is recorded for each one so you can see why. **Where does it run?** On the machine where you install the skill. It keeps a local database and sends invites on a schedule – no external services beyond LinkedIn CLI and Linked API. **Can I use more than one LinkedIn account?** Yes. People are spread across your connected accounts, each paced within its own limits. If a request goes unanswered, it can be retried from another account. **Is this safe for my account?** Yes. Every invite goes through a dedicated cloud browser that is matched to your account and behaves like a real person, with natural timing. Because Growth sends real connection requests, pacing comes first: invites stay within the daily limit and minimum gap you set, go out only during your active hours, and if LinkedIn signals a limit the account backs off until its next scheduled run. Stale, unanswered requests are withdrawn so your pending list never piles up. [See how we keep accounts safe](/safety). **Can I change my criteria later?** Any time. The search and your criteria are plain language – update them whenever you want, and your next import uses the new rules. **What do I need to get started?** Node.js 20+, the LinkedIn CLI, and your [Linked API tokens](https://app.linkedapi.io). The installer checks for these and offers to set them up. ### Pricing Runs on your existing Linked API plan. Invites count as normal actions – there is no separate fee for the skill. ## LinkedIn General Your agent handles LinkedIn in plain language – fetching profiles, searching people and companies, messaging, connecting, posting, and pulling analytics. Install: `npx @linkedapi/skills add linkedin`. Works with Claude Code, Codex, Cursor, Windsurf. ### Everything your agent can do One skill, the full surface of LinkedIn. Ask in plain language and each action runs in a real cloud browser. - **People** — Fetch full profiles, Search with filters, Posts, comments, reactions - **Companies** — Fetch company pages, Employees & decision-makers, Search by size & industry - **Messaging** — Send messages, Read conversations, InMail via Sales Navigator - **Connections** — Send & withdraw requests, Check status, List & filter your network - **Posts & engagement** — Create posts with media, React and comment, Post as a company - **Insights** — Social Selling Index, Profile & post analytics, API usage stats ### Safety Every action runs in an isolated cloud browser that behaves like a real person, paced within the per-account limits you set. - Each account gets its own isolated cloud browser. - Human-like timing, mouse, and typing on every action. - Per-account daily, weekly, and monthly limits. - Your password is never seen or stored. ### FAQ **What can the LinkedIn skill do?** It covers the full surface of LinkedIn: fetch profiles, search people and companies, send messages, manage connections, create posts, react and comment, and pull analytics. Sales Navigator search and InMail are included too. **How do I control it?** Just describe what you want in plain language. Your agent translates the request into the right LinkedIn actions and runs them – no flags or scripts to learn, though the exact CLI commands are there if you want them. **How fast is it?** Actions are not instant. Each one drives a real cloud browser, so expect anywhere from 30 seconds to a few minutes, and actions for one account run one at a time. **Is this safe for my account?** Yes. Every action runs through a dedicated cloud browser that is matched to your account – the same device fingerprint, language, and location each time – and behaves like a real person, with natural timing and mouse movement. Actions are physically performed in a real browser at human-like speed. On top of that, you cap how much it does with daily, weekly, and monthly limits per account, and your password is never seen or stored. [See how we keep accounts safe](/safety). **Can I use more than one LinkedIn account?** Yes. Connect as many accounts as your plan allows and tell your agent which one to act as – each is paced within its own limits. **Do I need Sales Navigator?** Only for the Sales Navigator features – advanced search and InMail. Everything else works on a standard LinkedIn account. **What do I need to get started?** Node.js 20+, the LinkedIn CLI, and your [Linked API tokens](https://app.linkedapi.io). The installer checks for these and offers to set them up. ### Pricing Runs on your existing Linked API plan. Each action counts as a normal action – there is no separate fee for the skill. --- # Linked API changelog What has been added to Linked API: new actions, result fields, parameters and capabilities. One release a week at most, dated by the Monday of its week; a week with nothing new has no release. Machine-readable forms of this page: - JSON, only releases for the week of a moment you name and later: https://linkedapi.io/api/changelog?since=2026-01-01T00:00:00Z - RSS: https://linkedapi.io/changelog/rss.xml ## Week of 2026-10-19 - **Skill endorsements.** `st.endorsePersonSkill` endorses one skill of a 1st-degree connection by its name, and `st.doForSkills` under `st.retrievePersonSkills` endorses all of the person's skills or only those whose names match RE2 `skillNamePatterns`, reporting the outcome for each selected skill; the same is available as `endorsePersonSkill` and `endorsePersonSkills` in the Node SDK, `linkedin person endorse` in the CLI, `endorse_person_skill` and `endorse_person_skills` in the MCP server and the Endorse person skill and Endorse person skills actions in n8n. [Docs](https://linkedapi.io/docs/endorsing-skills) ## Week of 2026-10-12 - **One person's conversation.** `/inbox/poll` with `personUrl` returns the whole known conversation with one person, the messages the inbox captured together with history loaded by `st.syncConversation` or `nv.syncConversation`, and its `threads` say how far back each thread's history goes with `historyFrom` and `historyComplete`; the same view is available as `pollInbox` in the Node SDK, `linkedin inbox get --person` in the CLI and `get_inbox` in the MCP server. [Docs](https://linkedapi.io/docs/monitoring-inbox) ## Week of 2026-10-05 - **Viewer state.** Person and content results carry a `viewerState` object that says how your account relates to them: people, including the authors of posts and comments, report your `connectionDegree` (`1st`, `2nd` or `3rd+`), and posts and comments report your `reaction` and `isOwn`, or `null` where it cannot be read. [Docs](https://linkedapi.io/docs/data-conventions) - **Connection degree filters.** `st.searchPeople`, `nv.searchPeople`, `st.retrieveCompanyEmployees` and `nv.retrieveCompanyEmployees` filter by your connection degree with `connectionDegrees` (`1st`, `2nd`, `3rd+`, plus `groupMembers` on Sales Navigator), also available as CLI flags. [Docs](https://linkedapi.io/docs/action-st-search-people) - **Signed webhooks and custom headers.** Webhook deliveries can carry an HMAC-SHA256 signature to verify and custom headers for an endpoint that requires authentication, both off by default and set through `webhook.set`, `webhook.setSigning` and `webhook.setHeader`, with endpoints to reveal and rotate the secret and to replace or delete headers. [Docs](https://linkedapi.io/docs/webhook-signature-verification) - **Event selection.** A webhook can receive only the events you choose, exact types such as `workflow.completed` or whole namespaces such as `inbox.*`, set with `events` on `webhook.set`, changed with `webhook.setEvents` or under Advanced → Events on the platform; a webhook that never chooses keeps receiving every event. [Docs](https://linkedapi.io/docs/webhook-event-types) - **Webhook management in the SDKs and CLI.** The Node and Python SDKs and the CLI manage webhook signing, custom headers and event selection, and the SDKs verify a signed delivery with `verifyWebhookSignature` / `verify_webhook_signature`. [Docs](https://linkedapi.io/docs/admin-webhooks) - **Changelog in the SDKs, CLI and MCP.** Read this changelog with `getChangelog` / `get_changelog` from code, `linkedin changelog` from the terminal or the `get_changelog` tool in the MCP server, with no token. [Docs](https://linkedapi.io/sdks/get-changelog) ## Week of 2026-09-28 - **Reposts.** `st.createRepost` reposts someone else's post or your own, as is or with your commentary, addressed by `postUrl` or `postUrn`. [Docs](https://linkedapi.io/docs/action-st-create-repost) - **Mentions.** `st.createPost` and `st.createRepost` tag up to 20 people and companies through `mentions`: write `@[key]` in the text and describe each key with a name, optionally pinned by `urn`, `personHashedUrl` or `companyHashedUrl`. [Docs](https://linkedapi.io/docs/action-st-create-post) - **Post URNs.** Opening, reacting to, commenting on and reposting a post, and reading its comments and reactions, accept `postUrn` instead of `postUrl`, and `st.createPost` returns the new post's `postUrn` when LinkedIn gives it an address and, in workflows sent to the API, can run `st.openPost` on it as a child. [Docs](https://linkedapi.io/docs/publishing-posts) ## Week of 2026-09-14 - **Post search.** `st.searchPosts` searches LinkedIn posts by keyword, with filters for the author, the date posted and the content type. [Docs](https://linkedapi.io/docs/action-st-search-posts) ## Week of 2026-08-17 - **Public profile URL in inbox events.** Inbox polling and message webhooks carry `personPublicUrl`, the other participant's public URL, once Linked API has resolved it; it can be null on any message. [Docs](https://linkedapi.io/docs/monitoring-inbox) ## Week of 2026-08-10 - **Conversation sync period.** `st.syncConversation` and `nv.syncConversation` accept `days` (1 to 90) and return `syncUntil`; sync lasts 30 days unless `days` says otherwise, and running the action again starts a new period without losing the history. [Docs](https://linkedapi.io/docs/action-st-sync-conversation) - **LinkedIn URNs.** Results carry LinkedIn's permanent identifiers next to URLs, `urn` for the person, company or job posting a result is about and fields such as `companyUrn` and `personUrn` for related entities, including in inbox and network events, or null where LinkedIn exposes none. [Docs](https://linkedapi.io/docs/data-conventions) - **Profile viewers.** `st.retrieveProfileViewers` reads who viewed your profile and when; semi-private viewers come back as anonymous with a people-search URL, and viewers in full private mode are not returned. [Docs](https://linkedapi.io/docs/action-st-retrieve-profile-viewers) - **AI-powered jobs search.** On LinkedIn's AI-powered jobs search, `st.searchJobs` takes `preferences` (date posted, experience levels, employment types, companies, remote, Easy Apply, under 10 applicants, in your network, keywords) that LinkedIn applies where it offers them, reports `isSimilarMatch` for each job, and leaves near matches out with `allowSimilarResults: false`. [Docs](https://linkedapi.io/docs/searching-for-jobs) ## Week of 2026-08-03 - **Working hours.** An account can have a weekly activity window, set on the platform: your requests outside it are queued, rejected with `outsideWorkingHours` or run anyway depending on the account's policy, a workflow that has not started reports why in `pendingReason`, and an account with no window is never gated. [Docs](https://linkedapi.io/docs/working-hours) ## Week of 2026-07-27 - **Connection page completion.** `accounts.createConnectionSession` and `accounts.createReconnectionSession` accept a `completion` object that sets how limits are handled on a new connection, a button back to your product, and redirects on success or failure. [Docs](https://linkedapi.io/docs/admin-connection-sessions) ## Week of 2026-07-20 - **Feed.** `st.retrieveFeed` reads your LinkedIn feed: the posts, their authors and their engagement counts. [Docs](https://linkedapi.io/docs/action-st-retrieve-feed) - **Comments.** React to or reply to a single comment with `st.reactToComment` and `st.replyToComment`, open one with `st.openComment`, or act with `st.doForComments` on each comment `st.retrievePostComments` returns. [Docs](https://linkedapi.io/docs/reacting-and-commenting) ## Week of 2026-07-13 - **Network sync.** `st.syncNetwork` enables network monitoring, so connection events (accepted requests, new connections, incoming requests) arrive through polling or `network.*` webhooks for the changes made after you enable it. [Docs](https://linkedapi.io/docs/action-st-sync-network) - **Profile photos and company logos.** Person results carry `avatarUrl` and company results `logoUrl`, or null, on the profile, company page and search actions for standard LinkedIn and Sales Navigator. [Docs](https://linkedapi.io/docs/action-st-open-person-page) - **Company and newsletter invitations.** Received invitations also include invitations to follow a company or subscribe to a newsletter, each with its `invitationType`, accepted or ignored with the same actions. [Docs](https://linkedapi.io/docs/working-with-invitations) ## Week of 2026-07-06 - **Inbox sync.** Keep a LinkedIn inbox in sync and read it through the API, or receive its messages as `inbox.messageReceived` and `inbox.messageSent` webhooks, for standard LinkedIn and Sales Navigator. [Docs](https://linkedapi.io/docs/action-st-sync-inbox) - **Conversations.** `st.sendMessage` and `nv.sendMessage` reply into an existing thread by `threadId`, `st.manageConversation` archives, stars or mutes a conversation and undoes each, also right after sending a message, and `nv.manageConversation` archives and unarchives one. [Docs](https://linkedapi.io/docs/action-st-manage-conversation) - **Received connection requests.** Read the connection requests you have received, and accept or ignore each one. [Docs](https://linkedapi.io/docs/working-with-invitations) ## Week of 2026-06-29 - **Webhooks.** Register one HTTPS endpoint through the Admin API to receive workflow and account events instead of polling; deliveries are retried and can be listed, replayed and tested, and a completed workflow's result travels in the event or is fetched by id. [Docs](https://linkedapi.io/docs/webhooks) --- # n8n Integration ## Linked API + n8n Linked API for n8n connects your LinkedIn account to n8n's automation platform, so you can build visual workflows that search for prospects, send messages, manage connections, extract data, and more – all with [industry-leading safety](/safety). > Our n8n node is a **verified community node** – just open the **nodes panel**, search for "Linked API", and install it with a click. Alternatively, install it manually: go to **Settings** → **Community Nodes**, enter `n8n-nodes-linked-api`, and click **Install**. ### Common use cases - **Lead generation pipeline**.Search for prospects matching your criteria, automatically send connection requests, wait for acceptance, then send personalized follow-up messages. Connect the results to your CRM to track everything in one place. - **CRM synchronization**. When a new lead enters your CRM, automatically find their LinkedIn profile, send a connection request, and update the CRM with their LinkedIn data. Keep your sales pipeline enriched with real-time LinkedIn information. - **Content engagement**. Monitor when your target accounts post on LinkedIn, automatically react or comment on their posts, then log the engagement in your tracking spreadsheet. Build relationships at scale without losing the personal touch. - **Recruitment workflows**. Search for candidates with specific skills, send personalized InMails through Sales Navigator, track responses in your ATS, and automatically follow up with interested candidates. Streamline your entire recruitment funnel. - **Multi-channel outreach.** Combine LinkedIn with email, Slack, and other channels. When someone accepts your connection request, trigger an email sequence, notify your team in Slack, and create a task in your project management tool. ### How to get started To build LinkedIn automations with n8n, read these essential guides: 1. [Creating credential](/integrations/n8n/creating-credential) – get your API tokens and connect to n8n. 2. [Building workflows](/integrations/n8n/building-workflows) – learn how to use Linked API actions in your workflows. 3. [Available actions](/integrations/n8n/available-actions) – explore all LinkedIn actions you can automate. ## Creating credential Setting up Linked API in n8n requires two steps: **getting your API tokens** and **creating credential**. This guide walks you through both steps. ## Getting your tokens 1. Sign up at [Linked API Platform](https://app.linkedapi.io/). 2. Choose your plan and start your 7-day free trial. 3. Connect your LinkedIn account (it takes about 2 minutes). 4. Copy your tokens from the dashboard (as shown below): ![](/images/docs/tokens.webp) ## Creating n8n credential 1. In your n8n workflow, add any Linked API action. 2. Click the "Create new credential" in the action settings. ![](/images/docs/add-credentials-n8n.webp) 3. Paste your tokens into the displayed fields. ![](/images/docs/paste-tokens-n8n.webp) 4. Click "Save". Your credential is now ready to use. ## Multiple LinkedIn accounts If you manage multiple LinkedIn accounts, create a separate credential for each one. Every account has its own **Identification Token**, while the **Linked API Token** remains the same. You can then switch between accounts by selecting different credentials. ## Building workflows Once your credential is set up, you can start building LinkedIn automations in n8n. To add Linked API functionality to your workflows: 1. Search for "Linked API" in the node search panel when building your workflow. ![](/images/docs/node-search-n8n.png) 2. Select the action that matches what you want to do: send messages, search for people, retrieve company data, etc. See the full [list of available actions](/integrations/n8n/available-actions). 3. Configure the module with your specific parameters and select the credential [you created earlier](/integrations/n8n/creating-credential). ![](/images/docs/select-credential-n8n.png) 4. Search for "Wait" in the node search and add it to your workflow. 5. In the Wait node, find the "Resume" field and select "On Webhook Call". ![](/images/docs/wait-node-config-n8n.png) 6. Connect your Wait node to the Linked API node. ![](/images/docs/wait-demo-n8n.png) > The Wait node is required for [all actions](/integrations/n8n/available-actions) except **Cancel workflow**, **Get actions statistics**, **Get workflow result**, **Poll inbox**, and **Poll network** – these return results directly. ### Combining with other tools The real power comes from combining Linked API actions with other apps in your n8n workflows. You can connect LinkedIn actions with: - **CRM systems** like HubSpot, Salesforce, or Pipedrive: automatically add new LinkedIn connections to your CRM or trigger LinkedIn messages when deals reach certain stages. - **Databases** like Airtable, Google Sheets, or PostgreSQL: store LinkedIn search results, track outreach campaigns, or pull prospect lists for automated connection requests. - **Communication tools** like Slack, Gmail, or Microsoft Teams: get notifications about LinkedIn responses or trigger LinkedIn actions from team messages. - **Webhooks and APIs**: integrate with any custom system or trigger LinkedIn workflows from external events. ## Available actions Linked API provides these n8n actions for automating your LinkedIn flows: ## Messages | Action | Description | API docs | | --- | --- | --- | | **Manage conversation** | Archive, star, or mute a conversation thread by thread ID | [`st.manageConversation`](/docs/action-st-manage-conversation) | | **Send message** | Send message to person, or reply into an existing thread by thread ID, with optional archive, star, or mute of the conversation right after sending | [`st.sendMessage`](/docs/action-st-send-message) | | **Sync conversation** | Load a person's earlier message history. Run once per person – not on every workflow run; to catch new replies use **Sync inbox** once and **Poll inbox** | [`st.syncConversation`](/docs/action-st-sync-conversation) | | **Sync inbox** | Recommended way to watch incoming messages: enable whole-inbox monitoring so every incoming conversation can be polled | [`st.syncInbox`](/docs/action-st-sync-inbox) | | **Poll inbox** | Poll the monitored inbox for new messages across all conversations, or set **Person URL** to get the whole known conversation with one person, including history loaded by **Sync conversation** | [Monitoring Inbox](/docs/monitoring-inbox) | ## Connections | Action | Description | API docs | | --- | --- | --- | | **Accept invitation** | Accept a connection, company-follow, or newsletter-subscription invitation | [`st.acceptInvitation`](/docs/action-st-accept-invitation) | | **Check connection status** | Check connection status with person | [`st.checkConnectionStatus`](/docs/action-st-check-connection-status) | | **Ignore invitation** | Ignore a connection, company-follow, or newsletter-subscription invitation | [`st.ignoreInvitation`](/docs/action-st-ignore-invitation) | | **Remove connection** | Remove person from connections | [`st.removeConnection`](/docs/action-st-remove-connection) | | **Retrieve invitations** | Get all received connection, company-follow, and newsletter-subscription invitations | [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations) | | **Retrieve connections** | Get your connections with filtering | [`st.retrieveConnections`](/docs/action-st-retrieve-connections) | | **Retrieve pending requests** | Get all pending connection requests | [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests) | | **Send connection request** | Send connection request with optional note | [`st.sendConnectionRequest`](/docs/action-st-send-connection-request) | | **Sync network** | Enable network monitoring so connection events can be polled | [`st.syncNetwork`](/docs/action-st-sync-network) | | **Withdraw connection request** | Withdraw pending connection request | [`st.withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request) | | **Poll network** | Poll the monitored network for new connection events | [Monitoring Network](/docs/monitoring-network) | ## People | Action | Description | API docs | | --- | --- | --- | | **Fetch person** | Get person page information with optional experience, education, skills, posts | [`st.openPersonPage`](/docs/action-st-open-person-page) | | **Endorse person skill** | Endorse one skill of a 1st-degree connection by its exact name, in the profile owner's language | [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill) | | **Endorse person skills** | Endorse every skill of a 1st-degree connection whose name matches any of the RE2 patterns, or all of their skills; returns the outcome for each selected skill | [Endorsing skills](/docs/endorsing-skills) | | **Search people** | Search for people with advanced filtering | [`st.searchPeople`](/docs/action-st-search-people) | ## Companies | Action | Description | API docs | | --- | --- | --- | | **Fetch company** | Get company information with optional employees, decision makers, posts | [`st.openCompanyPage`](/docs/action-st-open-company-page) | | **Search companies** | Search for companies with advanced filtering | [`st.searchCompanies`](/docs/action-st-search-companies) | ## Posts and Comments | Action | Description | API docs | | --- | --- | --- | | **Comment on post** | Leave comment on post; returns the new comment's URN and URL | [`st.commentOnPost`](/docs/action-st-comment-on-post) | | **Create post** | Publish a post with optional media attachments and mentions; returns the new post's URL and URN | [`st.createPost`](/docs/action-st-create-post) | | **Create repost** | Repost a post as is or with your own commentary; returns the repost's own URL and URN | [`st.createRepost`](/docs/action-st-create-repost) | | **Fetch post** | Get post information and engagement metrics | [`st.openPost`](/docs/action-st-open-post) | | **React to comment** | React to a comment by comment URL (like, love, support, celebrate, insightful, funny) | [`st.reactToComment`](/docs/action-st-react-to-comment) | | **React to post** | React to post (like, love, support, celebrate, insightful, funny) | [`st.reactToPost`](/docs/action-st-react-to-post) | | **Reply to comment** | Reply to a comment by comment URL; returns the reply's URN and URL | [`st.replyToComment`](/docs/action-st-reply-to-comment) | | **Search posts** | Search for posts with advanced filtering by sort, date, content type, author, and mentions | [`st.searchPosts`](/docs/action-st-search-posts) | ## Jobs | Action | Description | API docs | | --- | --- | --- | | **Fetch job** | Get job details such as company, location, salary, description | [`st.openJob`](/docs/action-st-open-job) | | **Search jobs** | Search for jobs with advanced filtering | [`st.searchJobs`](/docs/action-st-search-jobs) | ## Your Account | Action | Description | API docs | | --- | --- | --- | | **Retrieve feed** | Get posts from your home feed | [`st.retrieveFeed`](/docs/action-st-retrieve-feed) | | **Retrieve profile viewers** | Get the people who recently viewed your profile, with an optional limit and a since timestamp | [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers) | | **Retrieve performance** | Get LinkedIn dashboard analytics | [`st.retrievePerformance`](/docs/action-st-retrieve-performance) | | **Retrieve SSI** | Get current SSI (Social Selling Index) | [`st.retrieveSSI`](/docs/action-st-retrieve-ssi) | | **Get actions statistics** | Get statistics about previously executed actions | [API Usage Statistics](/docs/checking-api-usage-statistics) | ## Sales Navigator | Action | Description | API docs | | --- | --- | --- | | **Fetch company in Sales Navigator** | Get company information with optional employees and decision makers from Sales Navigator | [`nv.openCompanyPage`](/docs/action-nv-open-company-page) | | **Fetch person in Sales Navigator** | Get person page information from Sales Navigator | [`nv.openPersonPage`](/docs/action-nv-open-person-page) | | **Manage conversation in Sales Navigator** | Archive or unarchive a conversation thread by thread ID via Sales Navigator | [`nv.manageConversation`](/docs/action-nv-manage-conversation) | | **Search companies in Sales Navigator** | Search for companies with advanced filtering via Sales Navigator | [`nv.searchCompanies`](/docs/action-nv-search-companies) | | **Search people in Sales Navigator** | Search for people with advanced filtering via Sales Navigator | [`nv.searchPeople`](/docs/action-nv-search-people) | | **Send message in Sales Navigator** | Send message to person via Sales Navigator, or reply into an existing thread by thread ID | [`nv.sendMessage`](/docs/action-nv-send-message) | | **Sync conversation in Sales Navigator** | Load a person's earlier Sales Navigator message history. Run once per person – not on every workflow run; to catch new replies use **Sync inbox in Sales Navigator** once and **Poll inbox** | [`nv.syncConversation`](/docs/action-nv-sync-conversation) | | **Sync inbox in Sales Navigator** | Recommended way to watch incoming Sales Navigator messages: enable whole-inbox monitoring in Sales Navigator so every incoming conversation can be polled | [`nv.syncInbox`](/docs/action-nv-sync-inbox) | ## Custom Workflows | Action | Description | API docs | | --- | --- | --- | | **Execute custom workflow** | Execute custom workflow by raw definition | [Building Workflows](/docs/building-workflows) | | **Cancel workflow** | Cancel prevously started workflow | [Executing Workflows](/docs/executing-workflows#cancelling-workflows) | | **Get workflow result** | Get result by workflow id | [Executing Workflows](/docs/executing-workflows#checking-for-result) | ## Administration | Action | Description | API docs | | --- | --- | --- | | **Get Subscription Status** | Check current subscription status and trial eligibility | [Subscription](/docs/admin-subscription) | | **Get Seats** | Get active subscription seats | [Subscription](/docs/admin-subscription) | | **Set Seats** | Set number of subscription seats | [Subscription](/docs/admin-subscription) | | **Get Accounts** | List connected LinkedIn accounts with profile URL, avatar, headline, pending sessions, and reconnection links | [Accounts](/docs/admin-accounts) | | **Connect Account** | Create connection session for a new LinkedIn account | [Connection Sessions](/docs/admin-connection-sessions) | | **Reparse Account Info** | Refresh stored profile URL, avatar, headline, and name for an account | [Accounts](/docs/admin-accounts) | | **Reconnect Account** | Create reconnection session for an account that requires reconnection | [Connection Sessions](/docs/admin-connection-sessions) | | **Get Connection Session** | Get connection or reconnection session status | [Connection Sessions](/docs/admin-connection-sessions) | | **Cancel Connection Session** | Cancel a pending connection or reconnection session | [Connection Sessions](/docs/admin-connection-sessions) | | **Disconnect Account** | Disconnect a LinkedIn account | [Accounts](/docs/admin-accounts#accounts-disconnect) | | **Regenerate Token** | Regenerate identification token for an account | [Accounts](/docs/admin-accounts) | | **Get Limits Defaults** | Get system default rate limits | [Limits](/docs/admin-limits) | | **Get Limits** | Get configured rate limits for an account | [Limits](/docs/admin-limits) | | **Get Limits Usage** | Check current usage against configured limits | [Limits](/docs/admin-limits) | | **Set Limits** | Configure rate limits for an account | [Limits](/docs/admin-limits) | | **Delete Limits** | Delete specific account rate limits | [Limits](/docs/admin-limits) | | **Reset Limits** | Reset all limits to system defaults | [Limits](/docs/admin-limits) | ## Usage examples We collect the following examples to show how Linked API can be used with n8n to automate LinkedIn-related operations. They’re intended to inspire practical ways to reduce manual steps in your business processes. > 💡 We also have [n8n templates](/templates) that you can import and use right away, or customize for your specific case. ## Content warming & outreach Here is the scenario example of how you can use Linked API & n8n integration for social selling through gradual engagement before sending requests for connect. This workflow warms up prospects automatically: first it likes and comments on their latest posts, then sends a connection request, checks if it’s accepted, and withdraws unaccepted ones after 7 days. All actions are scheduled and logged in a Google Sheet, so you can monitor who’s at each stage. **Modules:** Google Sheets, Linked API (fetchPerson, reactToPost, commentOnPost, sendConnectionRequest, checkConnectionStatus, withdrawConnectionRequest), Slack. ![Example: Content warming & outreach](/images/docs/Workflow-1.webp) ## Form → CRM → LinkedIn/Email Here is the scenario example of how you can use Linked API & n8n to instantly turn new leads into personalized outreach at the moment they submit a form. When someone fills out your lead form (Typeform, website, ad campaign, etc.), the scenario instantly adds them to your CRM (e.g. Pipedrive), searches for their LinkedIn profile, and decides what to do next: send a connection request and log their LinkedIn URL or send a follow-up email instead. Every new lead will get a timely, personalized touch without manual lookup. **Modules:** Typeform (Trigger), Pipedrive, Linked API (searchPeople, sendConnectionRequest), Chat GPT, Email. ![Example: Form → CRM → LinkedIn/Email](/images/docs/3.webp) ## LinkedIn SMM tracker Here is the scenario example of how you can use Linked API & n8n to get automated insights into your LinkedIn growth and engagement. Once a week, the workflow collects your LinkedIn metrics (SSI score, profile performance, and post stats), saves them to Google Sheets, compares with last week’s data, and sends a Slack summary with what’s up or down. **Modules:** Google Sheets, Linked API (retrieveSSI, retrievePerformance, fetchPerson, fetchPost), Slack. ![Example: LinkedIn SMM tracker](/images/docs/--------------------------2025-10-24----14.03.26.webp) ## Recruiting automation Here is the scenario example of how you can use Linked API & n8n to automate candidate search, profile validation, and connection requests from ATS (Airtable) to LinkedIn. When a new record is added, it searches LinkedIn for a matching profile, validates fit using ChatGPT, sends a connection request, and tracks its status. The system updates candidate records daily, withdraws unaccepted requests after 7 days, and notifies the team in Slack when connections are accepted. **Modules:** Airtable, Linked API (searchPeople, sendConnectionRequest, checkConnectionStatus, withdrawConnectionRequest), ChatGPT, Slack. ![Example: Recruiting automation](/images/docs/5-1.webp) ## ERP lead warming Here is an example of how you can use Linked API and n8n to automate lead enrichment and engagement directly from your ERP system. Each company record is used to find up to three decision-makers on LinkedIn, fetch their profiles, and define if they are Active or Inactive based on posting activity over the last 90 days. The workflow continues only with active contacts, generating a short relevant comment with ChatGPT, and then sending a connection request. **Modules:** ERP (HubSpot, or other), Linked API (fetchCompany, fetchPerson, fetchPost, commentOnPost, sendConnectionRequest), ChatGPT. ![Example: ERP lead warming](/images/docs/2.webp) ## Standard Outreach Sequence Automate your LinkedIn outreach with this n8n workflow powered by [Linked API](https://linkedapi.io/). Sends connection requests, monitors acceptance, delivers personalized messages, and follows up automatically – all tracked in Google Sheets (you can adjust to something else). ### What it does - Sends connection requests to new leads - Monitors when connections are accepted - Sends your message sequences from Google Sheet (up to 3 messages per lead) - Follows up automatically if no reply - Marks leads as declined/expired/no response when appropriate - Tracks all leads and statuses in Google Sheets ### Setup - [Use this template in n8n](https://n8n.io/workflows/12915-manage-linkedin-outreach-sequences-with-linked-api-and-google-sheets/) - Copy the [Google Sheet template](https://docs.google.com/spreadsheets/d/141fJskisAQ7H8AxtojQ7LZrnd14EOyB26RdDq5aczEU/copy) - Connect credentials in n8n: - Google Sheets (OAuth2) - Linked API (get keys at [app.linkedapi.io](https://app.linkedapi.io)) - Set `DOCUMENT_LINK` and `SHEET_NAME` in the `Config` node (your spreadsheet URL after copying the template) - Add leads with `Status` = `NEW` and fill in: - `Connection note` (optional) - `Message 1`, `Message 2`, `Message 3` (required) - Activate the workflow ### Configuration | Setting | Default | Description | | --- | --- | --- | | DOCUMENT_LINK | – | URL to your Google Sheet | | SHEET_NAME | Leads | Name of the sheet with leads | | DAILY_CONNECTION_LIMIT | 25 | Max connection requests per day | | HOURS_TO_CHECK_IF_CONNECTION_ACCEPTED | 24 | Check frequency for connection acceptance | | HOURS_TO_CHECK_IF_REPLIED | 4 | Check frequency for message replies | | HOURS_DELAY_AFTER_CONNECTION_ACCEPTED | 24 | Delay before first message | | DAYS_DELAY_BETWEEN_MESSAGES | 2 | Delay between follow-ups | | DAYS_WAIT_FOR_CONNECTION_ACCEPTANCE | 10 | Timeout for connection requests | | DAYS_WAIT_AFTER_LAST_MESSAGE | 4 | Days to wait after last message before marking as no response | --- # Make Integration ## Linked API + Make Linked API for Make connects your LinkedIn account to Make's automation platform, so you can build visual scenarios that search for prospects, send messages, manage connections, extract data, and more – all with [industry-leading safety](/safety). > Our app is available in the **Make Apps Marketplace** – just search for "Linked API" when adding a new module to your scenario. ### Common use cases - **Lead generation pipeline**.Search for prospects matching your criteria, automatically send connection requests, wait for acceptance, then send personalized follow-up messages. Connect the results to your CRM to track everything in one place. - **CRM synchronization**. When a new lead enters your CRM, automatically find their LinkedIn profile, send a connection request, and update the CRM with their LinkedIn data. Keep your sales pipeline enriched with real-time LinkedIn information. - **Content engagement**. Monitor when your target accounts post on LinkedIn, automatically react or comment on their posts, then log the engagement in your tracking spreadsheet. Build relationships at scale without losing the personal touch. - **Recruitment workflows**. Search for candidates with specific skills, send personalized InMails through Sales Navigator, track responses in your ATS, and automatically follow up with interested candidates. Streamline your entire recruitment funnel. - **Multi-channel outreach.** Combine LinkedIn with email, Slack, and other channels. When someone accepts your connection request, trigger an email sequence, notify your team in Slack, and create a task in your project management tool. ### How to get started To build LinkedIn automations with Make, read these essential guides: 1. [Creating connection](/integrations/make/creating-connection) – get your API tokens and connect to Make. 2. [Building scenarios](/integrations/make/building-scenarios) – learn how to use Linked API modules in your scenarios. 3. [Available modules](/integrations/make/available-modules) – explore all LinkedIn actions you can automate. ## Creating connection Setting up Linked API in Make requires two steps: **getting your API tokens** and **creating a connection**. This guide walks you through both steps. ## Getting your tokens 1. Sign up at [Linked API Platform](https://app.linkedapi.io/). 2. Choose your plan and start your 7-day free trial. 3. Connect your LinkedIn account (it takes about 2 minutes). 4. Copy your tokens from the dashboard (as shown below): ![](/images/docs/tokens.webp) ## Creating Make connection 1. In your Make scenario, add any Linked API module. 2. Click the "Create a connection" button in the module settings. ![](/images/docs/create-connection-button.webp) 3. Paste your tokens into the displayed fields. ![](/images/docs/paste-tokens.webp) 4. Click "Save". Your connection is now ready to use. ## Multiple LinkedIn accounts If you manage multiple LinkedIn accounts, create a separate connection for each one. Every account has its own **Identification Token**, while the **Linked API Token** remains the same. You can then switch between accounts by selecting different connections. ## Building scenarios Once your connection is set up, you can start building LinkedIn automations in Make. To add Linked API functionality to your scenarios: 1. Search for "Linked API" in the module search bar when building your scenario. 2. Select the module that matches what you want to do: send messages, search for people, retrieve company data, etc. See the [full list of available modules](/integrations/make/available-modules). 3. Configure the module with your specific parameters and select the connection [you created earlier](/integrations/make/creating-connection). ![](/images/docs/make-modules.webp) ### Combining with other tools The real power comes from combining Linked API modules with other apps in your Make scenarios. You can connect LinkedIn actions with: - **CRM systems** like HubSpot, Salesforce, or Pipedrive: automatically add new LinkedIn connections to your CRM or trigger LinkedIn messages when deals reach certain stages. - **Databases** like Airtable, Google Sheets, or PostgreSQL: store LinkedIn search results, track outreach campaigns, or pull prospect lists for automated connection requests. - **Communication tools** like Slack, Gmail, or Microsoft Teams: get notifications about LinkedIn responses or trigger LinkedIn actions from team messages. - **Webhooks and APIs**: integrate with any custom system or trigger LinkedIn workflows from external events. ## Available modules Linked API provides these Make modules for automating your LinkedIn flows: ## Messages | Module | Description | API docs | | --- | --- | --- | | **Manage Conversation** | Archive, star, or mute a conversation thread by thread ID | [`st.manageConversation`](/docs/action-st-manage-conversation) | | **Send Message** | Send message to person, or reply into an existing thread by thread ID, with optional archive, star, or mute of the conversation right after sending | [`st.sendMessage`](/docs/action-st-send-message) | | **Sync Conversation** | Load a person's earlier message history. Run once per person – not on every scenario run; to catch new replies use **Sync Inbox** once and **Poll Inbox** | [`st.syncConversation`](/docs/action-st-sync-conversation) | | **Sync Inbox** | Recommended way to watch incoming messages: enable whole-inbox monitoring so every incoming conversation can be polled | [`st.syncInbox`](/docs/action-st-sync-inbox) | | **Poll Inbox** | Poll the monitored inbox for new messages across all conversations, or set **Person URL** to get the whole known conversation with one person, including history loaded by **Sync Conversation** | [Monitoring Inbox](/docs/monitoring-inbox) | ## Connections | Module | Description | API docs | | --- | --- | --- | | **Accept an Invitation** | Accept a connection, company-follow, or newsletter-subscription invitation | [`st.acceptInvitation`](/docs/action-st-accept-invitation) | | **Check Connection Status** | Check connection status with person | [`st.checkConnectionStatus`](/docs/action-st-check-connection-status) | | **Ignore an Invitation** | Ignore a connection, company-follow, or newsletter-subscription invitation | [`st.ignoreInvitation`](/docs/action-st-ignore-invitation) | | **Remove Connection** | Remove person from connections | [`st.removeConnection`](/docs/action-st-remove-connection) | | **Retrieve Incoming Invitations** | Get all received connection, company-follow, and newsletter-subscription invitations | [`st.retrieveInvitations`](/docs/action-st-retrieve-invitations) | | **Retrieve Connections** | Get your connections with filtering | [`st.retrieveConnections`](/docs/action-st-retrieve-connections) | | **Retrieve Pending Requests** | Get all pending connection requests | [`st.retrievePendingRequests`](/docs/action-st-retrieve-pending-requests) | | **Send Connection Request** | Send connection request with optional note | [`st.sendConnectionRequest`](/docs/action-st-send-connection-request) | | **Sync Network** | Enable network monitoring so connection events can be polled | [`st.syncNetwork`](/docs/action-st-sync-network) | | **Withdraw Connection Request** | Withdraw pending connection request | [`st.withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request) | | **Poll Network** | Poll the monitored network for new connection events | [Monitoring Network](/docs/monitoring-network) | ## People | Module | Description | API docs | | --- | --- | --- | | **Fetch Person** | Get person page information with optional experience, education, skills, posts | [`st.openPersonPage`](/docs/action-st-open-person-page) | | **Endorse Person Skill** | Endorse one skill of a 1st-degree connection by its exact name, in the profile owner's language | [`st.endorsePersonSkill`](/docs/action-st-endorse-person-skill) | | **Endorse Person Skills** | Endorse every skill of a 1st-degree connection whose name matches any of the RE2 patterns, or all of their skills; returns the outcome for each selected skill | [Endorsing skills](/docs/endorsing-skills) | | **Search People** | Search for people with advanced filtering | [`st.searchPeople`](/docs/action-st-search-people) | ## Companies | Module | Description | API docs | | --- | --- | --- | | **Fetch Company** | Get company information with optional employees, decision makers, posts | [`st.openCompanyPage`](/docs/action-st-open-company-page) | | **Search Companies** | Search for companies with advanced filtering | [`st.searchCompanies`](/docs/action-st-search-companies) | ## Posts and Comments | Module | Description | API docs | | --- | --- | --- | | **Comment on Post** | Leave comment on post; returns the new comment's URN and URL | [`st.commentOnPost`](/docs/action-st-comment-on-post) | | **Create Post** | Publish a post with optional media attachments and mentions; returns the new post's URL and URN | [`st.createPost`](/docs/action-st-create-post) | | **Create Repost** | Repost a post as is or with your own commentary; returns the repost's own URL and URN | [`st.createRepost`](/docs/action-st-create-repost) | | **Fetch Post** | Get post information and engagement metrics | [`st.openPost`](/docs/action-st-open-post) | | **React to Comment** | React to a comment by comment URL (like, love, support, celebrate, insightful, funny) | [`st.reactToComment`](/docs/action-st-react-to-comment) | | **React to Post** | React to post (like, love, support, celebrate, insightful, funny) | [`st.reactToPost`](/docs/action-st-react-to-post) | | **Reply to Comment** | Reply to a comment by comment URL; returns the reply's URN and URL | [`st.replyToComment`](/docs/action-st-reply-to-comment) | | **Search Posts** | Search for posts with advanced filtering by sort, date, content type, author, and mentions | [`st.searchPosts`](/docs/action-st-search-posts) | ## Jobs | Module | Description | API docs | | --- | --- | --- | | **Fetch Job** | Get job details such as company, location, salary, description | [`st.openJob`](/docs/action-st-open-job) | | **Search Jobs** | Search for jobs with advanced filtering | [`st.searchJobs`](/docs/action-st-search-jobs) | ## Your Account | Module | Description | API docs | | --- | --- | --- | | **Retrieve Feed Posts** | Get posts from your home feed | [`st.retrieveFeed`](/docs/action-st-retrieve-feed) | | **Retrieve Profile Viewers** | Get the people who recently viewed your profile, with an optional limit and a since timestamp | [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers) | | **Retrieve Performance** | Get LinkedIn dashboard analytics | [`st.retrievePerformance`](/docs/action-st-retrieve-performance) | | **Retrieve SSI** | Get current SSI (Social Selling Index) | [`st.retrieveSSI`](/docs/action-st-retrieve-ssi) | | **Get Actions Statistics** | Get statistics about previously executed actions | [API Usage Statistics](/docs/checking-api-usage-statistics) | ## Sales Navigator | Module | Description | API docs | | --- | --- | --- | | **Fetch Company in Sales Navigator** | Get company information with optional employees and decision makers from Sales Navigator | [`nv.openCompanyPage`](/docs/action-nv-open-company-page) | | **Fetch Person in Sales Navigator** | Get person page information from Sales Navigator | [`nv.openPersonPage`](/docs/action-nv-open-person-page) | | **Manage Conversation in Sales Navigator** | Archive or unarchive a conversation thread by thread ID via Sales Navigator | [`nv.manageConversation`](/docs/action-nv-manage-conversation) | | **Search Companies in Sales Navigator** | Search for companies with advanced filtering via Sales Navigator | [`nv.searchCompanies`](/docs/action-nv-search-companies) | | **Search People in Sales Navigator** | Search for people with advanced filtering via Sales Navigator | [`nv.searchPeople`](/docs/action-nv-search-people) | | **Send Message in Sales Navigator** | Send message to person via Sales Navigator, or reply into an existing thread by thread ID | [`nv.sendMessage`](/docs/action-nv-send-message) | | **Sync Conversation in Sales Navigator** | Load a person's earlier Sales Navigator message history. Run once per person – not on every scenario run; to catch new replies use **Sync Inbox in Sales Navigator** once and **Poll Inbox** | [`nv.syncConversation`](/docs/action-nv-sync-conversation) | | **Sync Inbox in Sales Navigator** | Recommended way to watch incoming Sales Navigator messages: enable whole-inbox monitoring in Sales Navigator so every incoming conversation can be polled | [`nv.syncInbox`](/docs/action-nv-sync-inbox) | ## Custom Workflows | Module | Description | API docs | | --- | --- | --- | | **Execute Custom Workflow** | Execute custom workflow by raw definition | [Building Workflows](/docs/building-workflows) | ## Administration | Module | Description | API docs | | --- | --- | --- | | **Get Subscription Status** | Check current subscription status and trial eligibility | [Subscription](/docs/admin-subscription) | | **Get Seats** | Get active subscription seats | [Subscription](/docs/admin-subscription) | | **Set Seats** | Set number of subscription seats | [Subscription](/docs/admin-subscription) | | **Get Accounts** | List connected LinkedIn accounts with profile URL, avatar, headline, pending sessions, and reconnection links | [Accounts](/docs/admin-accounts) | | **Connect Account** | Create connection session for a new LinkedIn account | [Connection Sessions](/docs/admin-connection-sessions) | | **Reparse Account Info** | Refresh stored profile URL, avatar, headline, and name for an account | [Accounts](/docs/admin-accounts) | | **Reconnect Account** | Create reconnection session for an account that requires reconnection | [Connection Sessions](/docs/admin-connection-sessions) | | **Get Connection Session** | Get connection or reconnection session status | [Connection Sessions](/docs/admin-connection-sessions) | | **Cancel Connection Session** | Cancel a pending connection or reconnection session | [Connection Sessions](/docs/admin-connection-sessions) | | **Disconnect Account** | Disconnect a LinkedIn account | [Accounts](/docs/admin-accounts#accounts-disconnect) | | **Regenerate Token** | Regenerate identification token for an account | [Accounts](/docs/admin-accounts) | | **Get Limits Defaults** | Get system default rate limits | [Limits](/docs/admin-limits) | | **Get Limits** | Get configured rate limits for an account | [Limits](/docs/admin-limits) | | **Get Limits Usage** | Check current usage against configured limits | [Limits](/docs/admin-limits) | | **Set Limits** | Configure rate limits for an account | [Limits](/docs/admin-limits) | | **Delete Limits** | Delete specific account rate limits | [Limits](/docs/admin-limits) | | **Reset Limits** | Reset all limits to system defaults | [Limits](/docs/admin-limits) | ## Usage examples We collect the following examples to show how Linked API can be used with Make to automate LinkedIn-related operations. They’re intended to inspire practical ways to reduce manual steps in your business processes. ## Content warming & outreach Here is the scenario example of how you can use Linked API & Make integration for social selling through gradual engagement before sending requests for connect. This workflow warms up prospects automatically: first it likes and comments on their latest posts, then sends a connection request, checks if it’s accepted, and withdraws unaccepted ones after 7 days. All actions are scheduled and logged in a Google Sheet, so you can monitor who’s at each stage. **Modules:** Google Sheets, Linked API (Fetch aPerson, React to a Post, Comment on a Post, Send a Connection Request, Check a Connection Status, Withdraw a Connection Request), Slack. ![Example: Content warming & outreach workflow](/images/docs/Content-warming---outreach-example.webp) ## Form → CRM → LinkedIn/Email Here is the scenario example of how you can use Linked API & Make to instantly turn new leads into personalized outreach at the moment they submit a form. When someone fills out your lead form (Typeform, website, ad campaign, etc.), the scenario instantly adds them to your CRM (e.g. Pipedrive), searches for their LinkedIn profile, and decides what to do next: send a connection request and log their LinkedIn URL or send a follow-up email instead. Every new lead will get a timely, personalized touch without manual lookup. **Modules:** Typeform (Webhook), Pipedrive, Linked API (Search People, Send a Connection Request), Email. ![Example: Form → CRM → LinkedIn/Email workflow](/images/docs/Form-----CRM-----LinkedIn-example-2.webp) ## LinkedIn SMM tracker Here is the scenario example of how you can use Linked API & Make to get automated insights into your LinkedIn growth and engagement. Once a week, the workflow collects your LinkedIn metrics (SSI score, profile performance, and post stats), saves them to Google Sheets, compares with last week’s data, and sends a Slack summary with what’s up or down. **Modules:** Google Sheets, Linked API (Retrieve SSI, Retrieve Performance, Fetch a Person, Fetch a Post), set of variables, Slack. ![Example: LinkedIn SMM tracker workflow](/images/docs/LinkedIn-SMM-tracker-example.webp) ## Recruiting automation Here is the scenario example of how you can use Linked API & Make to automate candidate search, profile validation, and connection requests from ATS (Airtable) to LinkedIn. When a new record is added, it searches LinkedIn for a matching profile, validates fit using ChatGPT, sends a connection request, and tracks its status. The system updates candidate records daily, withdraws unaccepted requests after 7 days, and notifies the team in Slack when connections are accepted. **Modules:** Airtable, Linked API (Search People, Check a Connection Status, Send/Withdraw a Connection Request), ChatGPT, Slack. ![Example: Recruiting automation workflow](/images/docs/-Recruiting-Automation-example.webp) ## ERP lead warming Here is an example of how you can use Linked API and Make to automate lead enrichment and engagement directly from your ERP system. Each company record is used to find up to three decision-makers on LinkedIn, fetch their profiles, and define if they are Active or Inactive based on posting activity over the last 90 days. The workflow continues only with active contacts, generating a short relevant comment with ChatGPT, and then sending a connection request. **Modules:** ERP (HubSpot, or other), Linked API (Fetch a Company, Fetch a Person, Comment on a Post, Send a Connection Request), ChatGPT. ![Example: ERP lead warming](/images/docs/ERP-Lead-Warming--company-lists--2.webp) --- # Guides ## AI Candidate Sourcing on LinkedIn Without a Recruiter Seat Sourcing is the slowest part of filling a role, and most advice on AI sourcing assumes one of two things: a LinkedIn Recruiter seat, or a separate candidate database. This guide covers the setup most small teams actually have – a standard LinkedIn account – and walks one vacancy from search to a reviewed first message. > **The short version.** AI candidate sourcing means an agent does the searching and the reading. It turns a vacancy into a handful of searches, reads each profile against the role's must-haves, and hands you a shortlist with the evidence behind every name. Without a Recruiter seat it runs on LinkedIn's standard people search in your own account. You get filters for title, location, current and past company, school and industry – limited to what your account can see and to LinkedIn's monthly search allowance. You review the shortlist and every first message before anything is sent. What you do not get: Recruiter's extra filters and InMail credits, candidate emails or phone numbers, or an ATS. ## What AI candidate sourcing is – and what stays human Sourcing means finding people who have not applied. AI sourcing hands the repetitive part of that to software: running the searches, reading the profiles, and making a first judgment against the role's criteria. The criteria and the decisions stay with you. | Delegate to the agent | Keep with you | | --- | --- | | Turning the role into a few precise searches | The must-haves and nice-to-haves themselves | | Reading every profile the searches return | Who makes the shortlist | | Scoring each person against the criteria, with the evidence | Whether to contact someone, and what the message says | | Flagging gaps and unknowns instead of guessing | Everything after the reply – screening, interviews, the offer | | A first draft of the connection note | | It is worth doing well because sourced candidates convert. Gem's 2026 Recruiting Benchmarks report that sourced candidates are "nearly eight times more likely to be hired than inbound applicants", with direct sourcing delivering 11% of hires from 2.6% of applications. And AI is already part of the job: in LinkedIn's [Future of Recruiting 2025](https://business.linkedin.com/talent-solutions/resources/future-of-recruiting) report, 37% of organizations are integrating or experimenting with generative AI in hiring, and those using it save about 20% of their work week. ## Can you source on LinkedIn without Recruiter? Yes, within what standard search shows. Here is what each LinkedIn tier adds for sourcing: | | Standard (free) | Premium Business | Recruiter Lite | Recruiter | | --- | --- | --- | --- | --- | | People-search filters | Locations, current and past company, school, industry, connection degree, connections of, followers of, profile language, service categories, keywords | Same, plus "Actively hiring" | 20 recruiting filters, including skills and years of experience | 40+ filters, including Open to Work | | Reach | 1st, 2nd and 3rd+ degree results | Same | Up to 3rd-degree connections | The full LinkedIn network | | Monthly search allowance | Commercial Use Limit | Raised | Raised | Searches run in Recruiter | | InMail credits per month | None | 15 | 30 | 150 per seat | | Notes on connection requests | As few as three a month, 200 characters | Unlimited, 300 characters | – | – | | Hiring Assistant | No | No | No | Paid add-on | Sources: LinkedIn Help on [people search filters](https://www.linkedin.com/help/linkedin/answer/a525054), the [commercial use limit](https://www.linkedin.com/help/linkedin/answer/a564226), [InMail credits](https://www.linkedin.com/help/linkedin/answer/a543695), [personalized invitations](https://www.linkedin.com/help/linkedin/answer/a563153) and the [Recruiter plan comparison](https://www.linkedin.com/help/linkedin/answer/a417251). Two native options are worth knowing before you build anything: - **LinkedIn's own AI search.** LinkedIn now documents natural-language people search ("Finance leaders at nonprofits in Boston") where it is available on your account. It finds people. The workflow below adds what comes after the search: reading each profile, weighing the evidence and drafting a first touch for your review. - **Hiring Assistant.** This is LinkedIn's native AI agent for recruiters. It "runs dozens of searches across LinkedIn" and drafts messages, per LinkedIn's [product page](https://business.linkedin.com/hire/hiring-assistant). It is sold as an add-on to Recruiter, is not available on Recruiter Lite, and has no published price. If you already have Recruiter, start there. So is standard search plus an agent a "LinkedIn Recruiter alternative"? For the searching and reading, yes. For Recruiter's reach, recruiting filters and InMail volume, no – that is what the seat buys. ## Turn the vacancy into a search plan Not every criterion can be a filter. Split the role into what standard search can narrow and what only a profile read can confirm: | Criterion | Where it gets checked | | --- | --- | | Job title | Position filter, or keywords | | Location | Locations filter | | Employer history | Current company and past company filters | | Education | School filter | | Sector | Industry filter | | A skill or a stack | Keywords to narrow, then the profile to confirm | | Years of experience, seniority | The profile only – those filters are Recruiter features | | Open to work | The public #OpenToWork frame only. Members who share it with "Recruiters only" are visible [only to Recruiter users](https://www.linkedin.com/help/linkedin/answer/a507508) | Then budget the searches. Profile searches count toward LinkedIn's [commercial use limit](https://www.linkedin.com/help/linkedin/answer/a564226), which kicks in when your activity "indicates that you're likely using LinkedIn for commercial use, like hiring or prospecting". The allowance resets on the 1st of each month, and LinkedIn shows no counter. Premium Business, Recruiter Lite and Sales Navigator raise it. A few sharp searches beat many broad ones – more in our breakdown of [search query limits](/guides/understanding-linkedin-limits#search-query-limits). For the keyword part, LinkedIn search accepts Boolean AND, OR and NOT – see [LinkedIn Boolean search](/guides/linkedin-boolean-search), and [X-ray search](/guides/linkedin-xray-search) for the Google route. ## The worked example: one vacancy to a reviewed shortlist Everything below is illustrative: the vacancy, the candidates (A to E) and the results are invented to show the method, not taken from a live run. **The vacancy:** a Senior Backend Engineer (Go) for a payments startup, based in Germany or remote within Germany. - **Must-haves:** five or more years in backend engineering; Go used in production; located in Germany. - **Nice-to-haves:** payments or fintech experience; Kubernetes. **The search plan – two searches, no more:** | Search | Keywords | Filters | Limit | | --- | --- | --- | --- | | 1 | golang | Position "Backend Engineer", location Germany | 40 | | 2 | golang | The same, plus past company N26, Trade Republic or SumUp | 40 | The second search overlaps the first, so results are deduplicated by profile URL before anyone is read twice. **The evidence pass.** The agent reads the experience and skills of every distinct profile and applies one rule to each must-have: - **met** – the profile shows it (a role, its dates, its description); - **unknown** – the profile does not say either way; - **failed** – the profile shows the opposite. Two cases stay unknown on purpose. Go listed under skills alone does not count as production use – it needs a role or a description that mentions it. And fewer than five visible years in backend roles is not a failure, because a profile can leave earlier roles out. Then: - every must-have met → **strong**; - anything unknown, nothing failed → **possible**, with the unknown named; - any must-have failed → **excluded**, with the reason. Nice-to-haves only order people within a band. **The output – the top of the shortlist (illustrative):** | Candidate | Fit | Must-have evidence | Gaps and unknowns | Nice-to-haves | | --- | --- | --- | --- | --- | | A | Strong | Backend roles since 2019 (about 7 years); "payment APIs in Go" in the current role; Berlin, Germany | – | Payments, Kubernetes | | B | Strong | Six years in backend roles; "migrated services from Java to Go" in the current role; Munich, Germany | – | Kubernetes | | C | Possible | Backend roles since 2020 (about 6 years); Hamburg, Germany | Go appears in skills only – production use not shown | Fintech | | D | Possible | Go named in the current role; Cologne, Germany | Earlier titles say "Software Engineer" with no description – backend tenure unclear | – | | E | Excluded | Eight years of Go backend work | Located in Amsterdam, Netherlands – fails the location must-have | Payments | C and D are the point of the method: the agent marks what it could not confirm instead of rounding up. E is excluded with a reason you can check in seconds. ![Diagram: the AI candidate sourcing flow on a standard LinkedIn account. A vacancy with must-haves becomes a search plan of a few standard searches, limited by LinkedIn's monthly commercial use limit. The agent reads each profile's experience and skills and scores every must-have as met, unknown or failed. You review the resulting shortlist of strong, possible and excluded candidates. The agent drafts a first touch, and you approve each note before it is sent, within the connection-note limit.](/images/guides/ai-candidate-sourcing-flow.webp) ## Run it with an AI agent The same workflow runs from a plain-language instruction. Connect an assistant to your LinkedIn account through a ready-made [skill](/skills) in Claude Code, Codex or Cursor, the [MCP server](/mcp/overview) in Claude or ChatGPT, or the agent-friendly [CLI](/cli). Setup is covered in [giving an AI agent access to LinkedIn](/guides/linkedin-ai-agent). The agent then uses [tools](/mcp/available-tools) such as `search_people`, `fetch_person` and `send_connection_request`. A runnable instruction – treat it as a starting point, not an observed run: ```text I'm hiring a Senior Backend Engineer (Go) for a payments startup in Germany. Must-haves: 5+ years in backend engineering; Go used in production; located in Germany. Nice-to-haves: payments or fintech experience; Kubernetes. Run exactly two LinkedIn people searches, up to 40 results each: 1) keywords "golang", position "Backend Engineer", location Germany; 2) the same, with past companies N26, Trade Republic or SumUp. Deduplicate by profile URL, then read each person's experience and skills. For each must-have record met, unknown or failed, with the evidence (role, dates, company, quote). Go in the skills list alone is "unknown", not "met". Fewer visible backend years is "unknown" too – profiles can be incomplete. All met = strong; any unknown and none failed = possible; any failed = excluded. If a search fails or LinkedIn limits searching, stop and tell me – don't treat it as zero results. Stop and show me a table: name, profile URL, fit, evidence, gaps and unknowns. For strong and possible rows, draft a connection note under 200 characters that names one specific thing from their profile and the role. Send nothing until I approve each row, then report each outcome. ``` The pause before sending is part of your instruction, not a built-in approval queue – keep it explicit. ## Or orchestrate it in code The same contract in code. The scoring is a plain function over the profile fields, so the reference path needs no LLM; a comment marks where one could replace the text checks. ```typescript import LinkedApi from '@linkedapi/node'; import { readFileSync, writeFileSync } from 'node:fs'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); const NOTE_LIMIT = 200; const MIN_BACKEND_MONTHS = 60; const SEARCHES = [ { term: 'golang', filter: { position: 'Backend Engineer', locations: ['Germany'] }, limit: 40 }, { term: 'golang', filter: { position: 'Backend Engineer', locations: ['Germany'], previousCompanies: ['N26', 'Trade Republic', 'SumUp'], }, limit: 40, }, ]; const BACKEND_ROLE = /\b(backend|back-end|server-side|platform)\b/i; const ENGINEERING_ROLE = /\b(engineer|developer)\b/i; const PAYMENTS = /\b(payments?|fintech|banking)\b/i; const KUBERNETES = /\b(kubernetes|k8s)\b/i; // Text checks keep the example deterministic; an LLM call could replace them. function mentionsGo(text: string) { return /\bgolang\b/i.test(text) || /\bGo\b(?!-)/.test(text); } type TStatus = 'met' | 'unknown' | 'failed'; type TFit = 'strong' | 'possible' | 'excluded' | 'unread'; interface TCheck { status: TStatus; evidence: string; } interface TShortlistRow { name: string; profileUrl: string; fit: TFit; checks?: Record; niceToHaves?: string[]; draftNote?: string; review: { decision: 'pending' | 'approve' | 'drop'; edited: boolean }; outcome?: string; } async function findProfiles() { const profiles = new Map(); for (const params of SEARCHES) { const workflow = await linkedapi.searchPeople.execute(params); const { data, errors } = await linkedapi.searchPeople.result(workflow.workflowId); // A failed search is not an empty market – searchingNotAllowed means the monthly allowance is spent. if (errors.length > 0) throw new Error(`Search stopped: ${errors[0]?.type}`); for (const person of data ?? []) profiles.set(person.publicUrl, person.name); } return profiles; } async function readProfile(personUrl: string) { const run = await linkedapi.fetchPerson.execute({ personUrl, retrieveExperience: true, retrieveSkills: true }); const { data, errors } = await linkedapi.fetchPerson.result(run.workflowId); return { person: data, error: errors[0]?.type }; } type TProfile = NonNullable>['person']>; function checkBackendYears(person: TProfile): TCheck { const roles = person.experiences ?? []; if (roles.length === 0) return { status: 'unknown', evidence: 'No experience listed' }; const backendRoles = roles.filter((role) => BACKEND_ROLE.test(role.position) && ENGINEERING_ROLE.test(role.position)); const backendMonths = backendRoles.reduce((sum, role) => sum + role.duration, 0); if (backendMonths >= MIN_BACKEND_MONTHS) { const evidence = backendRoles.map((role) => `${role.position} at ${role.companyName} (${role.duration} months)`); return { status: 'met', evidence: evidence.join('; ') }; } // A profile can list only part of a career, so a short visible tenure is unknown, never failed. const years = Math.round(backendMonths / 12); return { status: 'unknown', evidence: `About ${years} years in roles titled backend; earlier tenure not shown` }; } function checkGoInProduction(person: TProfile): TCheck { const role = (person.experiences ?? []).find((r) => mentionsGo(`${r.position} ${r.description ?? ''}`)); if (role) return { status: 'met', evidence: `Go named in the ${role.position} role at ${role.companyName}` }; const inSkills = (person.skills ?? []).some((skill) => mentionsGo(skill.name)); return { status: 'unknown', evidence: inSkills ? 'Go in skills only – production use not shown' : 'Go not mentioned' }; } function checkLocation(person: TProfile): TCheck { if (!person.countryCode) return { status: 'unknown', evidence: 'Country not shown' }; return person.countryCode === 'DE' ? { status: 'met', evidence: person.location } : { status: 'failed', evidence: `Located in ${person.location}` }; } function assess(person: TProfile) { const checks = { backendYears: checkBackendYears(person), goInProduction: checkGoInProduction(person), location: checkLocation(person), }; const statuses = Object.values(checks).map((check) => check.status); const fit: TFit = statuses.includes('failed') ? 'excluded' : statuses.every((s) => s === 'met') ? 'strong' : 'possible'; const profileText = [ ...(person.experiences ?? []).map((role) => `${role.companyName} ${role.description ?? ''}`), ...(person.skills ?? []).map((skill) => skill.name), ].join(' '); const niceToHaves: string[] = []; if (PAYMENTS.test(profileText)) niceToHaves.push('payments or fintech'); if (KUBERNETES.test(profileText)) niceToHaves.push('Kubernetes'); return { fit, checks, niceToHaves }; } function draftNote(firstName: string, company: string) { const note = `Hi ${firstName}, your backend work at ${company} stood out. We're hiring a senior Go engineer for a payments startup in Germany – open to a short chat?`; return note.length <= NOTE_LIMIT ? note : `Hi ${firstName}, we're hiring a senior Go engineer for a payments startup in Germany – open to a short chat?`; } const FIT_ORDER: Record = { strong: 0, possible: 1, excluded: 2, unread: 3 }; async function writeShortlist(path: string) { const rows: TShortlistRow[] = []; for (const [profileUrl, name] of await findProfiles()) { const review: TShortlistRow['review'] = { decision: 'pending', edited: false }; const { person, error } = await readProfile(profileUrl); if (!person) { rows.push({ name, profileUrl, fit: 'unread', checks: { read: { status: 'unknown', evidence: error ?? 'no data' } }, review }); continue; } const { fit, checks, niceToHaves } = assess(person); const firstName = name.split(' ')[0] ?? name; const note = fit === 'excluded' ? undefined : draftNote(firstName, person.companyName); rows.push({ name, profileUrl, fit, checks, niceToHaves, draftNote: note, review }); } rows.sort((a, b) => FIT_ORDER[a.fit] - FIT_ORDER[b.fit] || (b.niceToHaves?.length ?? 0) - (a.niceToHaves?.length ?? 0)); writeFileSync(path, JSON.stringify(rows, null, 2)); } async function sendApproved(path: string) { const rows: TShortlistRow[] = JSON.parse(readFileSync(path, 'utf8')); for (const row of rows) { if (row.review.decision !== 'approve' || !row.draftNote) continue; const invite = await linkedapi.sendConnectionRequest.execute({ personUrl: row.profileUrl, note: row.draftNote }); const { errors } = await linkedapi.sendConnectionRequest.result(invite.workflowId); // noteLimitExceeded: the free note allowance is spent – decide yourself whether to send without a note. row.outcome = errors[0]?.type ?? 'sent'; } writeFileSync(path, JSON.stringify(rows, null, 2)); } ``` ```python import json import re from linkedapi import ( FetchPersonParams, LinkedApi, LinkedApiConfig, SearchPeopleParams, SendConnectionRequestParams, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) NOTE_LIMIT = 200 MIN_BACKEND_MONTHS = 60 SEARCHES = [ {"term": "golang", "filter": {"position": "Backend Engineer", "locations": ["Germany"]}, "limit": 40}, { "term": "golang", "filter": { "position": "Backend Engineer", "locations": ["Germany"], "previous_companies": ["N26", "Trade Republic", "SumUp"], }, "limit": 40, }, ] BACKEND_ROLE = re.compile(r"\b(backend|back-end|server-side|platform)\b", re.I) ENGINEERING_ROLE = re.compile(r"\b(engineer|developer)\b", re.I) PAYMENTS = re.compile(r"\b(payments?|fintech|banking)\b", re.I) KUBERNETES = re.compile(r"\b(kubernetes|k8s)\b", re.I) FIT_ORDER = {"strong": 0, "possible": 1, "excluded": 2, "unread": 3} # Text checks keep the example deterministic; an LLM call could replace them. def mentions_go(text): return bool(re.search(r"\bgolang\b", text, re.I) or re.search(r"\bGo\b(?!-)", text)) def find_profiles(): profiles = {} for params in SEARCHES: workflow = linkedapi.search_people.execute(SearchPeopleParams(**params)) result = linkedapi.search_people.result(workflow.workflow_id) # A failed search is not an empty market – searchingNotAllowed means the monthly allowance is spent. if result.errors: raise RuntimeError(f"Search stopped: {result.errors[0].type}") for person in result.data or []: profiles[person.public_url] = person.name return profiles def read_profile(person_url): run = linkedapi.fetch_person.execute( FetchPersonParams(person_url=person_url, retrieve_experience=True, retrieve_skills=True) ) result = linkedapi.fetch_person.result(run.workflow_id) return result.data, (result.errors[0].type if result.errors else None) def check_backend_years(person): roles = person.experiences or [] if not roles: return {"status": "unknown", "evidence": "No experience listed"} backend_roles = [r for r in roles if BACKEND_ROLE.search(r.position or "") and ENGINEERING_ROLE.search(r.position or "")] backend_months = sum(r.duration or 0 for r in backend_roles) if backend_months >= MIN_BACKEND_MONTHS: evidence = "; ".join(f"{r.position} at {r.company_name} ({r.duration} months)" for r in backend_roles) return {"status": "met", "evidence": evidence} # A profile can list only part of a career, so a short visible tenure is unknown, never failed. years = round(backend_months / 12) return {"status": "unknown", "evidence": f"About {years} years in roles titled backend; earlier tenure not shown"} def check_go_in_production(person): for role in person.experiences or []: if mentions_go(f"{role.position or ''} {role.description or ''}"): return {"status": "met", "evidence": f"Go named in the {role.position} role at {role.company_name}"} in_skills = any(mentions_go(skill.name or "") for skill in person.skills or []) evidence = "Go in skills only – production use not shown" if in_skills else "Go not mentioned" return {"status": "unknown", "evidence": evidence} def check_location(person): if not person.country_code: return {"status": "unknown", "evidence": "Country not shown"} if person.country_code == "DE": return {"status": "met", "evidence": person.location} return {"status": "failed", "evidence": f"Located in {person.location}"} def assess(person): checks = { "backendYears": check_backend_years(person), "goInProduction": check_go_in_production(person), "location": check_location(person), } statuses = [check["status"] for check in checks.values()] if "failed" in statuses: fit = "excluded" elif all(status == "met" for status in statuses): fit = "strong" else: fit = "possible" profile_text = " ".join( [f"{r.company_name or ''} {r.description or ''}" for r in person.experiences or []] + [skill.name or "" for skill in person.skills or []] ) nice_to_haves = [] if PAYMENTS.search(profile_text): nice_to_haves.append("payments or fintech") if KUBERNETES.search(profile_text): nice_to_haves.append("Kubernetes") return fit, checks, nice_to_haves def draft_note(first_name, company): note = ( f"Hi {first_name}, your backend work at {company} stood out. We're hiring a senior Go engineer " "for a payments startup in Germany – open to a short chat?" ) if len(note) <= NOTE_LIMIT: return note return f"Hi {first_name}, we're hiring a senior Go engineer for a payments startup in Germany – open to a short chat?" def write_shortlist(path): rows = [] for profile_url, name in find_profiles().items(): review = {"decision": "pending", "edited": False} person, error = read_profile(profile_url) if person is None: checks = {"read": {"status": "unknown", "evidence": error or "no data"}} rows.append({"name": name, "profileUrl": profile_url, "fit": "unread", "checks": checks, "review": review}) continue fit, checks, nice_to_haves = assess(person) rows.append({ "name": name, "profileUrl": profile_url, "fit": fit, "checks": checks, "niceToHaves": nice_to_haves, "draftNote": None if fit == "excluded" else draft_note(name.split(" ")[0], person.company_name), "review": review, }) rows.sort(key=lambda row: (FIT_ORDER[row["fit"]], -len(row.get("niceToHaves", [])))) with open(path, "w") as f: json.dump(rows, f, indent=2) def send_approved(path): with open(path) as f: rows = json.load(f) for row in rows: if row["review"]["decision"] != "approve" or not row.get("draftNote"): continue invite = linkedapi.send_connection_request.execute( SendConnectionRequestParams(person_url=row["profileUrl"], note=row["draftNote"]) ) errors = linkedapi.send_connection_request.result(invite.workflow_id).errors # noteLimitExceeded: the free note allowance is spent – decide yourself whether to send without a note. row["outcome"] = errors[0].type if errors else "sent" with open(path, "w") as f: json.dump(rows, f, indent=2) ``` To use it: 1. Run `writeShortlist` (or `write_shortlist`) and open the file. 2. For each row, set `review.decision` to `approve` or `drop`. If you rewrite `draftNote`, set `review.edited` to `true`. 3. Run `sendApproved` (or `send_approved`). Every call is asynchronous: `execute` starts the work and returns a workflow ID, and `result` waits for it. Action failures come back in `errors`, not as exceptions, which is why a failed search stops the run instead of reading as zero candidates. For a quick look before writing any code, the same search runs from the [CLI](/cli/commands#people): ```bash linkedin person search --term "golang" --position "Backend Engineer" --locations "Germany" --limit 40 --json ``` Search reference: [searching for people](/docs/searching-for-people). SDK setup: [Node and Python SDKs](/sdks). ## Make the first touch How you reach someone depends on your relationship to them – check the [connection degree](/guides/linkedin-connection-degrees) first: | Relationship | Route | | --- | --- | | 1st-degree connection | A direct message | | 2nd or 3rd+ | A connection request with a note | | Open Profile member | A free message – LinkedIn [caps how many](https://www.linkedin.com/help/linkedin/answer/a544787) and publishes no number | Notes are the scarce resource on a free account. LinkedIn allows a personal note on [as few as three connection requests a month](https://www.linkedin.com/help/linkedin/answer/a563153) (its Help pages have said three to five), up to 200 characters each; Premium removes the monthly cap. So spend notes on the strong rows, and send the rest without a note or after upgrading. InMail to people outside your network needs Premium (15 credits a month on Business) or Sales Navigator, which Linked API reaches through its Sales Navigator actions – details in our [LinkedIn InMail guide](/guides/linkedin-inmail). A note that works names one specific, true thing from the profile and the role in a phrase – no pasted job ad. The example note from the code – 143 characters with this name and company: > Hi Lena, your backend work at ExamplePay stood out. We're hiring a senior Go engineer for a payments startup in Germany – open to a short chat? ## Guardrails: fairness, review, pace and limits **Fairness comes first in recruiting.** Score only on job-related evidence – the must-haves you wrote down. Never ask an agent to infer age, gender, ethnicity or any other protected trait from names, photos or graduation dates, and do not use those signals to rank. Keep the evidence column, so a person can see why each candidate landed where they did. The shortlist is a recommendation; a human makes the decision. Rules on automated hiring tools exist in some places, so check the ones that apply to you. **Review and approval are yours.** The pause before sending lives in your agent instruction or your code, as above. **Action caps are the platform's.** Linked API enforces per-action limits by category – searches, profile views, connection requests, messages – over daily, weekly or monthly periods, and you can set your own ([limits API](/docs/admin-limits)). LinkedIn publishes no weekly invitation number. If you go over, it may restrict invitations, and that restriction ["typically lasts one week"](https://www.linkedin.com/help/linkedin/answer/a550555). Our [connection limit guide](/guides/linkedin-connection-limit-2026) has the details. **LinkedIn's position.** LinkedIn's User Agreement does not permit third-party automation, and any automation carries account risk – what LinkedIn says, and what happens if an account is restricted, is in [LinkedIn account restricted](/guides/linkedin-account-restricted). What keeps the risk in check is how this runs: your own account, a dedicated cloud browser, a human pace, limits you configure, and a person approving each message. ## What this does not do - It does not give you Recruiter projects, pipelines or Recruiter-only filters. - It does not find candidate email addresses or phone numbers. - It is not an ATS, and it does not track candidates through your hiring stages. - It does not chart a whole talent market across companies – that is [talent mapping](/guides/talent-mapping-recruitment). - It works only with what your connected account can see on LinkedIn. - Drafts need a human eye before they go out. ## Frequently Asked Questions (FAQ) #### What is AI candidate sourcing? It is using AI to find people for a role who have not applied: turning the role into searches, reading profiles against the criteria, and producing a shortlist with the evidence for each person. A recruiter still decides who to contact and what to say. #### Can I source candidates on LinkedIn without Recruiter? Yes. Standard LinkedIn search filters by title, location, current and past company, school and industry, and an agent can read each profile for the criteria the filters cannot cover. The trade-offs are a monthly search allowance, no recruiting filters such as years of experience, and no InMail on a free account. #### Is there a free alternative to LinkedIn Recruiter? Standard search plus an AI agent covers the searching and reading that Recruiter speeds up. It does not replace what the seat buys: reach across the full network, 40+ recruiting filters, Open to Work signals shared only with recruiters, and InMail volume. #### How many people can I search on a free LinkedIn account? LinkedIn does not publish the number. Profile searches count toward a monthly commercial use limit that resets on the 1st of each month, and LinkedIn shows no counter. Premium Business, Recruiter Lite and Sales Navigator raise the allowance. #### How do I contact candidates without InMail? Message your 1st-degree connections directly, and send everyone else a connection request – with a short personal note while your monthly note allowance lasts. Members with an Open Profile can also be messaged for free, within a limit LinkedIn does not publish. #### Does LinkedIn's Hiring Assistant work without Recruiter? No. LinkedIn sells Hiring Assistant as an add-on to LinkedIn Recruiter; it is not available on Recruiter Lite or on standard and Premium accounts. #### Will an AI sourcing agent get my LinkedIn account restricted? Any automation carries some risk. What keeps it in check is how it runs: on your own account, in a dedicated cloud browser, at a human pace, within per-action limits you configure, with a person approving each message. #### Does this find candidate emails? No. It works with what LinkedIn shows your account – names, headlines, experience, skills and locations – and contacts people on LinkedIn itself. For emails you would need a separate contact-data provider. ## Build it in, or hand it to an agent Use it both ways. Build the sourcing workflow into your own tools with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). For the sales version of the same workflow, see [AI for sales prospecting on LinkedIn](/guides/ai-sales-prospecting-linkedin). *Facts verified October 16, 2026 – LinkedIn plan features, search and invitation limits checked against LinkedIn Help and LinkedIn's product pages; Gem's benchmark figures checked against its published report on that date.* ## Talent Mapping in Recruitment: Build a LinkedIn Market Map A market map tells you where the people you want work before a vacancy forces the question. Most guides describe one. This one builds one from LinkedIn, and is plain about what that data can and cannot tell you. > **The short version.** Talent mapping in recruitment charts the external market for a role: which companies employ the kind of people you want, where they are based, and who they are – before you need them. Market mapping is the company-level picture; talent mapping adds the named people. An AI agent can draft one from LinkedIn in your own account. It finds the target companies, lists the people in matching roles at each, reads a few representative profiles, and records what it could not confirm. The result is an observed map – what LinkedIn returns to your account, capped and dated – not a headcount. It leaves out salary data, contact details and anything about protected characteristics. ## What talent mapping means in recruitment The term covers three different things, and only two of them are about hiring from outside: | Term | What it produces | Covered here | | --- | --- | --- | | Talent mapping (external) | Named people in a defined market, profiled against a role | Yes | | Market mapping | A picture of the market itself – companies, team structures, where talent is concentrated | Yes | | Internal talent mapping | Your current staff against future needs – succession plans, the 9-box grid | No – that is a workforce-planning exercise | Recruiter-training company SocialTalent draws the line well: "Market mapping is the systematic analysis of the talent landscape in a given market … Where talent mapping produces a list of named individuals, market mapping produces a picture of the market itself" ([SocialTalent](https://www.socialtalent.com/?p=93152)). In agency work the two overlap, and most maps carry both. Why recruiters build them: - **To win the mandate.** TalentMaps' agency guide puts it bluntly: "command of the market is often what wins a retained or exclusive assignment over a contingent scrap." - **To sell intelligence.** QX Global and Recruiterflow both describe market maps used for business development and client acquisition, not only for live searches. - **To show coverage.** Leonar's executive-search workflow ends with presenting the map to the client as evidence of where the search looked. The pressure behind it is real: in LinkedIn's [January 2026 research](https://news.linkedin.com/2026/LinkedIn-Research-Talent-2026), two-thirds of recruiters said qualified talent had become harder to find, and 93% planned to increase their use of AI in 2026. ## What goes in a market map A layout you can copy. Each row is one company, or one person at a company: | Group | Columns | | --- | --- | | Company | Company · HQ · Company size (LinkedIn's company-wide figure) · Fit tier (A, B, C) | | People | Name · Current title · Location · Profile URL · Time in role · Previous company | | Judgment | In scope (yes, borderline, no) · Notes | | Provenance | Search used · Cap · Date retrieved · Coverage status · Next research step | Two notes. Salary bands and "availability" are common extra columns, but LinkedIn data does not supply them – fill them from your own sources or leave them out. And the provenance columns are what make a map trustworthy: Leonar's coverage table records evidence with a date and marks verified versus unknown, and HeroHunt advises storing the exact search criteria and the date each search ran. A map without provenance cannot be refreshed or defended. ## Observed, not a headcount Even LinkedIn's own paid tool is careful here: Talent Insights reports the "Number of LinkedIn members in the target talent pool" ([LinkedIn Help](https://www.linkedin.com/help/talent-insights/answer/a189016)). A count drawn from LinkedIn is members who list that company and title, not the company's real team. So the map below records what was observed, under rules that keep it honest: - **Retrieved matches, with the cap and the date.** "11 Data Engineer matches in London, cap 25, retrieved 16 October" – not "11 data engineers work there." - **A cell that hits the cap reads "at least N".** There may be more. - **A search that failed is "unknown", never zero.** It goes back on the list. - **Zero found is "none found under this search".** It is not "no talent here" – people use other titles. - **Company size stays separate.** The company page's figure covers the whole company and is labelled that way. - **Every cell is dated.** People move; refresh before you reuse a map. ## The LinkedIn-native routes Before building anything, know what LinkedIn gives you directly: - **The People tab on a company page (free).** LinkedIn Help describes it as offering "employee insights on their job title, areas of study, skills, and how you're connected with them" ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a550270)). It is the fastest way to look at one company. Viewing member profiles there counts toward LinkedIn's [commercial use limit](https://www.linkedin.com/help/linkedin/answer/a564226). - **Standard people search** with current-company and location filters – the same allowance applies. It resets at midnight PST on the 1st of each month, and LinkedIn publishes no number. More in our breakdown of [search query limits](/guides/understanding-linkedin-limits#search-query-limits). - **LinkedIn Talent Insights.** LinkedIn's paid talent-intelligence product, separate from Recruiter, with aggregate pools by title, location, company and skill; pricing is through LinkedIn's sales team. It is the right tool when you need market-wide totals and trends rather than named people. The method below sits between the two: named people and companies, built from what your own account can see, with every gap recorded. ## The worked example: a market map for one brief Everything below is illustrative: the companies (A to F), the people and the numbers are invented to show the method, not taken from a live run. **The client goal:** an agency is preparing a pitch for a client hiring Senior Data Engineers in London fintech. **What this map actually covers – say it on the map itself:** London-based people with "Data Engineer" title matches, at fintech companies headquartered in London with 201–1,000 employees, drawn from a capped sample of the first 30 companies the search returns. - **Seniority is unverified** except for the representative profiles read, so the map gives no "senior" count. - **London teams of companies headquartered elsewhere are outside this sample.** That is a named coverage gap with its own next step. **The steps:** 1. **The company universe.** A company search for the keyword `fintech`, sizes 201–500 and 501–1,000, headquartered in London, limit 30. 2. **People per company.** For each company, employees with the position "Data Engineer" located in London, capped at 25. 3. **Representative reads.** The experience of up to three people per company: their title and time in role at that company, and their previous employer. People can hold more than one current role, so only the role at the mapped company counts. **The map (illustrative, retrieved 16 October 2026):** | Company | Company size (whole company, LinkedIn) | Data Engineer matches in London (cap 25) | Representative people | Status | Next step | | --- | --- | --- | --- | --- | --- | | Company A | 640 | 11 | Person 1: Senior Data Engineer, 2 years in role, previously at a bank · Person 2: Data Engineer, 8 months, previously an analytics engineer | Observed | Read two more profiles for seniority | | Company B | 920 | At least 25 | Person 3: Lead Data Engineer, 3 years in role | Cap reached | Rerun with a higher cap or split by title | | Company C | 310 | 6 | Person 4: Data Engineer, 1 year, previously at a consultancy | Observed | – | | Company D | 450 | 0 | – | None found under this search | Try title variants: Analytics Engineer, Data Platform Engineer | | Company E | 280 | – | – | Unknown – the search failed | Rerun next month | | Company F | 870 | 4 | Person 5: Senior Data Engineer, 4 years in role | Observed | – | *Not in this sample: London teams of fintechs headquartered outside London. Next step: a second company search, or a people search by current company and location.* What the map tells the client: where observed Data Engineer title matches cluster within the sample (A and B); which companies need a second pass with title variants (D) or a higher cap (B); and what is still unknown – seniority beyond the profiles read, company E, and the London teams of firms based elsewhere. No headcounts, and no senior count. ![Diagram: how an observed market map is built from LinkedIn. A brief becomes a company universe from a company search, then people per company filtered by role and location under a cap, then a few representative profile reads, then the map. A legend shows the four cell states: retrieved N matches, cap reached meaning at least N, none found under this search, and unknown when the search failed.](/images/guides/talent-mapping-observed-map.webp) ## Run it with an AI agent The same map can be built from a plain-language instruction. Connect an assistant to your LinkedIn account through a ready-made [skill](/skills) in Claude Code, Codex or Cursor, the [MCP server](/mcp/overview) in Claude or ChatGPT, or the agent-friendly [CLI](/cli) – setup is covered in [giving an AI agent access to LinkedIn](/guides/linkedin-ai-agent). It then uses [tools](/mcp/available-tools) such as `search_companies`, `fetch_company` and `fetch_person`. A runnable instruction – a starting point, not an observed run: ```text Build a market map for Data Engineers in London fintech. 1) Search companies: keyword "fintech", sizes 201-500 and 501-1000, headquartered in London. Take the first 30 results – this is a capped sample, say so. 2) For each company, list employees with position "Data Engineer" located in London, up to 25. Record the number retrieved, the cap and today's date. If you hit 25, write "at least 25". If the search fails or LinkedIn limits it, write "unknown" – never 0. If it returns nothing, write "none found". 3) For up to 3 people per company, read their experience: their title and time in role at THIS company (ignore other current jobs), and their last employer before it. Note seniority only where that title shows it. Never report a headcount or a count of senior people. Show the company-wide size separately and label it. Do not infer gender, ethnicity, age or any other protected trait. Note that London teams of companies headquartered elsewhere are not covered. Stop and show me the map as a table before doing anything else. ``` ## Or build it in code The same contract in code. Every row carries its cap, date and status, and a failed retrieval stays a failure instead of becoming an empty list. ```typescript import LinkedApi from '@linkedapi/node'; import { writeFileSync } from 'node:fs'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); const COMPANY_SAMPLE = 30; const PEOPLE_CAP = 25; const REPRESENTATIVES = 3; const ROLE = 'Data Engineer'; const LOCATION = 'London'; type TCellStatus = 'observed' | 'capReached' | 'noneFound' | 'failed'; interface TRepresentative { name: string; profileUrl: string; currentTitle?: string; monthsInRole?: number; previousCompany?: string; } interface TMapRow { company: string; companyUrl: string; companySizeWholeCompany?: number; retrieved?: number; cap: number; status: TCellStatus; error?: string; representatives: TRepresentative[]; retrievedAt: string; } async function findCompanies() { const workflow = await linkedapi.searchCompanies.execute({ term: 'fintech', filter: { sizes: ['201-500', '501-1000'], locations: [LOCATION] }, limit: COMPANY_SAMPLE, }); const { data, errors } = await linkedapi.searchCompanies.result(workflow.workflowId); if (errors.length > 0) throw new Error(`Company search stopped: ${errors[0]?.type}`); return data ?? []; } function isSameCompany(a: string, b: string) { return a.trim().toLowerCase() === b.trim().toLowerCase(); } // People can hold several current roles, so evidence comes only from the mapped company. async function readRepresentative(name: string, profileUrl: string, companyName: string): Promise { const run = await linkedapi.fetchPerson.execute({ personUrl: profileUrl, retrieveExperience: true }); const { data: person } = await linkedapi.fetchPerson.result(run.workflowId); const roles = person?.experiences ?? []; const current = roles.find((role) => role.endTime === null && isSameCompany(role.companyName, companyName)); const previous = roles.find((role) => role.endTime !== null && !isSameCompany(role.companyName, companyName)); return { name, profileUrl, currentTitle: current?.position, monthsInRole: current?.duration, previousCompany: previous?.companyName, }; } async function mapCompany(companyUrl: string, companyName: string): Promise { const retrievedAt = new Date().toISOString().slice(0, 10); const run = await linkedapi.fetchCompany.execute({ companyUrl, retrieveEmployees: true, employeesRetrievalConfig: { limit: PEOPLE_CAP, filter: { position: ROLE, locations: [LOCATION] } }, }); const { data: company, errors } = await linkedapi.fetchCompany.result(run.workflowId); const base = { company: companyName, companyUrl, cap: PEOPLE_CAP, representatives: [], retrievedAt }; // A failed or limited retrieval is unknown, never zero. if (errors.length > 0 || !company?.employees) { return { ...base, companySizeWholeCompany: company?.employeesCount, status: 'failed', error: errors[0]?.type ?? 'no employee data' }; } const people = [...new Map(company.employees.map((person) => [person.publicUrl, person])).values()]; const status: TCellStatus = people.length === 0 ? 'noneFound' : people.length >= PEOPLE_CAP ? 'capReached' : 'observed'; const representatives = []; for (const person of people.slice(0, REPRESENTATIVES)) { representatives.push(await readRepresentative(person.name, person.publicUrl, companyName)); } return { ...base, companySizeWholeCompany: company.employeesCount, retrieved: people.length, status, representatives }; } async function buildMap(path: string) { const rows: TMapRow[] = []; for (const company of await findCompanies()) { rows.push(await mapCompany(company.publicUrl, company.name)); } writeFileSync(path, JSON.stringify({ scope: `${ROLE} title matches in ${LOCATION}, sample of ${COMPANY_SAMPLE} companies`, rows }, null, 2)); } ``` ```python import json from datetime import date from linkedapi import ( FetchCompanyParams, FetchPersonParams, LinkedApi, LinkedApiConfig, SearchCompaniesParams, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) COMPANY_SAMPLE = 30 PEOPLE_CAP = 25 REPRESENTATIVES = 3 ROLE = "Data Engineer" LOCATION = "London" def find_companies(): workflow = linkedapi.search_companies.execute( SearchCompaniesParams( term="fintech", filter={"sizes": ["201-500", "501-1000"], "locations": [LOCATION]}, limit=COMPANY_SAMPLE, ) ) result = linkedapi.search_companies.result(workflow.workflow_id) if result.errors: raise RuntimeError(f"Company search stopped: {result.errors[0].type}") return result.data or [] def is_same_company(a, b): return (a or "").strip().lower() == (b or "").strip().lower() # People can hold several current roles, so evidence comes only from the mapped company. def read_representative(name, profile_url, company_name): run = linkedapi.fetch_person.execute(FetchPersonParams(person_url=profile_url, retrieve_experience=True)) person = linkedapi.fetch_person.result(run.workflow_id).data roles = (person.experiences if person else None) or [] current = next( (r for r in roles if r.end_time is None and is_same_company(r.company_name, company_name)), None ) previous = next( (r for r in roles if r.end_time is not None and not is_same_company(r.company_name, company_name)), None ) return { "name": name, "profileUrl": profile_url, "currentTitle": current.position if current else None, "monthsInRole": current.duration if current else None, "previousCompany": previous.company_name if previous else None, } def map_company(company_url, company_name): row = { "company": company_name, "companyUrl": company_url, "cap": PEOPLE_CAP, "representatives": [], "retrievedAt": date.today().isoformat(), } run = linkedapi.fetch_company.execute( FetchCompanyParams( company_url=company_url, retrieve_employees=True, employees_retrieval_config={"limit": PEOPLE_CAP, "filter": {"position": ROLE, "locations": [LOCATION]}}, ) ) result = linkedapi.fetch_company.result(run.workflow_id) company = result.data row["companySizeWholeCompany"] = company.employees_count if company else None # A failed or limited retrieval is unknown, never zero. if result.errors or company is None or company.employees is None: row["status"] = "failed" row["error"] = result.errors[0].type if result.errors else "no employee data" return row people = list({person.public_url: person for person in company.employees}.values()) if not people: row["status"] = "noneFound" elif len(people) >= PEOPLE_CAP: row["status"] = "capReached" else: row["status"] = "observed" row["retrieved"] = len(people) row["representatives"] = [read_representative(p.name, p.public_url, company_name) for p in people[:REPRESENTATIVES]] return row def build_map(path): rows = [map_company(company.public_url, company.name) for company in find_companies()] scope = f"{ROLE} title matches in {LOCATION}, sample of {COMPANY_SAMPLE} companies" with open(path, "w") as f: json.dump({"scope": scope, "rows": rows}, f, indent=2) ``` Run `buildMap` (or `build_map`) and open the file: one row per company, each with its status, cap and date. Calls are asynchronous – `execute` starts the work and `result` waits for it – and action failures come back in `errors`, not as exceptions. That is how a failed retrieval reaches the map as `failed` instead of a misleading zero. References: [searching for companies](/docs/searching-for-companies), [retrieving company data](/docs/retrieving-company-data), what a company read returns in our [company data guide](/guides/linkedin-company-scraper), and the [Node and Python SDKs](/sdks). ## Budget the search allowance A map spends LinkedIn's monthly search allowance faster than any single search, so cap it at three levels: - **Companies.** TalentMaps suggests 30 to 60 target companies for a map; start at the low end. - **People per company.** A cap of 25 tells you where talent clusters; raise it only for the companies that hit it. - **Profile reads.** Two or three representatives per company are enough to judge what the titles mean. Linked API also enforces its own per-action limits, which you can set ([limits API](/docs/admin-limits)). If the allowance runs out mid-map, the unfinished companies are marked unknown and can resume next month – or sooner with Premium Business, Recruiter Lite or Sales Navigator, which LinkedIn says raise it. ## What the map leaves out: fairness and data minimisation - **No inferred protected characteristics.** Do not ask an agent to guess gender, ethnicity, age or any other protected trait from names or photos, and do not build "diversity maps" that way. In its 2024 audit of AI recruitment tools, the UK ICO found tools inferring characteristics such as ethnicity from names, and said information "intentionally inferred in this way is still special category data" ([ICO audit report](https://ico.org.uk/media2/migrated/4031620/ai-in-recruitment-outcomes-report.pdf)). - **Only the fields you need.** A map needs titles, companies, locations and tenure – not everything a profile shows. The same ICO report recommends giving people privacy information within a month when their data comes from job networking sites; check which obligations apply to you. - **LinkedIn's position, stated plainly.** LinkedIn's User Agreement does not permit third-party automation, and any automation carries account risk – details in [LinkedIn account restricted](/guides/linkedin-account-restricted). What keeps the risk in check is how this runs: your own account, a dedicated cloud browser, a human pace and limits you configure. ## From map to outreach A map is research. When a vacancy opens, you qualify people from it against that specific role and contact the strongest – that workflow, with an evidence-backed shortlist and a reviewed first message, is in [AI candidate sourcing on LinkedIn](/guides/ai-candidate-sourcing-linkedin). The sales world has its own version of mapping: charting one target account's buyers, covered in our guide to the [B2B buying committee](/guides/b2b-buying-committee). ## What this does not do - It does not give headcounts – only matches observed under a cap, with a date. - It does not provide salaries, contact details or internal performance data. - It is not internal succession planning or a 9-box review. - It works only with what your connected account can see on LinkedIn. - It does not replace Talent Insights when market-wide aggregates are what you need. ## Frequently Asked Questions (FAQ) #### What is talent mapping in recruitment? It is charting the external market for a role before you hire: which companies employ the people you want, where they are, and who they are. Recruiters use maps to plan searches, win mandates and advise clients. #### What is the difference between talent mapping and market mapping? Market mapping describes the market – companies, team structures and where talent is concentrated. Talent mapping adds the named people. In agency work the two are usually combined in one map. #### What data is used in talent mapping? Company names, sizes and locations; people's titles, locations, time in role and previous employers; and provenance – which search found them and when. Salary bands and availability usually come from other sources. #### Is talent mapping the same as the 9-box? No. The 9-box grid rates current employees on performance and potential for succession planning. External talent mapping looks at people outside your company. #### How do I do talent mapping on LinkedIn for free? Use the People tab on each target company's page and standard people search with company and location filters. Both count toward LinkedIn's monthly commercial use limit, so keep the company list short and record what you could not check. #### How accurate are LinkedIn employee numbers? They count members who list the company on their profile, not the company's actual staff, so treat them as an observed sample. Even LinkedIn's Talent Insights labels its figure as the number of LinkedIn members in a talent pool. #### Can AI do talent mapping? An AI agent can draft one: run the company and people searches, read representative profiles and record the gaps. A recruiter still sets the scope, checks the map and decides what it means for the client. #### Should a talent map include diversity data? Not data inferred from names or photos. The UK ICO's audit of AI recruitment tools said ethnicity inferred that way is still special category data. Keep the map to job-related information. ## Build it in, or hand it to an agent Use it both ways. Build the mapping workflow into your own tools with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). *Facts verified October 16, 2026 – LinkedIn page, search and Talent Insights details checked against LinkedIn Help; the ICO audit quotes against the ICO's published report; competitor coverage checked against their live pages on that date.* ## B2B Buying Committee: How to Map One on LinkedIn with an AI Agent Deals rarely hang on one person. The question is who the others are at your target account, and how to find them before the deal goes quiet. > **The short version.** A B2B buying committee is the group of people at one account who shape a purchase: typically a champion who advocates for the change internally, an economic buyer who controls budget, technical and operational evaluators, the day-to-day users, a security, legal or procurement gate, and often an executive sponsor. There is no universal size – it grows with price, risk and company size. To map it, name the roles first, then find candidates for each: search the company's employees on LinkedIn by title and by how close they are to your network, read their profiles and recent posts as evidence, and mark each role as a guess with a confidence level until conversations confirm it. An AI agent or a script can run that research on your own LinkedIn account; reporting lines and budget authority are never on LinkedIn, so the map stays an informed hypothesis. ## What is a B2B buying committee? A buying committee is everyone at the customer who can move, slow or stop a purchase – the people who evaluate, approve and live with the decision. Mapping it for one target account is what sales teams call account mapping (not to be confused with partner account mapping, which compares customer lists between companies). | Role | What they care about | Clues on LinkedIn | Title keywords to search | |---|---|---|---| | Champion (advocate) | Getting the change through; mobilizes colleagues | Posts about the problem; a role in the affected team. A problem owner is only a *potential* champion until they advocate | Head of, Manager + the function | | Economic buyer | Return, cost, priority | Budget-owning seniority in the function | VP, Director, C-level of the function | | Technical or operational evaluator | Integration, security, implementation | Engineering, IT, operations, data roles | Engineer, Architect, IT, Operations | | User or workflow representative | Day-to-day fit | Hands-on titles in the team | Specialist, Analyst, Representative | | Security, legal or procurement gate | Risk, terms, vendor process | Security, legal, procurement departments | Security, Legal, Procurement, Compliance | | Executive sponsor | Strategic fit, breaking deadlocks | Executive titles above the function | CEO, COO, CRO, CFO | A few rules keep the map honest: - **There is no universal size.** Committees grow with price, risk and company size. - **One person can fill several roles** – at a smaller company the economic buyer may also be the champion. - **A title match gives a candidate, not a role.** LinkedIn's own [sales terms](https://business.linkedin.com/sell/resources/sales-terms/buying-committee) define the champion as an advocate, and that shows in behavior, not in a job title. ## Why single-threaded deals stall A deal with one contact rests on one person's calendar, priorities and job. If they go quiet, change roles or turn out not to hold the budget, the deal stalls with nobody else to call. **Multithreading in sales** means building relationships with several members of the committee, each on the questions they own, so the deal does not depend on any one of them. ## Where the map comes from | Route | Best for | Watch out for | |---|---|---| | Sales Navigator, by hand | Reps who already have a seat and map a few accounts | Creating CRM contacts – with an optional stakeholder role on an opportunity – requires Advanced Plus and CRM Sync ([LinkedIn Help](https://www.linkedin.com/help/sales-navigator/answer/a170004)); the evidence-and-confidence map is still yours to maintain | | Sales-engagement platforms with org-chart features | Teams that live in that platform, its database and their CRM – Amplemarket, for example, describes an Org Chart Skill and a deal contact gap analysis built on its own data and HubSpot | The map comes from the vendor's data; LinkedIn is an outreach channel there, not the source | | LinkedIn research on your own account (Linked API) | Agents and scripts that query LinkedIn directly and write the map in the shape you need | Bounded by what your account can see; roles stay inferred | Role-first mapping is not new – it is how good account executives already work. What changes with the last route is who does the legwork: an agent or a script runs the LinkedIn research, and you review the result. ## How to map a buying committee from LinkedIn 1. **Name the roles and title keywords** for this deal, using the table above. A RevOps tool and a security product have different committees. 2. **Pull candidates per role.** - Search the company's employees by `position` keyword, one search per keyword, optionally narrowed by location and by connection degree (`1st`, `2nd`, `3rd+`). One search returns up to 1,000 people. - Add the decision makers LinkedIn lists for the company – up to 20. The reference for both is in the docs: [company employees](/docs/action-st-retrieve-company-employees) and [decision makers](/docs/action-st-retrieve-company-dms). 3. **Read the evidence.** For the strongest candidates, open the profile (current position, experience) and recent posts. 4. **Assign roles with a confidence level** and the evidence behind each: - **high** – the role is stated or clearly shown: a matching title plus posts or experience that fit; - **medium** – the title fits and nothing contradicts it; - **low** – a title-keyword match only. A champion is confirmed only by what they do in conversations, never by title. 5. **Mark warm paths.** 1st-degree candidates you can message directly. For 2nd-degree candidates, look up your mutual connections – the label shows proximity, not a named introducer. 6. **Find the gaps** – roles with no candidate – and decide whom to research next. 7. **Write the map** to your CRM or a sheet. A person reviews it before any outreach. ## Run it with an AI agent The research runs from a plain-language instruction. Connect an assistant such as Claude to your account through the [MCP server](/mcp/available-tools) – `fetch_company` with employees and decision makers, `fetch_person` for profiles and posts – the agent-friendly [CLI](/cli), or a ready-made [skill](/skills). Setup is covered in [giving an AI agent access to LinkedIn](/guides/linkedin-ai-agent). A runnable instruction. It is a starting point, not a record of an observed run: ```text Map the buying committee at Example Target (https://www.linkedin.com/company/example-target) for a RevOps analytics product. Roles and title keywords: - Champion (candidate): "Head of Revenue Operations", "RevOps Manager" - Economic buyer: "VP Sales", "Chief Revenue Officer" - Technical evaluator: "Salesforce Administrator", "Data Engineer" - User: "Sales Operations Analyst" - Security, legal or procurement: "Procurement", "Security" For each keyword, search the company's employees by position and note each person's connection degree. Also list the decision makers LinkedIn shows for the company. For the top 3 candidates per role, read their profile and posts from the last 90 days. Return a table: name, profile URL, inferred role, confidence (high / medium / low) with the evidence and links behind it, connection degree. Then list the roles with no candidate. Do not send any invitations or messages – stop for my review. ``` The pause before outreach is part of your instruction, not a built-in approval queue – keep it explicit. ## Or build it in code The same research as a script. It runs one employee search per title keyword, adds LinkedIn's decision makers, reads recent posts for the closest candidates, and writes `committee-map.json` with candidates, confidence, gaps and any searches that failed. The role rules are deterministic – change them for your deal. ```typescript import LinkedApi from '@linkedapi/node'; import { writeFileSync } from 'node:fs'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); const COMPANY_URL = 'https://www.linkedin.com/company/example-target'; const PROBLEM_TERMS = /pipeline|forecast|crm/i; const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000; const ROLES = [ { role: 'champion (candidate)', keywords: ['Head of Revenue Operations', 'RevOps Manager'] }, { role: 'economic buyer', keywords: ['VP Sales', 'Chief Revenue Officer'] }, { role: 'technical evaluator', keywords: ['Salesforce Administrator', 'Data Engineer'] }, { role: 'user', keywords: ['Sales Operations Analyst'] }, { role: 'security, legal or procurement', keywords: ['Procurement', 'Security'] }, ]; interface TCandidate { role: string; name: string; profileUrl: string; headline: string; connectionDegree: string | null; confidence: 'high' | 'medium' | 'low'; evidence: Array; } async function employeesFor(keyword: string) { const workflow = await linkedapi.fetchCompany.execute({ companyUrl: COMPANY_URL, retrieveEmployees: true, employeesRetrievalConfig: { limit: 25, filter: { position: keyword } }, }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); return { employees: data?.employees ?? [], errorTypes: errors.map((error) => error.type) }; } async function decisionMakers() { const workflow = await linkedapi.fetchCompany.execute({ companyUrl: COMPANY_URL, retrieveDMs: true }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); if (errors.length > 0) console.warn('decision makers', errors.map((error) => error.type)); return (data?.dms ?? []).map((dm) => ({ name: dm.name, profileUrl: dm.publicUrl, headline: dm.headline, connectionDegree: dm.viewerState.connectionDegree, })); } async function recentProblemPost(profileUrl: string) { const workflow = await linkedapi.fetchPerson.execute({ personUrl: profileUrl, retrievePosts: true, postsRetrievalConfig: { limit: 5, since: new Date(Date.now() - NINETY_DAYS_MS).toISOString() }, }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); if (errors.length > 0) console.warn(profileUrl, errors.map((error) => error.type)); return data?.posts?.find((post) => PROBLEM_TERMS.test(post.text ?? '')); } async function mapCommittee() { const candidates: Array = []; const failedSearches: Array<{ role: string; keyword: string; errorTypes: Array }> = []; for (const { role, keywords } of ROLES) { for (const keyword of keywords) { const { employees, errorTypes } = await employeesFor(keyword); if (errorTypes.length > 0) failedSearches.push({ role, keyword, errorTypes }); for (const employee of employees) { const isTitleMatch = employee.headline.toLowerCase().includes(keyword.toLowerCase()); candidates.push({ role, name: employee.name, profileUrl: employee.publicUrl, headline: employee.headline, connectionDegree: employee.viewerState.connectionDegree, confidence: isTitleMatch ? 'medium' : 'low', evidence: [`Matched "${keyword}" in employee search; headline: ${employee.headline}`], }); } } } const closest = candidates.filter( (candidate) => candidate.confidence === 'medium' && (candidate.connectionDegree === '1st' || candidate.connectionDegree === '2nd'), ); for (const candidate of closest) { const post = await recentProblemPost(candidate.profileUrl); if (post) { candidate.confidence = 'high'; candidate.evidence.push(`Recent post on the problem: ${post.url}`); } } const emptyRoles = ROLES.map(({ role }) => role).filter((role) => !candidates.some((candidate) => candidate.role === role)); const isFullySearched = (role: string) => !failedSearches.some((search) => search.role === role); const map = { company: COMPANY_URL, candidates, decisionMakers: await decisionMakers(), gaps: emptyRoles.filter(isFullySearched), incompleteRoles: emptyRoles.filter((role) => !isFullySearched(role)), failedSearches, }; writeFileSync('committee-map.json', JSON.stringify(map, null, 2)); } mapCommittee(); ``` ```python import json import re from datetime import datetime, timedelta, timezone from linkedapi import FetchCompanyParams, FetchPersonParams, LinkedApi, LinkedApiConfig linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) COMPANY_URL = "https://www.linkedin.com/company/example-target" PROBLEM_TERMS = re.compile(r"pipeline|forecast|crm", re.I) ROLES = [ {"role": "champion (candidate)", "keywords": ["Head of Revenue Operations", "RevOps Manager"]}, {"role": "economic buyer", "keywords": ["VP Sales", "Chief Revenue Officer"]}, {"role": "technical evaluator", "keywords": ["Salesforce Administrator", "Data Engineer"]}, {"role": "user", "keywords": ["Sales Operations Analyst"]}, {"role": "security, legal or procurement", "keywords": ["Procurement", "Security"]}, ] def degree(person): return person.viewer_state.connection_degree if person.viewer_state else None def employees_for(keyword): workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url=COMPANY_URL, retrieve_employees=True, employees_retrieval_config={"limit": 25, "filter": {"position": keyword}}, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) employees = (result.data.employees if result.data else None) or [] return employees, [error.type for error in result.errors or []] def decision_makers(): workflow = linkedapi.fetch_company.execute(FetchCompanyParams(company_url=COMPANY_URL, retrieve_dms=True)) result = linkedapi.fetch_company.result(workflow.workflow_id) if result.errors: print("decision makers", [error.type for error in result.errors]) dms = (result.data.dms if result.data else None) or [] return [ {"name": dm.name, "profileUrl": dm.public_url, "headline": dm.headline, "connectionDegree": degree(dm)} for dm in dms ] def recent_problem_post(profile_url): since = (datetime.now(timezone.utc) - timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ") workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url=profile_url, retrieve_posts=True, posts_retrieval_config={"limit": 5, "since": since}, ) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.errors: print(profile_url, [error.type for error in result.errors]) posts = (result.data.posts if result.data else None) or [] return next((post for post in posts if PROBLEM_TERMS.search(post.text or "")), None) def map_committee(): candidates = [] failed_searches = [] for entry in ROLES: for keyword in entry["keywords"]: employees, error_types = employees_for(keyword) if error_types: failed_searches.append({"role": entry["role"], "keyword": keyword, "errorTypes": error_types}) for employee in employees: headline = employee.headline or "" candidates.append({ "role": entry["role"], "name": employee.name, "profileUrl": employee.public_url, "headline": headline, "connectionDegree": degree(employee), "confidence": "medium" if keyword.lower() in headline.lower() else "low", "evidence": [f'Matched "{keyword}" in employee search; headline: {headline}'], }) for candidate in candidates: if candidate["connectionDegree"] not in ("1st", "2nd") or candidate["confidence"] != "medium": continue post = recent_problem_post(candidate["profileUrl"]) if post: candidate["confidence"] = "high" candidate["evidence"].append(f"Recent post on the problem: {post.url}") empty_roles = [entry["role"] for entry in ROLES if not any(c["role"] == entry["role"] for c in candidates)] failed_roles = {search["role"] for search in failed_searches} committee_map = { "company": COMPANY_URL, "candidates": candidates, "decisionMakers": decision_makers(), "gaps": [role for role in empty_roles if role not in failed_roles], "incompleteRoles": [role for role in empty_roles if role in failed_roles], "failedSearches": failed_searches, } with open("committee-map.json", "w") as f: json.dump(committee_map, f, indent=2) map_committee() ``` Every call is asynchronous – `execute` starts the work and returns a workflow ID, `result` waits for it. Action failures, such as LinkedIn refusing a search, come back in `errors`; critical request or account failures raise a `LinkedApiError` instead ([handling results](/sdks/handling-results#handling-critical-errors)). Roles with no candidate and at least one failed search go under `incompleteRoles`, never under gaps, and `failedSearches` records every failed search – an unfinished search is not evidence that nobody holds the role. The confidence rules are deliberately conservative: a title match alone is low or medium, and only a matching title plus a recent post on the problem becomes high. Whether someone is a champion is left to your conversations. ## Multithreading without noise - **Start with your strongest relationship** and ask how the decision actually gets made – that conversation corrects the map faster than any search. - **Write to each role about what it owns.** The economic buyer cares about return and priority, the evaluator about integration, the user about daily work. One pitch to everyone reads as spam. - **Never imply a colleague shared anything** they did not. - **Choose the right first touch.** Check [the connection degree](/guides/linkedin-connection-degrees) to decide between an invitation and a message, and keep first touches reviewed – see [AI for sales prospecting](/guides/ai-sales-prospecting-linkedin). - **Watch who is already engaging.** People reacting to your posts or viewing your profile may already be on the committee – see [LinkedIn buying signals](/guides/linkedin-buying-signals). ## Limits - **Reporting lines and budget authority are not on LinkedIn.** Every role is inferred until the buyer confirms it. - **Titles are self-reported** and vary between companies. - **Employee lists are bounded** by what LinkedIn shows your account. - **A 2nd-degree label is proximity**, not an introduction. - **No emails or phone numbers** – Linked API works with what LinkedIn shows. - **Pace and limits.** Profile views and searches count toward per-action limits by category and period ([limits API](/docs/admin-limits)). The account runs in a dedicated cloud browser at a human pace. Pulling a company's employee list as data, rather than mapping a committee, is covered in the [company scraper guide](/guides/linkedin-company-scraper). ## Frequently Asked Questions (FAQ) #### Who is on a B2B buying committee? Typically a champion, an economic buyer, technical and operational evaluators, users, a security, legal or procurement gate, and often an executive sponsor. One person can hold several of these roles. #### How big is a typical buying committee? There is no universal number. It grows with the price of the purchase, the risk it carries and the size of the company. #### How do you find decision makers in a company on LinkedIn? Search the company's employees by title and seniority keywords, check the decision makers LinkedIn lists for the company, then confirm each candidate on their profile and recent posts. #### What is multithreading in sales? Building relationships with several members of the buying committee, so a deal does not depend on one contact's availability or authority. #### Can an AI agent map a buying committee? Yes, for the research: an agent can search employees, read profiles and posts and draft the map on your own account. The roles stay inferred, and a person reviews the map before any outreach. ## Build it in, or hand it to an agent Build the research into your own tools with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). *Facts verified October 12, 2026 – Sales Navigator CRM contact creation against LinkedIn Help; Amplemarket's org-chart and gap-analysis features against its live guide on that date.* ## Claude Code for LinkedIn Marketing: A Weekly Content Workflow Most "Claude for marketing" advice is about landing pages and reports. This guide is about the channel where B2B buyers talk in public every day – LinkedIn – and one workflow Claude Code can run there every week. > **The short version.** Claude Code can run the research-and-drafting half of LinkedIn marketing on a weekly loop: read what competitors and creators in your category posted, mine the comments for objections and buyer questions, write a one-page content brief, draft a post for you to edit, publish it once you approve, and then pull who engaged so the right people reach your CRM. Claude does the thinking and writing; the LinkedIn reads and actions come from a tool on your own account – Linked API's ready-made skill, its MCP server or its agent-friendly CLI. A person approves every post before it goes live. ## What Claude Code can do for LinkedIn marketing | Job | Claude's part | The LinkedIn tool behind it | |---|---|---| | Research | Read and summarize what your category posted this week | Post search and a company's recent posts | | Analyse | Cluster themes, objections and the questions buyers ask | Comments and reactions on the strongest posts | | Plan | Write a one-page brief with angles and sources | – (Claude alone) | | Draft | Write a post in your voice | – (Claude alone) | | Publish | Only after you approve the draft | Create post | | Follow up | Check each engager's current role and company, then decide who fits your buyer | Reactions and comments on your post, then person and company lookups | Claude has no LinkedIn access of its own. Everything in the right-hand column comes from a tool that works on your own account. ## Give Claude access to LinkedIn This is a short handoff – the setup pages cover it in full: - **Claude Code:** install the ready-made [LinkedIn skill](/skills/linkedin) with `npx @linkedapi/skills add linkedin`, or give Claude the agent-friendly [CLI](/cli). - **Claude desktop, claude.ai or another MCP client:** connect the [MCP server](/mcp/overview) – [installation](/mcp/installation) covers each client. - More options, and how the access works, are in [giving an AI agent access to LinkedIn](/guides/linkedin-ai-agent). Your account runs in its own dedicated cloud browser at a human pace. ## Tell Claude what you sell and how you write Generic prompts produce generic posts. Give Claude one context file it reads before every run – `marketing-context.md`. The example below is **illustrative**: a made-up RevOps analytics company, used through the rest of this guide. ```markdown # Marketing context ## Product Acme Analytics flags stale pipeline before forecast calls, for RevOps teams. ## Target buyer - Head, director or VP of Revenue Operations - Software companies with 50–500 employees - Exclude: competitors (Example Rival, Other Rival), students, recruiters, job seekers ## Post objective Start conversations with RevOps leads. Not reach, not followers. ## Category Phrase to research: "pipeline forecasting" Competitors to watch: Example Rival, Other Rival ## Voice - Short sentences. First person. One idea per post. - No emojis, no hashtags beyond two, no "game-changer" or "unlock". - Three of my past posts, for tone: 1. 2. 3. ``` Five minutes on this file is the difference between a draft you edit and a draft you rewrite. ## The weekly workflow, step by step 1. **Research.** Claude searches LinkedIn posts on your category phrase from the past week, sorted by latest, and reads each competitor's recent Page posts. The full listening method – brand mentions, competitors' people, what each search can and cannot see – is in [LinkedIn social listening](/guides/linkedin-social-listening). 2. **Mine the comments.** On the three to five strongest posts, Claude reads the comments for objections, open questions and the words buyers use. Each comment carries its author, so you can see who is asking. 3. **Write the brief.** One page: three themes with links to the posts behind them, the objections, the angle you can own given your product, and one recommended post. 4. **Draft the post** in your voice, for you to edit. Claude does not publish at this step. 5. **Publish.** After you approve the edited draft, Claude publishes it. 6. **Follow up, with evidence.** A few days later Claude reads the reactions and comments on your post. A reaction or comment carries only a name, a profile link and a headline, so for each engager Claude then looks up the person's current role and company, and the company's industry and size. Only then does it match them against your target buyer. Anyone whose company or size it cannot confirm goes on the list as **unknown – review**, not as a match. The result, with the evidence for each match, is ready for your CRM or the routing in [LinkedIn buying signals](/guides/linkedin-buying-signals). ## The prompt Three runnable instructions for Claude Code, one per moment in the week. They are a starting point, not a record of an observed run. **Monday – research, brief and draft:** ```text Read marketing-context.md first and follow it for product, buyer, objective and voice. 1. Search LinkedIn posts for the category phrase, latest, past week (up to 30). 2. Fetch each competitor's company page with its posts from the last 7 days. 3. Pick the 3–5 posts with the most comments that are actually about the category. Fetch their comments. 4. Write brief.md: - 3 themes, each with 1–2 links to the posts behind it - objections and open questions from the comments, with links - the angle we can own, given our product - one recommended post idea 5. Write draft-post.md: one post in my voice, under 1,300 characters, no links in the body. Do not publish anything. Stop and wait for my edits. ``` **After you edit the draft – publish:** ```text Publish draft-post.md exactly as it is now, and give me the post URL. ``` **Three days later – follow up:** ```text Read marketing-context.md first. 1. Fetch the reactions and comments on . 2. For each person, fetch their profile for current position and company, then fetch that company for its industry and employee count. 3. Write engagers.csv with: name, profile URL, current position, company, industry, employee count, what they did (reaction or comment), match (yes / no / unknown), and the evidence for the decision. - "yes" only if role, industry and size all match the target buyer. - "unknown" if any of them could not be confirmed – never guess. - Skip everyone in the exclusions. ``` ## What the brief looks like An **illustrative** `brief.md` for the Acme Analytics scenario – the posts and people are placeholders: ```markdown # Brief – week of 12 Oct ## Themes 1. Forecast calls are spent arguing about which deals are real (posts: , ) 2. CRM hygiene projects that never finish (post: ) 3. AI forecasting tools that nobody trusts (post: ) ## Objections and questions - "Our reps won't update stages more often – how is this different?" () - "What counts as stale – no activity, or no stage change?" () ## Angle we can own Stale is a definition problem before it is a tooling problem. Show our three signals. ## Recommended post "The three signals we use to call a deal stale – and the one we stopped using." ``` ## Run it on a schedule instead **Inside Claude Code.** Claude Code [routines](https://code.claude.com/docs/en/routines) run a saved prompt on a schedule – weekly, for example – on Anthropic's cloud, so your laptop can be closed. Create one with `/schedule` in the CLI or at claude.ai/code/routines. A routine can use the MCP connectors on your claude.ai account (connect the [MCP server](/mcp/installation) there), or skills committed to a repository it clones. Two things to know: - Routines are in research preview, on Pro, Max, Team and Enterprise plans. - They run without asking for permission, including write tools on the connectors you include. So put only the Monday research-and-draft prompt in the routine, and keep publishing as the separate instruction you run after editing. The in-session `/loop` timer is not a substitute: it only fires while Claude Code is running ([limitations](https://code.claude.com/docs/en/scheduled-tasks#limitations)). **With your own scheduler.** If you would rather own the job, the same steps work through the REST API, the [Node and Python SDKs](/sdks) or the shell [CLI](/cli/commands#posts-and-comments). A weekly cron script can collect the inputs, then hand them to whichever LLM writes your brief: ```bash #!/usr/bin/env bash set -euo pipefail linkedin post search --term "pipeline forecasting" --sort latest --date-posted pastWeek --limit 30 --json > category-posts.json since=$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) linkedin company fetch https://www.linkedin.com/company/example-rival --posts --posts-limit 20 --posts-since "$since" --json > example-rival-posts.json # For each post you want to mine: linkedin post fetch "" --comments --comments-sort mostRecent --comments-limit 50 --json > comments.json ``` The engager step for your own post is the same `post fetch` with `--reactions --comments`, followed by `linkedin person fetch` and `linkedin company fetch` for each engager before you match them. For a full scheduled collection job with deduplication, see the code in [LinkedIn buying signals](/guides/linkedin-buying-signals). ## Guardrails - **A person approves every post.** Claude drafts; you publish. Keep the stop in your instruction – there is no built-in approval queue. - **Keep the sources.** Themes and objections are Claude's interpretation, so the brief carries a link next to every claim. - **A sample, not all of LinkedIn.** Research covers what your account's search returns. Each post search needs a keyword, and LinkedIn widens narrow queries, so Claude should drop hits that are not about your category. - **Pace and limits.** Searches, profile views and posts count toward per-action limits by category and period, which you can see and set with the [limits API](/docs/admin-limits). The account runs in a dedicated cloud browser at a human pace. Automating posting itself – scheduling, the native scheduler, the posts API question – is covered in [LinkedIn post automation](/guides/how-to-automate-linkedin-posts). ## Frequently Asked Questions (FAQ) #### Can Claude Code post on LinkedIn? Yes, with a LinkedIn tool on your own account – Linked API's skill, MCP server or CLI – and with you approving the draft first. #### Can Claude read LinkedIn on its own? No. Claude needs a tool that can search and read LinkedIn on an account, such as Linked API's skill, MCP server or CLI. #### What else can Claude Code do for marketing? Plenty – landing pages, reporting scripts, email templates. This guide covers the LinkedIn part: research, briefs, drafts and engager follow-up. #### Is it safe to let Claude publish to LinkedIn? Keep a person approving every post. The actions run on your own account in a dedicated cloud browser at a human pace, within the per-action limits you set. #### Do I need to code? No. With the skill or the MCP server, the whole workflow runs from plain-language instructions, and a Claude Code routine can run the weekly research on a schedule. Code is optional; use it when you want to own the scheduled job. ## Build it in, or hand it to an agent Build the workflow into your own tools with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). ## LinkedIn Social Listening: Monitor Mentions and Competitors with AI What people say about your brand, your competitors and your category on LinkedIn is useful – if you can see it without reading the feed all day. > **The short version.** LinkedIn social listening means watching three things: posts that mention your company, what your competitors and their people publish, and the conversations in your category. LinkedIn's own tools cover a slice – your Page sees posts that tag it, and Page competitor analytics compares you with one competitor on a free Page or up to nine on Premium. Beyond that, the data comes by one of three routes: official-API Page connectors (your own Page and its comments), public-web keyword samples that monitoring tools collect, or LinkedIn's own post search run from a logged-in account. The last route is what an AI agent can run on your account every week, turning keyword searches into a digest of themes, objections and who is engaging. Every route is a sample, not every post on LinkedIn. ## What LinkedIn social listening covers – and what it can't see Listening on LinkedIn has three jobs: - **Your brand** – who mentions your company, tagged or not. - **Your competitors** – what their Pages publish, and what their people post. - **Your category** – what buyers and peers say about the problem you solve. The data reaches you by different routes, and a product may use more than one: | Route | What it sees | Limits | |---|---|---| | LinkedIn's native Page tools | Posts that @-tag your Page, under the Page's Activity tab ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a567278)). Competitor analytics for 1 competitor on a free Page or up to 9 on Premium – followers, organic content metrics, trending competitor posts from the last 30 days ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a789847)) | Untagged mentions; people rather than Pages | | Official-API Page connectors | Your own Business Page's posts, comments and insights, inside a listening suite. Mentionlytics, for example, notes that commenters come through anonymized | Not a keyword search across LinkedIn | | Public-web keyword samples | Public LinkedIn posts with your keywords that appear on the web – the keyword monitoring some suites offer, such as Mentionlytics and Brand24 | A sample; Mentionlytics states it cannot retrieve every public post with your keywords | | LinkedIn post search from a logged-in account | The posts LinkedIn's own content search returns for your keyword, as that account sees them – what an agent on your account runs | Each search needs a keyword; results are bounded per call; LinkedIn widens narrow queries; no private content | Whichever route you use, treat the result as a sample. It tells you what is being said and by whom, not how many times your brand came up across all of LinkedIn. ## Native tools, listening suites, keyword monitors or your own agent | Option | Best for | Watch out for | |---|---|---| | LinkedIn Page analytics | A free baseline for your own Page: tagged mentions and a competitor comparison | Competitor tracking depends on your Page tier; untagged mentions do not show | | Listening suites (Sprout, Brand24, Mentionlytics) | Reporting across several social networks in one dashboard | On LinkedIn they combine Page connectors with public-web keyword samples, depending on the product | | LinkedIn keyword monitors (Octolens, OutX, Buska) | Ready-made alerts on keywords, people and companies | Their coverage and sources vary; OutX is also listed as an MCP server for AI agents | | An agent or script on your own account (Linked API) | A custom weekly digest with your own searches, LLM analysis and routing into your tools | You run and schedule it; it sees what your account's search returns | Agent access is not unique to any one route. What an agent on your own account adds is control: you choose the searches, the digest format and where the results go. ## The weekly listening digest: what to watch Four watches, each one search. "Past week" and "latest" keep each run to new posts. | Watch | What to search | Linked API call | Caveat | |---|---|---|---| | **Brand mentions** (baseline, untagged included) | Your brand name as the keyword | `searchPosts` with `term: ""`, `sort: latest`, `datePosted: pastWeek` – then keep only posts whose text contains the brand | LinkedIn widens narrow queries, so check each hit | | **Tagged mentions** (optional) | The same keyword, limited to posts that tag your company | The same search plus `mentioningCompanies: [""]` | This filter is a company-mention facet combined with the keyword, so it narrows to tagged posts. Never use it as the only brand watch | | **Competitors' Pages** | What each competitor publishes | `fetchCompany` with `retrievePosts` and `postsRetrievalConfig: { limit, since }` | Page posts only | | **Competitors' people** | What their employees post about your category | `searchPosts` with your category keyword plus `authorCompanies: [""]` | Matches the author's current company as LinkedIn shows it | | **Category conversations** | Posts about the problem you solve | `searchPosts` with a problem phrase, past week, latest | Pick phrases buyers use, not your product's vocabulary | Then, for the few hits worth it, see **who engages**: `fetchPost` with `retrieveComments` and `retrieveReactions`. Comments carry LinkedIn's relative time, such as `3d`; reactions carry no time. Company and person filters accept a name, or a name with an identifier that pins the exact company. If LinkedIn's filter panel does not offer a match for the name, the search fails with `filterNotApplied` rather than returning unfiltered results. Details are in the [post search reference](/docs/action-st-search-posts). ## Run it with an AI agent The digest runs from a plain-language instruction. Give an assistant such as Claude access to your account through the [MCP server](/mcp/available-tools) – tools `search_posts`, `fetch_company` and `fetch_post` – the agent-friendly [CLI](/cli/commands#posts-and-comments), or a ready-made [skill](/skills). Setup is covered in [giving an AI agent access to LinkedIn](/guides/linkedin-ai-agent). A runnable instruction. It is a starting point, not a record of an observed run: ```text Run my weekly LinkedIn listening digest for the past week. Brand: "Acme Analytics". Competitors: "Example Rival", "Other Rival". Category phrase: "pipeline forecasting". 1. Search posts for "Acme Analytics" (latest, past week). Keep only posts whose text actually mentions Acme Analytics. Do not use the mentioning-company filter for this. 2. Fetch each competitor's company page with its posts from the last 7 days. 3. Search posts for "pipeline forecasting" from people who work at each competitor. 4. Search posts for "pipeline forecasting" (latest, past week). 5. For the three most-engaged hits, fetch the comments. Write the digest in this format: - Themes: 3–5 recurring themes, each with 1–2 post links. - Objections and questions people raised, with links. - Competitors: what each one published and what got engagement. - Notable authors and commenters worth following. - Share of the observed sample by watch (counts of the posts you kept, not LinkedIn-wide). - Items for sales: posts from people asking about the problem we solve. ``` From the terminal, the same searches look like this: ```bash linkedin post search --term "Acme Analytics" --sort latest --date-posted pastWeek --json linkedin post search --term "pipeline forecasting" --author-companies "Example Rival" --date-posted pastWeek --json ``` Use `--mentioning-companies` only for a separate tagged-mentions watch. Scheduling is yours: a weekly agent session, cron, n8n or Make. ## Or schedule it in code The same digest as a script. It runs the watches and writes the new posts to `digest-input.json` for whichever LLM you use for the summary. Posts already sent in an earlier week are skipped. ```typescript import LinkedApi, { type TSearchPostsParams } from '@linkedapi/node'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); const BRAND = 'Acme Analytics'; const COMPETITORS = [{ name: 'Example Rival', pageUrl: 'https://www.linkedin.com/company/example-rival' }]; const CATEGORY_TERM = 'pipeline forecasting'; const SEEN_FILE = 'listening-seen.json'; const OUTPUT_FILE = 'digest-input.json'; const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; const PAST_WEEK = { sort: 'latest', datePosted: 'pastWeek' } as const; interface THit { watch: string; url: string; author: string | null; time: string; text: string; reactions: number; comments: number; } async function searchWatch(watch: string, params: TSearchPostsParams): Promise> { const workflow = await linkedapi.searchPosts.execute({ limit: 50, ...params }); const { data, errors } = await linkedapi.searchPosts.result(workflow.workflowId); if (errors.length > 0) console.warn(watch, errors.map((error) => error.type)); return (data ?? []).map((post) => ({ watch, url: post.url, author: post.author?.name ?? null, time: post.time, text: post.text ?? '', reactions: post.reactionsCount, comments: post.commentsCount, })); } async function competitorPagePosts(competitor: { name: string; pageUrl: string }): Promise> { const workflow = await linkedapi.fetchCompany.execute({ companyUrl: competitor.pageUrl, retrievePosts: true, postsRetrievalConfig: { limit: 20, since: new Date(Date.now() - ONE_WEEK_MS).toISOString() }, }); const { data, errors } = await linkedapi.fetchCompany.result(workflow.workflowId); if (errors.length > 0) console.warn(competitor.name, errors.map((error) => error.type)); return (data?.posts ?? []).map((post) => ({ watch: `competitor page: ${competitor.name}`, url: post.url, author: competitor.name, time: post.time, text: post.text ?? '', reactions: post.reactionsCount, comments: post.commentsCount, })); } async function collectDigestInput() { const seen = new Set(existsSync(SEEN_FILE) ? JSON.parse(readFileSync(SEEN_FILE, 'utf8')) : []); const brandHits = await searchWatch('brand', { term: BRAND, filter: PAST_WEEK }); const hits = brandHits.filter((hit) => hit.text.toLowerCase().includes(BRAND.toLowerCase())); for (const competitor of COMPETITORS) { hits.push(...(await competitorPagePosts(competitor))); hits.push(...(await searchWatch(`competitor people: ${competitor.name}`, { term: CATEGORY_TERM, filter: { ...PAST_WEEK, authorCompanies: [competitor.name] }, }))); } hits.push(...(await searchWatch('category', { term: CATEGORY_TERM, filter: PAST_WEEK }))); const fresh = hits.filter((hit) => !seen.has(hit.url)); const excerpts = fresh.map((hit) => ({ ...hit, text: hit.text.slice(0, 500) })); writeFileSync(OUTPUT_FILE, JSON.stringify(excerpts, null, 2)); writeFileSync(SEEN_FILE, JSON.stringify([...seen, ...fresh.map((hit) => hit.url)])); } collectDigestInput(); ``` ```python import json import os from datetime import datetime, timedelta, timezone from linkedapi import FetchCompanyParams, LinkedApi, LinkedApiConfig, SearchPostsParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) BRAND = "Acme Analytics" COMPETITORS = [{"name": "Example Rival", "page_url": "https://www.linkedin.com/company/example-rival"}] CATEGORY_TERM = "pipeline forecasting" SEEN_FILE = "listening-seen.json" OUTPUT_FILE = "digest-input.json" PAST_WEEK = {"sort": "latest", "date_posted": "pastWeek"} def to_hit(watch, post, author): return { "watch": watch, "url": post.url, "author": author, "time": post.time, "text": post.text or "", "reactions": post.reactions_count, "comments": post.comments_count, } def search_watch(watch, term, filter_): workflow = linkedapi.search_posts.execute(SearchPostsParams(term=term, filter=filter_, limit=50)) result = linkedapi.search_posts.result(workflow.workflow_id) if result.errors: print(watch, [error.type for error in result.errors]) return [to_hit(watch, post, post.author.name if post.author else None) for post in result.data or []] def competitor_page_posts(competitor): since = (datetime.now(timezone.utc) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ") workflow = linkedapi.fetch_company.execute( FetchCompanyParams( company_url=competitor["page_url"], retrieve_posts=True, posts_retrieval_config={"limit": 20, "since": since}, ) ) result = linkedapi.fetch_company.result(workflow.workflow_id) if result.errors: print(competitor["name"], [error.type for error in result.errors]) posts = result.data.posts if result.data else None return [to_hit(f"competitor page: {competitor['name']}", post, competitor["name"]) for post in posts or []] def collect_digest_input(): seen = set() if os.path.exists(SEEN_FILE): with open(SEEN_FILE) as f: seen = set(json.load(f)) brand_hits = search_watch("brand", BRAND, PAST_WEEK) hits = [hit for hit in brand_hits if BRAND.lower() in hit["text"].lower()] for competitor in COMPETITORS: hits += competitor_page_posts(competitor) hits += search_watch( f"competitor people: {competitor['name']}", CATEGORY_TERM, {**PAST_WEEK, "author_companies": [competitor["name"]]}, ) hits += search_watch("category", CATEGORY_TERM, PAST_WEEK) fresh = [hit for hit in hits if hit["url"] not in seen] excerpts = [{**hit, "text": hit["text"][:500]} for hit in fresh] with open(OUTPUT_FILE, "w") as f: json.dump(excerpts, f, indent=2) with open(SEEN_FILE, "w") as f: json.dump(sorted(seen | {hit["url"] for hit in fresh}), f) collect_digest_input() ``` Every call is asynchronous – `execute` starts the work and returns a workflow ID, `result` waits for it – and action failures come back in `errors`, not as exceptions. A failed watch is logged and the rest of the digest still runs. Feed `digest-input.json` to your model with the same digest format as the agent instruction above. To pin a competitor exactly, pass `{ name, id }` with its company URN or numeric ID instead of the bare name. ## Turning the digest into decisions A digest is only worth the time if someone acts on it: - **Themes** feed your content plan – what your category argues about this week. - **Objections and questions** go to sales enablement and to the FAQ on your site. - **Competitors' launches and messaging** shape your counter-positioning before your buyers ask about them. - **People asking about the problem you solve** are sales-relevant; route them the way [LinkedIn buying signals](/guides/linkedin-buying-signals) describes, with a person reviewing before any outreach. Report shares as the share of your observed sample – "most of the competitor posts we saw were about pricing" – never as LinkedIn-wide share of voice. ## Limits and guardrails - **A sample, not a firehose.** You see what your account's search returns, bounded per call. - **A keyword is required.** Every post search needs a term, and LinkedIn widens narrow queries, so check each hit. - **No private content,** and no posts the search does not surface. - **No streaming or alerts.** You schedule the run; Linked API has no post-mention webhook. - **The analysis is the LLM's,** not LinkedIn's. Themes and sentiment are interpretations – keep the post links next to every claim. - **Pace and limits.** Searches count toward per-action limits by category and period, which you can see and set with the [limits API](/docs/admin-limits). The account runs in a dedicated cloud browser at a human pace. Need the post data itself – full text, engagers, a CSV – rather than a digest? That is the job of the [LinkedIn post scraper guide](/guides/linkedin-post-scraper#search-linkedin-posts-by-keyword). ## Frequently Asked Questions (FAQ) #### What is LinkedIn social listening? Watching what is said on LinkedIn about your brand, your competitors and your category, then turning it into decisions – content, positioning, sales enablement. #### Can you see who mentions your company on LinkedIn? Posts that tag your Page show in the Page's Activity tab. Untagged mentions only appear through keyword search – a monitoring tool's public-web sample, or LinkedIn's own post search run from an account. #### How do I monitor competitors on LinkedIn? Start with Page competitor analytics – one competitor on a free Page, up to nine on Premium. For more, collect their Page posts and their employees' posts each week by search, as in the digest above. #### Is there a LinkedIn social listening API? LinkedIn's official APIs centre on your own Page's content, and access to them is gated – see [LinkedIn API access](/guides/linkedin-api-access). Linked API runs LinkedIn's post search and company pages on your own account, so a script or an agent can collect the digest. #### Do I need a social listening tool for LinkedIn? It depends on the job. A suite fits multi-network reporting, a keyword monitor fits ready-made alerts, and an agent on your own account fits a custom digest that feeds your own tools. ## Build it in, or hand it to an agent Build the digest into your own tools with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). *Facts verified October 9, 2026 – LinkedIn Page mentions and competitor analytics against LinkedIn Help; Mentionlytics' LinkedIn monitoring routes against its help center; Brand24, Octolens, OutX and Buska descriptions against their live pages on that date.* ## AI for Sales Prospecting on LinkedIn: A Worked Agent Workflow Most advice on AI for sales prospecting stops at "let it write your emails". This guide walks one real workflow on LinkedIn from start to finish – who to look for, what to read, how to decide, what to send – and shows it two ways: run by an AI agent, and orchestrated in code. > **The short version.** AI is useful for the research-heavy half of prospecting: building a list from your ideal customer profile, reading each person's profile and recent posts, judging fit, and drafting a first note. The decision to contact and the tone stay with you. On LinkedIn that can run two ways – an AI agent working in your own account through an MCP server, an agent-friendly CLI or a ready-made skill; or your own code through a REST API, Node or Python SDKs or a CLI. Either way it reads what your account can see, a person reviews the shortlist and the notes, and each invitation is sent only after approval, at a human pace within per-action limits. ## What AI should and should not do in prospecting | Delegate to AI | Keep with a person | | --- | --- | | Building a candidate list from your ICP | Deciding who to contact | | Reading profiles and recent posts | The final tone of a note | | Writing a fit rationale with sources | Anything after the first touch | | Drafting a first note | Pricing and timing conversations | The reason for the split is practical: an agent that sends unreviewed messages at volume costs you replies, reputation and, eventually, account health. Research and drafting are where AI saves real time; judgement is where it should hand back. ## The worked example: from ICP to an approved invitation The example below is **illustrative** – the company, the offer and the sample output are invented to show the workflow, and no live invitation was sent while writing it. **The seller and the offer.** A RevOps analytics product that flags stale pipeline. The one offer sentence the note may use, at most once: *"we help RevOps teams spot stale pipeline before forecast calls"*. **The ideal customer profile.** - Title: head, director or VP of Revenue Operations. - Location: United Kingdom. - Industry: Software Development. - Company size: 50–500 employees. **The workflow, step by step.** 1. **Find candidates.** People search with the position, location and industry filters, capped at 25 results. The standard people search used here has no company-size filter, so size is checked later. To start from people already showing interest – profile viewers, post engagers – see [LinkedIn buying signals](/guides/linkedin-buying-signals). 2. **Gather evidence.** For each candidate, fetch the profile with up to five posts from the last 90 days. Then fetch their current company from the company link on the profile, which returns employee count and industry. Every fact keeps the link it came from – the profile, the company page or the post. 3. **Qualify with explicit rules.** - **Exclude** – the title is not a head, director or VP of Revenue Operations; it includes "former" or "ex-"; the company is outside 50–500 employees or not in Software Development; or the company is on your competitor list. - **Fit** – passes every ICP check. - **Strong fit** – fit, plus a post in the last 90 days that mentions pipeline, forecast, CRM or data quality. The rationale links that post. - **Unknown** – the company could not be fetched or reports no employee count. It goes to review flagged, never approved automatically. 4. **Draft a note.** At most 200 characters – [LinkedIn's limit](https://www.linkedin.com/help/linkedin/answer/a6239760) for free accounts (300 with Premium). It refers to the cited post or the role, uses the offer sentence at most once, and avoids flattery, invented familiarity and "I saw you viewed my profile". Free accounts can add a note to only a few invitations a month ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a563153)), so on a free account most of a 25-person list goes without one: once the cap is reached, invitations with a note fail with that reason, and you either send without a note or use Premium. 5. **Review.** A person reads each rationale and draft, then approves, edits or drops it. Edits are recorded. 6. **Send the approved invitations and record the result.** Each send ends as **sent**, **already pending**, **already connected**, or **failed** with the reason – for example a note over the length limit, a person who requires their email address, or a free account that has used up its personalized notes. One record per person carries the whole trail – an illustrative example: ```json { "name": "Alex Morgan", "profileUrl": "https://www.linkedin.com/in/alex-morgan-example", "rule": "strong-fit", "rationale": [ "Title: Head of Revenue Operations (https://www.linkedin.com/in/alex-morgan-example)", "Company: 180 employees, Software Development (https://www.linkedin.com/company/example-analytics)", "Recent post: https://www.linkedin.com/posts/example" ], "draftNote": "Hi Alex, your recent post on forecasting caught my attention – we help RevOps teams spot stale pipeline before forecast calls. Happy to connect.", "review": { "decision": "approve", "edited": false }, "outcome": "sent", "date": "2026-10-05" } ``` **Checking the output.** Before you trust the workflow, spot-check it: - does each cited source actually say what the rationale claims? - reject any fit or intent claim that is not backed by a link; - note what reviewers changed – repeated edits mean the rules or the template need work; - track research and review time per person. Sales value is judged later, by you, through qualified replies and meetings. ## Run it with an AI agent The same workflow runs from a plain-language instruction. Connect an assistant such as Claude to your LinkedIn account through the [MCP server](/mcp/overview), the agent-friendly [CLI](/cli) or a ready-made [skill](/skills) – setup is covered in [giving an AI agent access to LinkedIn](/guides/linkedin-ai-agent). The agent then uses [tools](/mcp/available-tools) such as `search_people`, `fetch_person`, `fetch_company` and `send_connection_request`. A runnable instruction – the agent's behaviour depends on how you set it up, so treat this as a starting point, not an observed run: ```text Find heads, directors or VPs of Revenue Operations in the United Kingdom at Software Development companies, up to 25 people. For each, read their profile and posts from the last 90 days, then check their company's employee count and industry. Apply these rules: exclude titles that are not head/director/VP of RevOps, "former" or "ex-" titles, companies outside 50–500 employees or not in Software Development, and these competitors: [list]. Mark "strong fit" if a recent post mentions pipeline, forecast, CRM or data quality, and cite that post's URL. Mark "unknown" if the company size is missing. For every fit, draft a connection note under 200 characters that refers to the cited post or the role, uses "we help RevOps teams spot stale pipeline before forecast calls" at most once, and never mentions profile views. Stop and show me a table: name, profile URL, rule, rationale with links, draft note. Send invitations only for the rows I approve, then report each outcome. ``` The pause before sending is **part of your instruction, not a built-in approval queue** – keep it explicit in the prompt. ## Or orchestrate it in code The same contract, in code. Qualification and drafting are deterministic rules here, so the reference path needs no LLM. ```typescript import LinkedApi from '@linkedapi/node'; import { readFileSync, writeFileSync } from 'node:fs'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000; const NOTE_LIMIT = 200; const COMPETITORS = ['Example Competitor Ltd']; const OFFER = 'we help RevOps teams spot stale pipeline before forecast calls'; const SIGNAL_TOPICS = [ { pattern: /pipeline/i, topic: 'pipeline' }, { pattern: /forecast/i, topic: 'forecasting' }, { pattern: /\bcrm\b/i, topic: 'CRM data' }, { pattern: /data quality/i, topic: 'data quality' }, ]; async function findCandidates() { const workflow = await linkedapi.searchPeople.execute({ term: 'revenue operations', filter: { position: 'Revenue Operations', locations: ['United Kingdom'], industries: ['Software Development'], }, limit: 25, }); const { data } = await linkedapi.searchPeople.result(workflow.workflowId); return data ?? []; } async function gatherEvidence(personUrl: string) { const personRun = await linkedapi.fetchPerson.execute({ personUrl, retrievePosts: true, postsRetrievalConfig: { limit: 5, since: new Date(Date.now() - NINETY_DAYS_MS).toISOString() }, }); const { data: person } = await linkedapi.fetchPerson.result(personRun.workflowId); if (!person) return undefined; if (!person.companyHashedUrl) return { person, company: undefined }; const companyRun = await linkedapi.fetchCompany.execute({ companyUrl: person.companyHashedUrl }); const { data: company } = await linkedapi.fetchCompany.result(companyRun.workflowId); return { person, company }; } type TEvidence = NonNullable>>; function qualify({ person, company }: TEvidence) { const title = person.position ?? ''; const titleFact = `Title: ${title} (${person.publicUrl})`; const isRevOpsLeader = /revenue operations/i.test(title) && /\b(head|director|vp|vice president)\b/i.test(title); if (!isRevOpsLeader || /\b(former|ex-)/i.test(title)) return { rule: 'exclude', rationale: [titleFact] }; if (!company?.employeesCount) return { rule: 'unknown', rationale: [titleFact, 'Company size not available'] }; const size = company.employeesCount; const companyFact = `Company: ${size} employees, ${company.industry} (${company.publicUrl})`; const isIcpCompany = size >= 50 && size <= 500 && company.industry === 'Software Development'; if (!isIcpCompany || COMPETITORS.includes(company.name)) return { rule: 'exclude', rationale: [companyFact] }; const rationale = [titleFact, companyFact]; for (const post of person.posts ?? []) { const signal = SIGNAL_TOPICS.find(({ pattern }) => pattern.test(post.text ?? '')); if (signal) return { rule: 'strong-fit', rationale: [...rationale, `Recent post: ${post.url}`], topic: signal.topic }; } return { rule: 'fit', rationale }; } function draftNote(firstName: string, topic?: string) { const opener = topic ? `your recent post on ${topic} caught my attention` : `I'm reaching out to RevOps leaders in UK software`; const note = `Hi ${firstName}, ${opener} – ${OFFER}. Happy to connect.`; return note.length <= NOTE_LIMIT ? note : `Hi ${firstName}, ${OFFER}. Happy to connect.`; } async function writeReviewFile(path: string) { const rows = []; for (const candidate of await findCandidates()) { const evidence = await gatherEvidence(candidate.publicUrl); if (!evidence) continue; const { rule, rationale, topic } = qualify(evidence); if (rule === 'exclude') continue; const firstName = candidate.name.split(' ')[0] ?? candidate.name; rows.push({ name: candidate.name, profileUrl: candidate.publicUrl, rule, rationale, draftNote: draftNote(firstName, topic), review: { decision: 'pending', edited: false }, }); } writeFileSync(path, JSON.stringify(rows, null, 2)); } async function sendApproved(path: string) { const rows = JSON.parse(readFileSync(path, 'utf8')); for (const row of rows) { if (row.review.decision !== 'approve') continue; const invite = await linkedapi.sendConnectionRequest.execute({ personUrl: row.profileUrl, note: row.draftNote }); const { errors } = await linkedapi.sendConnectionRequest.result(invite.workflowId); const errorType = errors[0]?.type; row.outcome = errorType === undefined ? 'sent' : errorType === 'alreadyPending' || errorType === 'alreadyConnected' ? errorType : `failed: ${errorType}`; row.date = new Date().toISOString().slice(0, 10); } writeFileSync(path, JSON.stringify(rows, null, 2)); } ``` ```python import json import re from datetime import datetime, timedelta, timezone from linkedapi import ( FetchCompanyParams, FetchPersonParams, LinkedApi, LinkedApiConfig, SearchPeopleParams, SendConnectionRequestParams, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) NOTE_LIMIT = 200 COMPETITORS = ["Example Competitor Ltd"] OFFER = "we help RevOps teams spot stale pipeline before forecast calls" SIGNAL_TOPICS = [ (r"pipeline", "pipeline"), (r"forecast", "forecasting"), (r"\bcrm\b", "CRM data"), (r"data quality", "data quality"), ] def find_candidates(): workflow = linkedapi.search_people.execute( SearchPeopleParams( term="revenue operations", filter={ "position": "Revenue Operations", "locations": ["United Kingdom"], "industries": ["Software Development"], }, limit=25, ) ) return linkedapi.search_people.result(workflow.workflow_id).data or [] def gather_evidence(person_url): since = (datetime.now(timezone.utc) - timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ") person_run = linkedapi.fetch_person.execute( FetchPersonParams( person_url=person_url, retrieve_posts=True, posts_retrieval_config={"limit": 5, "since": since}, ) ) person = linkedapi.fetch_person.result(person_run.workflow_id).data if person is None: return None if not person.company_hashed_url: return person, None company_run = linkedapi.fetch_company.execute(FetchCompanyParams(company_url=person.company_hashed_url)) return person, linkedapi.fetch_company.result(company_run.workflow_id).data def qualify(person, company): title = person.position or "" title_fact = f"Title: {title} ({person.public_url})" is_revops_leader = re.search(r"revenue operations", title, re.I) and re.search( r"\b(head|director|vp|vice president)\b", title, re.I ) if not is_revops_leader or re.search(r"\b(former|ex-)", title, re.I): return "exclude", [title_fact], None if company is None or not company.employees_count: return "unknown", [title_fact, "Company size not available"], None size = company.employees_count company_fact = f"Company: {size} employees, {company.industry} ({company.public_url})" if not (50 <= size <= 500 and company.industry == "Software Development") or company.name in COMPETITORS: return "exclude", [company_fact], None rationale = [title_fact, company_fact] for post in person.posts or []: for pattern, topic in SIGNAL_TOPICS: if re.search(pattern, post.text or "", re.I): return "strong-fit", [*rationale, f"Recent post: {post.url}"], topic return "fit", rationale, None def draft_note(first_name, topic): if topic: opener = f"your recent post on {topic} caught my attention" else: opener = "I'm reaching out to RevOps leaders in UK software" note = f"Hi {first_name}, {opener} – {OFFER}. Happy to connect." return note if len(note) <= NOTE_LIMIT else f"Hi {first_name}, {OFFER}. Happy to connect." def write_review_file(path): rows = [] for candidate in find_candidates(): evidence = gather_evidence(candidate.public_url) if evidence is None: continue rule, rationale, topic = qualify(*evidence) if rule == "exclude": continue rows.append({ "name": candidate.name, "profileUrl": candidate.public_url, "rule": rule, "rationale": rationale, "draftNote": draft_note(candidate.name.split(" ")[0], topic), "review": {"decision": "pending", "edited": False}, }) with open(path, "w") as f: json.dump(rows, f, indent=2) def send_approved(path): with open(path) as f: rows = json.load(f) for row in rows: if row["review"]["decision"] != "approve": continue invite = linkedapi.send_connection_request.execute( SendConnectionRequestParams(person_url=row["profileUrl"], note=row["draftNote"]) ) errors = linkedapi.send_connection_request.result(invite.workflow_id).errors error_type = errors[0].type if errors else None if error_type is None: row["outcome"] = "sent" elif error_type in ("alreadyPending", "alreadyConnected"): row["outcome"] = error_type else: row["outcome"] = f"failed: {error_type}" row["date"] = datetime.now(timezone.utc).date().isoformat() with open(path, "w") as f: json.dump(rows, f, indent=2) ``` Run `writeReviewFile` (or `write_review_file`) and open the file. For each row, set `review.decision` to `approve` or `drop`; if you rewrite `draftNote`, set `review.edited` to `true`. Then run `sendApproved` (or `send_approved`). Every call is asynchronous – `execute` starts the work and returns a workflow ID, `result` waits for it – and action failures come back in `errors`, not as exceptions. **Optional: an LLM for drafting.** To get richer drafts, replace `draftNote` with a call to your model that receives the person's rationale and the same constraints – the length limit, the offer sentence at most once, no flattery. Keep the length check and the review step either way. ## Which tool does which part | Part of the job | Fits best | When another route is better | | --- | --- | --- | | Research and drafting from sources you supply | A general AI assistant such as ChatGPT or Claude | It needs a LinkedIn tool for live data – on its own it works from what you paste in | | Account-visible LinkedIn research and the approved first touch | Linked API, run by an agent or from code | When your motion is a fixed multi-step sequence you would rather manage in a UI | | Email and phone sourcing | A verified contact-data provider | Linked API returns neither | | Managed follow-up after the first touch | An established CRM or sequencer | When follow-ups need multichannel timing, templates and a shared team inbox | ## Guardrails: review, pace and limits Two kinds of control keep this safe, and they are not the same thing: - **Review and approval are yours.** The pause before sending lives in your agent instruction or your code, as above. - **Action caps are the platform's.** Linked API enforces per-action limits by category – profile views, connection requests, messages, search queries – over daily, weekly or monthly periods, and you can set your own ([limits API](/docs/admin-limits)). Keep the pace human, stay inside [LinkedIn's practical limits](/guides/understanding-linkedin-limits), and treat invitations as one touch in a wider plan – more in [automating connection requests](/guides/how-to-automate-linkedin-connection-requests). Before you message instead of invite, check [the connection degree](/guides/linkedin-connection-degrees). ## What this does not do - It does not find email addresses or phone numbers. - It works only with what your connected account can see on LinkedIn. - It does not replace a sequencer's multi-step follow-up – see [LinkedIn automation tools](/blog/best-linkedin-automation-tools-2026) for that category. - Drafts need a human eye before they go out. ## Frequently Asked Questions (FAQ) #### Can AI do sales prospecting on LinkedIn? Yes, the research-heavy part: building a list from your ICP, reading profiles and recent posts, judging fit and drafting a first note. The decision to contact and the tone of the message are best kept with a person. #### How do I use ChatGPT or Claude for sales prospecting? Give the assistant LinkedIn actions: ChatGPT and Claude connect through the [MCP server](/mcp/installation); coding agents such as Claude Code, Cursor and Codex can also use the agent-friendly [CLI](/cli) or a ready-made [skill](/skills). Then describe the workflow in plain language – who to find, what to check, how to draft – and tell it to stop for your approval before sending anything. #### Will an AI agent get my LinkedIn account restricted? Any automation carries some risk. What keeps it in check is how it runs: on your own account, in a dedicated cloud browser, at a human pace, within per-action limits you configure, with a person approving each invitation. #### Do I need Sales Navigator for AI prospecting? No. The workflow above uses standard LinkedIn search and profiles. If your account has a Sales Navigator seat, the Plus plan adds Sales Navigator actions. #### What should I never let an AI do in prospecting? Send unreviewed messages at volume. An agent can research and draft at scale; whether and how to contact someone stays a human decision. ## Build it in, or hand it to an agent Use it both ways. Build the workflow into your own tools with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). ## What Is a GTM Engineer? Role, Stack and the LinkedIn Layer The title is new, the job is not: someone has to turn a go-to-market plan into systems that actually run. > **The short version.** A GTM (go-to-market) engineer builds the systems behind sales and marketing: the data flows, enrichment, routing, automations and, increasingly, AI agents that find prospects and start conversations. The role often sits inside RevOps; what typically sets it apart is that the main deliverable is a built system, composed with APIs, workflows and code, rather than configured tools. The usual stack is a CRM, an enrichment and orchestration layer, an automation runner, outreach tools and AI agents. LinkedIn is a special case: LinkedIn's official APIs cover specific programs, not member-level prospecting for most teams, so GTM engineers add an account-based layer that reads what a connected account can see and acts within its limits. ## What a GTM engineer does A GTM engineer designs and builds the machinery that moves prospects from "unknown" to "in conversation" – and keeps it running. Clay, which says it coined the term in 2023, describes GTM engineers working across RevOps, growth and customer success. The overlap with RevOps is real. GTM engineering is often embedded in RevOps, and RevOps teams own data pipelines too. The difference is typically the primary deliverable: a RevOps or sales-ops admin mostly configures the tools the team uses, while a GTM engineer mostly composes new systems out of them – connecting APIs, writing workflow logic and scripts, and wiring in AI where it saves time. Typical deliverables: - lead routing; - enrichment waterfalls; - signal-based triggers – see [LinkedIn buying signals](/guides/linkedin-buying-signals); - CRM hygiene automations; - outbound plumbing – lists, sequences and the hand-offs between them; - AI agents in the funnel – research, scoring and drafting. ## The GTM engineer stack | Layer | What it does | Typical tools | Honest note | | --- | --- | --- | --- | | **CRM** | The system of record for accounts, contacts and deals | HubSpot, Salesforce, Pipedrive | Everything else should write back here, or the stack drifts | | **Enrichment and orchestration** | Builds and enriches lists, runs waterfalls across data providers | Clay tables, data providers | Best for row-by-row enrichment; long-running jobs need a runner beside it | | **Automation runner** | Moves data between systems on triggers and schedules | n8n, Make, Zapier | Best for glue and waiting; heavy logic is easier in code | | **Outreach and sequencing** | Sends and manages multi-step email and LinkedIn sequences | Email sequencers, LinkedIn sequencers | Best for repeatable sequences; standalone actions vary by vendor | | **Data warehouse and reverse ETL** | Central data and syncs back to tools, where the team is big enough | A warehouse plus a reverse-ETL tool | Often unnecessary for small teams | | **AI agents and LLMs** | Research, scoring, drafting, triage | ChatGPT, Claude, agent frameworks | Need live data tools and a review step | | **The LinkedIn layer** | Reads LinkedIn data and acts on an account | See the next section | The hardest layer to source well | ## The LinkedIn layer: what to retrieve and what the account can do For most B2B teams LinkedIn is where the people are, so it ends up in almost every GTM system. A GTM engineer typically needs: - people and company search; - profile and company data; - posts and engagement – reactions and comments; - profile viewers; - job postings at target accounts; - invitations and messages; - connection and inbox events. **Why the official APIs rarely cover this.** LinkedIn's developer documentation on [getting access](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access) separates a small set of open permissions from partner programs that need LinkedIn's approval – sales integrations, for example, go through the Sales Navigator Application Platform (SNAP). Those programs serve specific integrations rather than member-level prospecting for most teams. The details are in [LinkedIn API access](/guides/linkedin-api-access). **The options, with honest trade-offs:** | Option | Good at | Trade-off | | --- | --- | --- | | Scrapers and datasets | Bulk public data | No account actions; freshness varies | | Campaign-oriented APIs (sequencers) | Creating and running multi-step outreach campaigns | Coverage of standalone actions varies, and some also expose inbox replies – HeyReach's public API, for example, can send a message into a LinkedIn conversation | | Account-based APIs (Linked API) | Reading what the connected account sees and acting within its limits | One account per seat, a human pace, no emails or phone numbers | **What a connected account can do through Linked API:** - search people, companies, jobs and posts; - read profiles and company pages, including employees, decision makers and posts; - react, comment, post and repost; - send invitations and messages; - monitor the inbox and the network, with events by webhook; - retrieve profile viewers, SSI and performance analytics; - run Sales Navigator actions on the Plus plan. The full list is in the [actions overview](/docs/actions-overview). ## Where the LinkedIn layer joins your stack **Clay.** Call the Linked API REST API from Clay's **HTTP API enrichment**. Linked API workflows are asynchronous – you start one, then fetch its result – and Clay's HTTP enrichment does not wait for that on its own. The default pattern: 1. Clay rows start the work with an HTTP call. 2. n8n, Make or a small backend waits for completion – polling the result, or receiving Linked API's [webhook](/docs/webhooks). 3. That runner writes the result back to Clay. The alternative is a second HTTP lookup run after the work completes. Our webhook does not update a Clay row by itself – something has to receive it and write back. There is no native Clay integration. **n8n and Make.** Use the official Linked API nodes for [n8n](/integrations/n8n/overview) and modules for [Make](/integrations/make/overview). A Zapier integration is coming but not available yet. **Scripts.** Use the Node and Python [SDKs](/sdks) or the [CLI](/cli). A minimal example – find RevOps people at a target account and read their profiles: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); async function readAccountContacts(company: string) { const search = await linkedapi.searchPeople.execute({ term: 'revenue operations', filter: { currentCompanies: [company], position: 'Revenue Operations' }, limit: 10, }); const { data: people, errors } = await linkedapi.searchPeople.result(search.workflowId); if (errors.length > 0) throw new Error(`Search failed: ${errors.map((error) => error.type).join(', ')}`); const profiles = []; for (const person of people ?? []) { const run = await linkedapi.fetchPerson.execute({ personUrl: person.publicUrl }); const { data: profile, errors: fetchErrors } = await linkedapi.fetchPerson.result(run.workflowId); if (!profile) { console.warn(person.publicUrl, fetchErrors.map((error) => error.type)); continue; } profiles.push({ name: profile.name, position: profile.position, url: profile.publicUrl }); } return profiles; } readAccountContacts('Example Co').then(console.log); ``` ```python from linkedapi import FetchPersonParams, LinkedApi, LinkedApiConfig, SearchPeopleParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) def read_account_contacts(company): search = linkedapi.search_people.execute( SearchPeopleParams( term="revenue operations", filter={"current_companies": [company], "position": "Revenue Operations"}, limit=10, ) ) result = linkedapi.search_people.result(search.workflow_id) if result.errors: raise RuntimeError(f"Search failed: {[error.type for error in result.errors]}") profiles = [] for person in result.data or []: run = linkedapi.fetch_person.execute(FetchPersonParams(person_url=person.public_url)) fetched = linkedapi.fetch_person.result(run.workflow_id) if fetched.data is None: print(person.public_url, [error.type for error in fetched.errors or []]) continue profiles.append({"name": fetched.data.name, "position": fetched.data.position, "url": fetched.data.public_url}) return profiles print(read_account_contacts("Example Co")) ``` `execute` starts the work and returns a workflow ID; `result` waits for it and returns `data` and `errors` – action failures arrive in `errors`, not as exceptions. **AI agents.** Give an agent the same capabilities through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Setup is covered in [giving an AI agent access to LinkedIn](/guides/linkedin-ai-agent), and a worked example is in [AI for sales prospecting](/guides/ai-sales-prospecting-linkedin). ## Guardrails a GTM engineer should build in - **Per-action limits.** Linked API enforces limits by action category – profile views, connection requests, messages, searches and more – over daily, weekly or monthly periods, and you can set your own ([limits API](/docs/admin-limits)). - **Human review before sends.** Keep a review step between an agent's draft and anything that reaches a person. - **Error handling and retries.** Check `errors` on every result, retry only what is safe to repeat, and treat `alreadyPending` or `alreadyConnected` as done rather than failed. - **Webhook deduplication.** A retried delivery reuses the same event `id`, so deduplicate on it ([webhooks](/docs/webhooks)). The account side is built the same way: each action runs on your own account, in a dedicated cloud browser, at a human pace. ## Frequently Asked Questions (FAQ) #### What is a GTM engineer? Someone who builds the systems behind sales and marketing – data flows, enrichment, routing, automations and AI agents – rather than only configuring the tools. #### How is a GTM engineer different from RevOps? The roles overlap, and GTM engineering often sits inside RevOps. The difference is typically the main deliverable: RevOps runs and configures the revenue stack, while a GTM engineer builds new systems on top of it with APIs, workflows and code. #### What tools does a GTM engineer use? A CRM, an enrichment and orchestration tool such as Clay, an automation runner such as n8n or Make, outreach and sequencing tools, AI assistants and agents, and an API layer for data sources such as LinkedIn. #### Does a GTM engineer need to code? It helps. Many GTM engineers work mostly in low-code runners and enrichment tools, and add scripts and API calls where those tools run out. #### How do GTM engineers automate LinkedIn? With an account-based layer: an API or agent tool that reads what a connected account can see and acts on it within per-action limits, with a review step before anything is sent. ## Build it in, or hand it to an agent Build the LinkedIn layer into your own systems with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). *Facts verified October 5, 2026 – Clay's description of the role and its HTTP API enrichment, HeyReach's campaign and messaging API coverage, and LinkedIn's API access documentation checked against their live pages on that date.* ## B2B Buying Signals: The LinkedIn Ones and How to Track Them Most buying-signal advice starts with an intent-data contract. A good share of the signals worth acting on are already visible from your own LinkedIn account – if you know where to look and collect them consistently. > **The short version.** A buying signal is behaviour that suggests an account may be moving toward a purchase – visiting your pricing page, hiring for the role your product serves, engaging with your content, or asking a direct question about pricing or timing. Some come from vendor intent data; others you can observe on LinkedIn: who viewed your profile, who engaged with your posts or a competitor's, who is posting about the problem you solve, who is hiring for the role you sell to, and who just accepted your invitation or replied. You can collect those from your own account on a schedule, without an intent-data contract. Treat every one as a reason to look closer, not as proof someone is ready to buy. ## What counts as a B2B buying signal A buying signal is any observable behaviour that makes a purchase more likely than it was before. It falls into three kinds: - **Direct engagement with you** – visits to your profile, reactions and comments on your posts, replies, accepted invitations, and the strongest of all: a direct request for a demo, pricing or timing. - **Public business and context observations** – engagement on a competitor's post, public posts about the problem you solve, hiring for a role, a job change. - **Vendor intent datasets** – topic research and website visits that intent-data vendors aggregate and sell. LinkedIn's own [marketing blog](https://www.linkedin.com/business/marketing/blog/trends-tips/the-3-big-intent-based-marketing-trends-and-why-theyre-happening) draws the line between first-party intent data you gather yourself and data bought from others. This guide covers the first two kinds together – the **LinkedIn signals your account can observe**. No single signal proves intent. A profile view can be curiosity, a like can be politeness, and a hiring post can be a backfill. Signals earn attention in combination and in context – which is why the rest of this guide pairs every signal with a strength and a route. ## The LinkedIn buying signals you can see yourself The strength grades below are **editorial heuristics, not validated purchase probabilities** – a starting point to tune against what actually turns into conversations for you. | Signal | What it may suggest | Strength (heuristic) | How to pull it with Linked API | | --- | --- | --- | --- | | **Someone viewed your profile** | They looked you up after a post, comment or invitation | Weak alone; medium with ICP fit plus recent engagement | `retrieveProfileViewers` – identified viewers with name and profile. Semi-private viewers come back only as LinkedIn's description plus a search link, never a name; full-private viewers are not returned | | **Someone reacted to your post** | The topic landed | Weak; medium with ICP fit when the post is about the problem | `fetchPost` with reactions ([`st.retrievePostReactions`](/docs/action-st-retrieve-post-reactions) in the workflow API) – the response has **no reaction time**, so "first seen" is when your run noticed it | | **Someone commented on your post** | Active interest in the topic | Medium; strong if they ask a problem, pricing or timing question | `fetchPost` with comments ([`st.retrievePostComments`](/docs/action-st-retrieve-post-comments)), with LinkedIn's relative time such as `3d` | | **Someone engaged with a competitor's post** | Interest in the category | Weak; medium with ICP fit and a category-relevant post | The same reactions and comments, on the competitor's post | | **Someone posted about the problem you solve** | They are thinking about it in public | Medium when the author fits your ICP and the post describes the problem | [`searchPosts`](/docs/action-st-search-posts) on a keyword – results can be loosely related, and each call is bounded (up to 100 posts) | | **A target account is hiring for the role you sell to** | Budget and a team forming around the problem | Medium; weak outside your target accounts | [`searchJobs`](/docs/action-st-search-jobs) filtered by company | | **Someone accepted your invitation** | Regular messaging is now open | Not a buying signal on its own | Network monitoring – `connectionAccepted` events, polled or by webhook | | **Someone replied** | A conversation started | Strong if it is a positive question about the problem, pricing or timing; a negative reply means suppress | Inbox monitoring – `inbox.messageReceived` events | | **A champion changed jobs** | Someone who knows your product lands somewhere new | Medium to strong when the new company fits your ICP | No native event: re-fetch saved profiles on a schedule and compare company and title | A few boundaries worth knowing before you build on these: - **Profile views** depend on what LinkedIn shows your account – [Premium shows more of the list](https://www.linkedin.com/help/linkedin/answer/a548046) – and a semi-private viewer is an account-level observation at most: it names a company only when LinkedIn discloses one, and it never identifies a person or proves a repeat visit ([viewing modes](https://www.linkedin.com/help/linkedin/answer/a567226)). The full picture is in [who viewed my profile](/guides/linkedin-who-viewed-my-profile). - **Post engagement** works on any public post URL, yours or a competitor's – see [turning post engagers into warm leads](/guides/linkedin-post-scraper#turn-post-engagers-into-warm-leads). - **Keyword posts** come from LinkedIn's content search – see [searching posts by keyword](/guides/linkedin-post-scraper#search-linkedin-posts-by-keyword). - **Hiring** comes from jobs search – see [monitoring new job postings](/guides/linkedin-jobs-scraper#how-to-monitor-new-job-postings). ## How to identify buying signals that matter Collecting everything produces noise. A short routine keeps it useful: 1. **Define the accounts and roles you sell to.** Without an ICP check, every signal looks equally interesting. 2. **Pick the three or four signals that fit your motion**, not all nine. A founder who posts weekly gets most from post engagement; a team selling to a named account list gets most from hiring and job changes. 3. **Watch them on a schedule** – daily is enough for most of them. 4. **Score combinations above single events.** A viewer from a target account who also engaged with a post beats either alone. 5. **Review weekly what turned into conversations**, and adjust which signals you keep. Then route each observation with one small rule set: | Condition | Route | | --- | --- | | ICP and role fit **and** relevant content **and** recent | Research and review queue – a person checks before any outreach | | A direct positive question about the problem, pricing or timing | Follow-up task – reply, offer a call | | Negative reply, no fit, or unrelated content | Suppress | | A single weak signal with no fit check yet | Log only | ## How to respond to a buying signal - **Lead with relevance, not surveillance.** Refer to the shared context – the post, the topic, the role – and never open with "I saw you viewed my profile". - **Answer direct questions directly.** A comment asking about pricing or timing deserves a reply in the thread or a short message, not a sequence. - **Pick the right channel.** Check [the connection degree](/guides/linkedin-connection-degrees) to choose between an invitation and a message. - **Keep the pace human.** Stay inside [LinkedIn's practical limits](/guides/understanding-linkedin-limits); more on first-touch tactics in [cold outreach strategies](/guides/linkedin-cold-outreach-strategies-2026). ## Collecting LinkedIn signals automatically Below is one daily job that pulls three signals – new profile viewers, engagement on your last three posts, and recent posts on a keyword – routes each one with the table above, and appends it to a review file. Each line of the file is one observation: - **who** – name, profile URL and URN when known, or LinkedIn's description when that is all it shows; - **signal type** and **source** – the post, the search term, the profile-viewers list; - **observed at** – when this run first saw it, not when it happened; - **evidence** – the comment text, the post excerpt, the viewer's relative time, and a comment's age as LinkedIn shows it; - **rule and action** – which routing condition matched and what to do next. ```typescript import LinkedApi from '@linkedapi/node'; import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); const MY_PROFILE_URL = 'https://www.linkedin.com/in/your-profile'; const KEYWORD = 'stale pipeline'; const STATE_FILE = 'signals-state.json'; const OUTPUT_FILE = 'signals.jsonl'; const OVERLAP_MS = 2 * 24 * 60 * 60 * 1000; const ICP_ROLE = /revenue operations|revops|sales operations/i; const PROBLEM_TERMS = /pipeline|forecast|crm/i; const BUYING_TERMS = /pric|cost|demo|trial|timeline|budget/i; const NEGATIVE_TERMS = /not interested|no thanks|unsubscribe|stop messaging|remove me/i; const RECENT_AGE = /^\d+\s*(s|m|h|d|w)$/i; interface TSignal { key?: string; name?: string; profileUrl?: string; urn?: string; headline?: string; type: 'profile-view' | 'reaction' | 'comment' | 'keyword-post'; source: string; evidence: string; age?: string; isAboutProblem: boolean; } async function collectProfileViews(since: string) { const signals: Array = []; const workflow = await linkedapi.retrieveProfileViewers.execute({ limit: 100, since }); const { data, errors } = await linkedapi.retrieveProfileViewers.result(workflow.workflowId); if (errors.length > 0) throw new Error(`Profile viewers: ${errors.map((error) => error.type).join(', ')}`); for (const viewer of data ?? []) { if (viewer.viewerType === 'anonymous') { signals.push({ type: 'profile-view', source: viewer.searchUrl, evidence: viewer.description, isAboutProblem: false }); continue; } signals.push({ key: `view:${viewer.urn ?? viewer.publicUrl}`, name: viewer.name, profileUrl: viewer.publicUrl, urn: viewer.urn ?? undefined, headline: viewer.headline ?? undefined, type: 'profile-view', source: 'profile viewers', evidence: `Viewed ${viewer.viewedAgo ?? 'recently'}`, isAboutProblem: false, }); } return signals; } async function collectEngagement() { const signals: Array = []; const profileRun = await linkedapi.fetchPerson.execute({ personUrl: MY_PROFILE_URL, retrievePosts: true, postsRetrievalConfig: { limit: 3 }, }); const { data: me, errors: profileErrors } = await linkedapi.fetchPerson.result(profileRun.workflowId); if (profileErrors.length > 0) console.warn('Own posts', profileErrors.map((error) => error.type)); for (const { url } of me?.posts ?? []) { const postRun = await linkedapi.fetchPost.execute({ postUrl: url, retrieveReactions: true, reactionsRetrievalConfig: { limit: 100 }, retrieveComments: true, commentsRetrievalConfig: { limit: 50, sort: 'mostRecent' }, }); const { data: post, errors } = await linkedapi.fetchPost.result(postRun.workflowId); if (errors.length > 0) console.warn(url, errors.map((error) => error.type)); if (!post) continue; const isAboutProblem = PROBLEM_TERMS.test(post.text ?? ''); for (const reaction of post.reactions ?? []) { if (reaction.actor.type !== 'person') continue; const { name, profileUrl, urn, headline } = reaction.actor; signals.push({ key: `reaction:${url}:${urn ?? profileUrl}`, name: name ?? undefined, profileUrl: profileUrl ?? undefined, urn: urn ?? undefined, headline: headline ?? undefined, type: 'reaction', source: url, evidence: `Reacted (${reaction.type}); reaction time not available`, isAboutProblem, }); } for (const comment of post.comments ?? []) { if (comment.author.type !== 'person') continue; const { name, profileUrl, headline } = comment.author; signals.push({ key: `comment:${comment.commentUrl ?? `${url}:${profileUrl}:${comment.text}`}`, name: name ?? undefined, profileUrl: profileUrl ?? undefined, headline: headline ?? undefined, type: 'comment', source: comment.commentUrl ?? url, evidence: comment.text ?? '', age: comment.time, isAboutProblem: isAboutProblem || PROBLEM_TERMS.test(comment.text ?? ''), }); } } return signals; } async function collectKeywordPosts() { const signals: Array = []; const searchRun = await linkedapi.searchPosts.execute({ term: KEYWORD, filter: { sort: 'latest', datePosted: 'pastWeek' }, limit: 20, }); const { data, errors } = await linkedapi.searchPosts.result(searchRun.workflowId); if (errors.length > 0) console.warn('Keyword search', errors.map((error) => error.type)); for (const post of data ?? []) { if (post.author?.type !== 'person' || !post.author.profileUrl) continue; signals.push({ key: `post:${post.url}`, name: post.author.name ?? undefined, profileUrl: post.author.profileUrl, headline: post.author.headline ?? undefined, type: 'keyword-post', source: post.url, evidence: (post.text ?? '').slice(0, 280), isAboutProblem: PROBLEM_TERMS.test(post.text ?? ''), }); } return signals; } function route(signal: TSignal, hasQualifyingEngagement: boolean) { if (signal.type === 'comment') { if (NEGATIVE_TERMS.test(signal.evidence)) return { rule: 'negative response', action: 'suppress' }; if (signal.age && !RECENT_AGE.test(signal.age)) return { rule: 'older than a month', action: 'log only' }; if (signal.evidence.includes('?') && BUYING_TERMS.test(signal.evidence)) { return { rule: 'question about pricing or timing – confirm it is positive', action: 'follow-up task' }; } } if (!signal.headline) return { rule: 'no person to check fit', action: 'log only' }; if (!ICP_ROLE.test(signal.headline)) return { rule: 'outside the roles you sell to', action: 'suppress' }; if (signal.type === 'profile-view') { return hasQualifyingEngagement ? { rule: 'role fit, plus qualifying engagement in this run', action: 'review queue' } : { rule: 'single weak signal', action: 'log only' }; } if (!signal.isAboutProblem) return { rule: 'unrelated content', action: 'suppress' }; return { rule: 'role fit and problem-related content', action: 'review queue' }; } async function runDailyCollection() { const state = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, 'utf8')) : { lastRunAt: new Date().toISOString(), seen: [] }; const seen = new Set(state.seen); const runStartedAt = new Date().toISOString(); const since = new Date(Date.parse(state.lastRunAt) - OVERLAP_MS).toISOString(); const signals = [ ...(await collectProfileViews(since)), ...(await collectEngagement()), ...(await collectKeywordPosts()), ].filter((signal) => !signal.key || !seen.has(signal.key)); const engagedPeople = new Set(); for (const signal of signals) { const personId = signal.urn ?? signal.profileUrl; const { action } = route(signal, false); if (signal.type !== 'profile-view' && personId && (action === 'review queue' || action === 'follow-up task')) { engagedPeople.add(personId); } } for (const signal of signals) { const personId = signal.urn ?? signal.profileUrl; const { rule, action } = route(signal, personId !== undefined && engagedPeople.has(personId)); appendFileSync(OUTPUT_FILE, `${JSON.stringify({ ...signal, observedAt: runStartedAt, rule, action })}\n`); if (signal.key) seen.add(signal.key); } writeFileSync(STATE_FILE, JSON.stringify({ lastRunAt: runStartedAt, seen: [...seen] })); } runDailyCollection(); ``` ```python import json import os import re from datetime import datetime, timedelta, timezone from linkedapi import ( FetchPersonParams, FetchPostParams, LinkedApi, LinkedApiConfig, RetrieveProfileViewersParams, SearchPostsParams, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) MY_PROFILE_URL = "https://www.linkedin.com/in/your-profile" KEYWORD = "stale pipeline" STATE_FILE = "signals-state.json" OUTPUT_FILE = "signals.jsonl" OVERLAP = timedelta(days=2) TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" ICP_ROLE = re.compile(r"revenue operations|revops|sales operations", re.I) PROBLEM_TERMS = re.compile(r"pipeline|forecast|crm", re.I) BUYING_TERMS = re.compile(r"pric|cost|demo|trial|timeline|budget", re.I) NEGATIVE_TERMS = re.compile(r"not interested|no thanks|unsubscribe|stop messaging|remove me", re.I) RECENT_AGE = re.compile(r"^\d+\s*(s|m|h|d|w)$", re.I) def make_signal(signal_type, source, evidence, **person): return { "key": person.get("key"), "name": person.get("name"), "profileUrl": person.get("profile_url"), "urn": person.get("urn"), "headline": person.get("headline"), "type": signal_type, "source": source, "evidence": evidence, "age": person.get("age"), "isAboutProblem": person.get("is_about_problem", False), } def collect_profile_views(since): workflow = linkedapi.retrieve_profile_viewers.execute(RetrieveProfileViewersParams(limit=100, since=since)) result = linkedapi.retrieve_profile_viewers.result(workflow.workflow_id) if result.errors: raise RuntimeError(f"Profile viewers: {[error.type for error in result.errors]}") signals = [] for viewer in result.data or []: if viewer.viewer_type == "anonymous": signals.append(make_signal("profile-view", viewer.search_url, viewer.description)) continue signals.append(make_signal( "profile-view", "profile viewers", f"Viewed {viewer.viewed_ago or 'recently'}", key=f"view:{viewer.urn or viewer.public_url}", name=viewer.name, profile_url=viewer.public_url, urn=viewer.urn, headline=viewer.headline, )) return signals def collect_engagement(): signals = [] profile_run = linkedapi.fetch_person.execute( FetchPersonParams(person_url=MY_PROFILE_URL, retrieve_posts=True, posts_retrieval_config={"limit": 3}) ) profile_result = linkedapi.fetch_person.result(profile_run.workflow_id) if profile_result.errors: print("Own posts", [error.type for error in profile_result.errors]) me = profile_result.data for my_post in (me.posts if me else None) or []: url = my_post.url post_run = linkedapi.fetch_post.execute( FetchPostParams( post_url=url, retrieve_reactions=True, reactions_retrieval_config={"limit": 100}, retrieve_comments=True, comments_retrieval_config={"limit": 50, "sort": "mostRecent"}, ) ) result = linkedapi.fetch_post.result(post_run.workflow_id) if result.errors: print(url, [error.type for error in result.errors]) post = result.data if post is None: continue is_about_problem = bool(PROBLEM_TERMS.search(post.text or "")) for reaction in post.reactions or []: actor = reaction.actor if actor.type != "person": continue signals.append(make_signal( "reaction", url, f"Reacted ({reaction.type}); reaction time not available", key=f"reaction:{url}:{actor.urn or actor.profile_url}", name=actor.name, profile_url=actor.profile_url, urn=actor.urn, headline=actor.headline, is_about_problem=is_about_problem, )) for comment in post.comments or []: author = comment.author if author is None or author.type != "person": continue signals.append(make_signal( "comment", comment.comment_url or url, comment.text or "", key=f"comment:{comment.comment_url or f'{url}:{author.profile_url}:{comment.text}'}", name=author.name, profile_url=author.profile_url, headline=author.headline, age=comment.time, is_about_problem=is_about_problem or bool(PROBLEM_TERMS.search(comment.text or "")), )) return signals def collect_keyword_posts(): signals = [] search_run = linkedapi.search_posts.execute( SearchPostsParams(term=KEYWORD, filter={"sort": "latest", "date_posted": "pastWeek"}, limit=20) ) result = linkedapi.search_posts.result(search_run.workflow_id) if result.errors: print("Keyword search", [error.type for error in result.errors]) for post in result.data or []: author = post.author if author is None or author.type != "person" or not author.profile_url: continue signals.append(make_signal( "keyword-post", post.url, (post.text or "")[:280], key=f"post:{post.url}", name=author.name, profile_url=author.profile_url, headline=author.headline, is_about_problem=bool(PROBLEM_TERMS.search(post.text or "")), )) return signals def route(signal, has_qualifying_engagement): if signal["type"] == "comment": if NEGATIVE_TERMS.search(signal["evidence"]): return "negative response", "suppress" if signal["age"] and not RECENT_AGE.match(signal["age"]): return "older than a month", "log only" if "?" in signal["evidence"] and BUYING_TERMS.search(signal["evidence"]): return "question about pricing or timing – confirm it is positive", "follow-up task" if not signal["headline"]: return "no person to check fit", "log only" if not ICP_ROLE.search(signal["headline"]): return "outside the roles you sell to", "suppress" if signal["type"] == "profile-view": if has_qualifying_engagement: return "role fit, plus qualifying engagement in this run", "review queue" return "single weak signal", "log only" if not signal["isAboutProblem"]: return "unrelated content", "suppress" return "role fit and problem-related content", "review queue" def run_daily_collection(): now = datetime.now(timezone.utc) if os.path.exists(STATE_FILE): with open(STATE_FILE) as f: state = json.load(f) else: state = {"lastRunAt": now.strftime(TIME_FORMAT), "seen": []} seen = set(state["seen"]) last_run_at = datetime.strptime(state["lastRunAt"], TIME_FORMAT).replace(tzinfo=timezone.utc) since = (last_run_at - OVERLAP).strftime(TIME_FORMAT) run_started_at = now.strftime(TIME_FORMAT) signals = collect_profile_views(since) + collect_engagement() + collect_keyword_posts() signals = [signal for signal in signals if not signal["key"] or signal["key"] not in seen] engaged_people = { signal["urn"] or signal["profileUrl"] for signal in signals if signal["type"] != "profile-view" and route(signal, False)[1] in ("review queue", "follow-up task") } with open(OUTPUT_FILE, "a") as output: for signal in signals: person_id = signal["urn"] or signal["profileUrl"] rule, action = route(signal, person_id in engaged_people) output.write(json.dumps({**signal, "observedAt": run_started_at, "rule": rule, "action": action}) + "\n") if signal["key"]: seen.add(signal["key"]) with open(STATE_FILE, "w") as f: json.dump({"lastRunAt": run_started_at, "seen": sorted(seen)}, f) run_daily_collection() ``` **What the code checks, and what it leaves to you.** The routing applies the table above as far as the data allows: - **Role fit** – a pattern on the person's headline. - **Relevance** – the problem terms in the post, or in the comment itself. - **Recency** – keyword posts come from the past week; a comment older than a month goes to the log. - **Negative responses** are suppressed before anything else. - **A profile view** reaches the review queue only when the same person also has a qualifying engagement in the same run. What it cannot check is left to the person working the queue: when a reaction happened (LinkedIn gives no time), whether a question is actually positive, and whether a headline match really is someone you sell to. That is why the strongest route is a review queue, not an automatic message. What the job deliberately does, and why: - **It checks `errors` on every result.** If the viewer pull fails, the run stops before it moves `lastRunAt`, so the next run covers the same window instead of skipping it. Errors from the profile, post and keyword fetches are printed even when part of the data came back – a failed reactions or comments pull would otherwise look like an empty source – and the run continues; whatever was missed is picked up next time, because seen keys are stored per observation. - **`since` is a coarse filter with an overlap.** Viewer times are estimates from LinkedIn's relative ages, so each run reaches two days back and drops what it has already seen. - **Identified viewers are deduplicated by URN, falling back to the profile URL.** Semi-private rows carry no identity, so the overlap can log the same view twice – treat them as account-level observations, not counts. - **Reactions have no time.** The first run logs every reaction already on your last three posts; after that, only new ones appear. - **Keyword search is loose.** The problem-terms check suppresses posts that matched the keyword but are not about the problem. - **Combinations count only qualifying engagement**, matched per person by URN where the source returns one and by profile URL otherwise – so a view and a comment from the same person may not always pair up. Run it once a day from cron or any scheduler, then work the `review queue` and `follow-up task` lines. **The other signals plug into the same file:** - **Accepted invitations and replies.** Turn on [network monitoring](/docs/monitoring-network) and [inbox monitoring](/docs/monitoring-inbox) once per account, then poll for events or receive them by [webhook](/docs/webhooks) – `network.connectionAccepted` and `inbox.messageReceived`. Deduplicate by the event `id`, since a retried delivery reuses it. Monitoring captures only changes after you enable it. - **Job changes.** There is no native job-change event. Save the people who championed you, re-fetch their profiles with `fetchPerson` on a schedule – weekly is plenty – and compare the current company and title with what you stored. - **Hiring at target accounts.** Run `searchJobs` with your target companies in the filter and a role term, and log postings you have not seen before. You can also skip the code. An AI agent can run the same routine on your account through the [MCP server](/mcp/available-tools), the agent-friendly [CLI](/cli) or a ready-made [skill](/skills) – "check my new profile viewers and the engagement on my last three posts, and list the people who match these roles". ## What this does not cover - **No third-party intent data** – no "account X is researching your category" topic surges. - **No website-visitor identification.** - **No personal emails or phone numbers.** If you need those, they come from intent-data and enrichment vendors; LinkedIn signals complement them rather than replace them. Everything here runs on your own account, in a dedicated cloud browser, at a human pace, within per-action limits you can [configure](/docs/admin-limits). ## Frequently Asked Questions (FAQ) #### What are buying signals in B2B sales? Behaviour that suggests an account may be moving toward a purchase – engaging with your content, hiring for the role your product serves, or asking a direct question about pricing or timing. Each one is a reason to look closer, not proof of intent. #### What are examples of B2B buying signals? Profile views, reactions and comments on your posts, engagement on a competitor's post, posts about the problem you solve, hiring for a relevant role, a champion changing jobs, accepted invitations, replies, and pricing-page visits or topic research from intent-data vendors. #### Is a LinkedIn profile view a buying signal? A weak one on its own; it gets stronger combined with ICP fit and recent engagement. Semi-private viewers show only LinkedIn's description, so they can point to an account at most, never a person. #### Can you track job changes on LinkedIn automatically? Yes, by re-fetching saved profiles on a schedule and comparing company and title. LinkedIn offers no job-change event to subscribe to. #### Do I need intent data to use buying signals? No. The LinkedIn signals your account can observe work on their own; vendor intent data adds topic research and website visits that you cannot see on LinkedIn. ## Wrapping up The signals worth acting on are often already in your own account: who looked, who engaged, who is posting about the problem and who is hiring. Collect them on a schedule, route them with a few rules, and spend your time on the combinations. Build it into your own tools with the REST API, the [Node and Python SDKs](/sdks) or the [CLI](/cli). Or have an AI agent run it out of the box through the [MCP server](/mcp/available-tools), the agent-friendly CLI or a ready-made [skill](/skills). Plans start at $49 a seat a month billed annually – see [pricing](/pricing). ## Does LinkedIn Show Who Viewed Your Profile? Free vs Premium Yes – LinkedIn shows you who viewed your profile, but how much it shows depends on your plan and on how you browse yourself. A free account gets a glimpse, Premium gets the list, and some viewers never get a name on any plan. > **The short version.** Your viewers live under **Analytics → profile views** on your own profile. A free account sees its **three most recent viewers from the last 90 days** – and only while it browses other profiles under its own name and headline. **Premium Career and Premium Business show the full list**, 90 days by default and extendable to 365, and keep the last 90 days even while you browse privately. On every plan, someone browsing semi-privately appears only as a description such as "Someone at Acme", and someone in private mode appears as "LinkedIn Member" – nobody can put a name to them. For a one-off look, LinkedIn's own page is all you need, and the first three sections cover it. The rest is for people who sell or recruit and want the list as data they can act on. ## Where to see who viewed your LinkedIn profile Per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a540651): 1. **Desktop:** click the **Me** icon at the top of your homepage, then **View Profile**, scroll to the **Analytics** section and click **[Number] profile views**. 2. **Mobile:** tap your profile picture, tap it again to open your profile, scroll to **Analytics** and tap **[Number] profile views**. The same page opens directly at `linkedin.com/analytics/profile-views/`. If nobody viewed you in the past 90 days, LinkedIn has nothing to show there. ## What a free account shows, and what Premium adds | | Free (Basic) | Premium Career and Business | | --- | --- | --- | | **Viewers you can see** | The three most recent | The full list | | **How far back** | 90 days | 90 days by default, extendable to 365 | | **Insights** | Number of viewers, where they work, how they found you, job titles | All of that, plus filters by date, company and industry, and weekly trends | | **While you browse privately yourself** | **Nothing** – the list is hidden | The last 90 days | | **Viewers in private mode** | "LinkedIn Member", no name | "LinkedIn Member", no name | Sources: LinkedIn Help on [free versus Premium](https://www.linkedin.com/help/linkedin/answer/a548046), [Premium viewer insights](https://www.linkedin.com/help/linkedin/answer/a516745) and [browsing in private mode](https://www.linkedin.com/help/linkedin/answer/a567226). Those pages describe Premium Career and Premium Business; they say nothing about Sales Navigator or Recruiter, so neither does this table. **Without Premium, three is the whole allowance.** There is no setting that shows a free account more, and the "last five viewers" figure you will still find repeated around the web is out of date – LinkedIn's own comparison now says three. The condition people miss is the fourth row: a free account that browses in private or semi-private mode loses its viewer list entirely until it switches back. ## Why some viewers show up without a name What you see of a viewer is decided by *their* setting, not yours. LinkedIn gives every member [three profile viewing options](https://www.linkedin.com/help/linkedin/answer/a567226): | The viewer's setting | What appears in your list | | --- | --- | | **Your name and headline** | The viewer, with name, headline and photo | | **Private profile characteristics** | A description only – job title, company, school or industry, such as "Someone at Acme" | | **Private mode** | "LinkedIn Member – This person is viewing profiles in private mode" | No plan unmasks the last two. LinkedIn is explicit that "even if you have a Premium account, you can't view the names of private mode viewers of your own profile" ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a564182)), and no outside tool changes that. **Why isn't someone in my list?** Usually one of four reasons: - **They browsed privately or semi-privately** – they are in the list, just as "LinkedIn Member" or a description. - **You are on a free account and they are not among the three most recent** – the fourth-most-recent viewer is already out of view. - **You browse privately yourself on a free account** – then the whole list is hidden ([LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a568149)). - **The view is older than your window** – 90 days on free, 90 to 365 days on Premium depending on the date range you pick. What going private costs you, plan by plan, is laid out in the [private mode guide's reciprocity rule](/guides/linkedin-private-mode#what-private-mode-costs-you-the-reciprocity-rule). ## Will someone know you viewed their profile? Yes, in the terms your own setting allows: your visit lands in their list as you, as a description, or as "LinkedIn Member", depending on the viewing option you chose. Whether they also get a notification, and how to turn private mode on or off, is covered in [does viewing someone's profile notify them](/guides/linkedin-private-mode#does-viewing-someone-s-profile-notify-them). ## Turning profile viewers into warm leads If you sell or recruit, a profile view is one of the few intent signals LinkedIn hands you directly: someone chose to look at you, usually after something you did – a post, a comment, an invitation, a visit of your own. That makes a viewer warmer than a cold search result. It does not make them a lead until you have checked they fit. A routine that respects that: - **Check on a schedule.** A free account shows only three viewers, so a daily look catches people a weekly one misses. - **Triage by fit first.** Role, company and seniority decide whether a viewer is worth a message, not the view itself. - **Open with a reason that stands on its own.** Reference what you have in common or what they may care about – not "I saw you viewed my profile". - **Check the connection degree before you write.** It decides whether you can message someone or have to invite them first – see [LinkedIn connection degrees](/guides/linkedin-connection-degrees). - **Treat "Someone at Acme" as an account signal.** It names nobody, but it tells you a company you may be targeting just looked. Visibility cuts both ways here: your own visits only work as an introduction when you browse as yourself – the [private mode guide](/guides/linkedin-private-mode#when-should-you-stay-visible) weighs that trade. ## Get your profile viewers as data with Linked API Checking the page by hand stops scaling once viewers are a pipeline input. [Linked API](/)'s [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers) reads your own profile viewers page and returns it as JSON: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const workflow = await linkedapi.retrieveProfileViewers.execute({ limit: 50 }); const { data } = await linkedapi.retrieveProfileViewers.result(workflow.workflowId); for (const viewer of data ?? []) { if (viewer.viewerType === 'identified') { console.log(viewer.name, viewer.headline, viewer.connectionDegree, viewer.viewedAgo); } else { console.log(viewer.description, viewer.viewedAgo); } } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, RetrieveProfileViewersParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.retrieve_profile_viewers.execute(RetrieveProfileViewersParams(limit=50)) for viewer in linkedapi.retrieve_profile_viewers.result(workflow.workflow_id).data or []: if viewer.viewer_type == "identified": print(viewer.name, viewer.headline, viewer.connection_degree, viewer.viewed_ago) else: print(viewer.description, viewer.viewed_ago) ``` `execute` starts the retrieval and returns a workflow ID; `result` waits for it and returns the viewers. Each one comes back tagged with how much LinkedIn revealed: | `viewerType` | What you get | What you never get | | --- | --- | --- | | **`identified`** | `name` and `publicUrl`, always; `headline`, `connectionDegree` and `avatarUrl` where LinkedIn showed them; `urn` for part of the list only | – | | **`anonymous`** | The `description` LinkedIn showed, verbatim, and the `searchUrl` LinkedIn itself offers for those criteria | A name, a profile URL, a photo | A semi-private viewer's row looks like this: ```json { "viewerType": "anonymous", "viewedAt": "2026-08-11T09:20:00.000Z", "viewedAgo": "4h", "description": "Someone at Example Company", "searchUrl": "https://www.linkedin.com/search/results/people/?keywords=&origin=WHO_VIEWED_ME¤tCompany=71168977" } ``` The limits, stated plainly, because they are LinkedIn's limits and no API removes them: - **Your own profile only.** It reads the viewers page of the account it runs on. - **Only what your plan shows.** Without Premium, LinkedIn shows part of the list, and fewer rows than your `limit` is a normal, successful result – not an error. - **Viewers in full private mode are not returned at all.** They disclose nothing, so there is nothing to return; `anonymous` rows are the semi-private ones. - **No row is ever turned into a name** that LinkedIn withheld. - **`viewedAt` is an estimate**, worked back from the relative age LinkedIn displays – `6h` is give or take an hour, `2mo` give or take a month. From the terminal it is one command – `linkedin stats viewers --limit 50 --json` with the [Linked API CLI](/cli/commands#your-account) – and the [SDK reference](/docs/action-st-retrieve-profile-viewers?surface=sdk) has the full response contract. For the trend rather than the names, [`retrievePerformance`](/docs/action-st-retrieve-performance?surface=sdk) returns `profileViewsLast90Days` next to your follower count and post views. It is an account-level total that answers "is interest growing" rather than "who came"; appending it to a sheet weekly turns LinkedIn's rolling window into a curve, the same pattern the [SSI guide](/guides/linkedin-social-selling-index#track-your-ssi-over-time-the-part-worth-automating) uses. ## From a viewer to a conversation The list becomes useful when it feeds the next action. Run the retrieval daily, invite the identified viewers who fit, and tally the semi-private ones by company: ```typescript const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000; const since = new Date(lastRunAt.getTime() - TWO_DAYS_MS).toISOString(); const workflow = await linkedapi.retrieveProfileViewers.execute({ limit: 100, since }); const { data } = await linkedapi.retrieveProfileViewers.result(workflow.workflowId); const anonymousByCompany = new Map(); for (const viewer of data ?? []) { if (viewer.viewerType === 'anonymous') { const company = new URL(viewer.searchUrl).searchParams.get('currentCompany'); if (company) anonymousByCompany.set(company, (anonymousByCompany.get(company) ?? 0) + 1); continue; } const viewerKey = viewer.urn ?? viewer.publicUrl; if (alreadySeen(viewerKey) || viewer.connectionDegree === 1 || !isTargetFit(viewer)) continue; const invite = await linkedapi.sendConnectionRequest.execute({ personUrl: viewer.publicUrl, note: noteFor(viewer), }); const { errors } = await linkedapi.sendConnectionRequest.result(invite.workflowId); const isHandled = errors.every( (error) => error.type === 'alreadyPending' || error.type === 'alreadyConnected', ); if (!isHandled) { console.warn(viewer.publicUrl, errors.map((error) => error.type)); continue; } markSeen(viewerKey); } ``` ```python from datetime import timedelta from urllib.parse import parse_qs, urlparse from linkedapi import RetrieveProfileViewersParams, SendConnectionRequestParams since = (last_run_at - timedelta(days=2)).isoformat() workflow = linkedapi.retrieve_profile_viewers.execute( RetrieveProfileViewersParams(limit=100, since=since) ) anonymous_by_company: dict[str, int] = {} for viewer in linkedapi.retrieve_profile_viewers.result(workflow.workflow_id).data or []: if viewer.viewer_type == "anonymous": company = parse_qs(urlparse(viewer.search_url).query).get("currentCompany", [None])[0] if company: anonymous_by_company[company] = anonymous_by_company.get(company, 0) + 1 continue viewer_key = viewer.urn or viewer.public_url if already_seen(viewer_key) or viewer.connection_degree == 1 or not is_target_fit(viewer): continue invite = linkedapi.send_connection_request.execute( SendConnectionRequestParams(person_url=viewer.public_url, note=note_for(viewer)) ) result = linkedapi.send_connection_request.result(invite.workflow_id) if any(error.type not in ("alreadyPending", "alreadyConnected") for error in result.errors): print(viewer.public_url, [error.type for error in result.errors]) continue mark_seen(viewer_key) ``` `lastRunAt`, `alreadySeen`, `markSeen`, `isTargetFit` and `noteFor` are yours – your storage and your fit rules. A viewer is marked as handled only once the invitation went out or was already pending: any other failure – LinkedIn asking for the person's email, say, or a note that is too long – comes back in `errors` rather than as an exception, is logged, and leaves the viewer eligible for the next run. What the code relies on, and what it does not promise: - **The overlap is deliberate.** `since` is matched against the estimated `viewedAt`, so a boundary set exactly at the last run can drop a view near the edge. Two days back covers a daily run, because recent views are shown in hours or days; deduplication then drops anyone already handled. Treat it as best effort, not an exact cursor. - **Only identified viewers are deduplicated,** by `urn`, falling back to `publicUrl` where `urn` is empty. That gives you "new viewers since last time", not a count of visits. - **Anonymous rows have no identity.** Two runs cannot tell whether "Someone at Acme" is one person twice or two people, so the company tally is a rough signal, never a precise count. Read the company from the `searchUrl` parameters, as above, rather than parsing the description, whose wording changes with the account's interface language. - **A semi-private viewer can still lead somewhere.** Their `searchUrl` is LinkedIn's own people search for the criteria it disclosed; pass it to [`searchPeople`](/docs/action-st-search-people) as `customSearchUrl` and you get **people who match what LinkedIn disclosed** – a candidate list, not the viewer. - **Keep the pace human.** Invitations and profile views have [their own daily ceilings](/guides/understanding-linkedin-limits#person-profile-view-limits), and a viewer is one touch in a [multi-touch sequence](/guides/linkedin-cold-outreach-strategies-2026), not a volume game. More on invitations in [automating connection requests](/guides/how-to-automate-linkedin-connection-requests). Use it both ways. Build it into your own backend or CRM with the REST API, the [Node and Python SDKs](/sdks), or the [CLI](/cli) – or have an AI agent run the same routine out of the box through the [MCP server](/mcp/available-tools) (the `retrieve_profile_viewers` tool), the agent-friendly [CLI](/cli), or a ready-made [skill](/skills), from a plain-language ask. Either way it runs on your own account through a dedicated cloud browser, at a human pace, on LinkedIn's own pages – and Linked API never stores your password. ## Frequently Asked Questions (FAQ) #### Can I see who viewed my LinkedIn profile without Premium? Yes, but only the three most recent viewers from the last 90 days, and only while you browse other profiles under your own name and headline. Switch to private or semi-private mode on a free account and the list disappears until you switch back. #### How far back can I see who viewed my LinkedIn profile? On a free account, 90 days. On Premium Career and Premium Business the list covers 90 days by default and can be extended to 365; while you browse privately, Premium keeps the last 90 days. #### Why can't I see who viewed my LinkedIn profile? Most often because you browse in private or semi-private mode on a free account, which hides your own list. Otherwise nobody viewed you in the last 90 days, or the people who did browsed privately and appear only as "LinkedIn Member" or a description. #### Can LinkedIn Premium see private mode viewers? No. Premium shows more of your list, but LinkedIn does not reveal the names of people who viewed you in private mode on any plan. The [private mode guide](/guides/linkedin-private-mode#can-anyone-see-who-viewed-your-profile-in-private-mode) explains what each mode shows. #### Can someone see that I viewed their LinkedIn profile? Yes, in whatever form your viewing option allows: as yourself, as a description of your role and company, or as "LinkedIn Member" in private mode. You choose that setting yourself – see [LinkedIn private mode](/guides/linkedin-private-mode). #### Is there an API for who viewed my LinkedIn profile? Yes, for your own profile: Linked API's `st.retrieveProfileViewers` returns the list LinkedIn shows your account, as identified viewers with a name and profile URL, and semi-private viewers as the description LinkedIn displayed. It never names a private viewer, does not return fully private visits, and returns only as much history as your plan shows. ## Wrapping up LinkedIn shows you who viewed your profile within firm limits: three viewers on a free account, the full list on Premium, and no names for anyone who browses privately. For a quick look, open your profile's Analytics. To act on viewers every day, [get an API key](/pricing) and pull the list into your own workflow – or let an agent do it through [MCP](/mcp/available-tools). ## LinkedIn Private Mode: What It Hides, and What It Costs You LinkedIn private mode lets you decide what someone sees when you look at their profile: your full name, a vague description of your job, or nothing at all. The setting takes ten seconds to change, and almost nobody is told what it costs – because hiding yourself also hides them from you. > **The short version.** LinkedIn gives you three profile viewing options: your name and headline, private profile characteristics such as job title and industry, or private mode. In private mode the person you viewed sees only "LinkedIn Member - This person is viewing profiles in private mode". The trade is reciprocal: on a free account, switching away from name and headline costs you your own list of who viewed you – and a free account only ever shows the three most recent viewers anyway. On Premium you can browse privately and still see the list of people who viewed you in the last 90 days. And no account, Premium included, can put a name to someone who viewed it in private mode. ## What LinkedIn's three profile viewing options actually show The setting is called **Profile viewing options**, and per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a567226/browsing-profiles-in-private-and-semi-private-mode) it has exactly three states. | Option | What the profile owner sees | What you keep | | --- | --- | --- | | **Your name and headline** | Your name, headline and photo, like any normal visit | Full access to your own viewer list, subject to your account tier | | **Private profile characteristics** | "Profile characteristics such as job title, company, school, and industry" – for example, "Consultant at State Farm" | Nothing on a free account; the 90-day list on Premium | | **Private mode** | "LinkedIn Member - This person is viewing profiles in private mode" | Nothing on a free account; the 90-day list on Premium | The middle option is the one people misjudge. It feels anonymous because your name is gone, but it still tells a recruiter or a competitor which company and which role just looked at them – often enough to guess exactly who you are at a small company. ## What private mode costs you: the reciprocity rule LinkedIn treats profile viewing as an exchange, and on a free account the exchange is strict: stop contributing your identity and it stops showing you other people's. Premium is the documented exception – the table below is the whole rule. | | Free (Basic) account | Premium | | --- | --- | --- | | Browsing in private mode | Available | Available | | Your own viewer list while private | **Lost** | **Kept – the last 90 days** | | Viewer list when not private | Three most recent viewers in the last 90 days | Full list, and the dashboard reaches up to 365 days | | Can you unmask a private viewer | No | No | Two numbers here answer different questions, and they get merged constantly. **The 90 days is the private-mode guarantee:** LinkedIn states that with Premium you can browse privately "and still see the list of people who viewed your profile in the last 90 days", and that this "isn't possible with a Basic (free) account". **The 365 days is something else** – the general Premium viewer dashboard, which per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a548046) offers a "Full list of viewers for up to 365 days" against a free account's "Three most recent viewers in the last 90 days". Private browsing does not extend to that longer window. If your viewer list has gone empty and you do not know why, this is usually the reason. LinkedIn's own troubleshooting says it plainly: "If you don't see any profile viewers, check your Private Mode settings." Where to find your own list, and every other reason a viewer can be missing from it, is covered in [who viewed my LinkedIn profile](/guides/linkedin-who-viewed-my-profile). ## Can anyone see who viewed your profile in private mode? No – on any account type, including Premium. Premium buys you the ability to browse privately without losing your own list. It does not buy you the ability to see through anyone else's private mode. [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a564182/private-mode-viewers-of-profiles) is explicit that even Premium subscribers cannot access the names of members who viewed them privately, and there is no setting, subscription or tool that reverses it. A private viewer appears in your list as an anonymous entry and stays that way. What you can still tell is that *someone* came. A private visit is recorded – it is unattributable, not invisible. Keep the two hiding options apart here, because they are not equally hidden: a **fully private** visit shows "LinkedIn Member" and no identity information at all, while a **semi-private** visit still shows characteristics such as job title, company, school and industry. Both land in your list without a name, and [both look different in the data](#what-you-can-read-back-and-where-linkedin-draws-the-line). ## How to turn private mode on or off The path, per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a568195): **On desktop** 1. Click the **Me** icon at the top of your LinkedIn homepage. 2. Select **Settings & Privacy** from the dropdown. 3. Click **Visibility** on the left pane, and under **Visibility of your profile & network**, click **Profile viewing options**. 4. Choose one of the three options. **On mobile** 1. Tap your profile photo, then tap **Settings**. 2. Tap the **Visibility** tab. 3. Tap **Profile viewing options**. 4. Choose one of the three options. One thing worth knowing before you switch: LinkedIn's own troubleshooting for an empty viewer list points straight back here, telling members to set their profile viewing options to public to get viewer insights again. ## Private mode in Sales Navigator is a separate setting If you use Sales Navigator, you have two independent controls, and changing one does nothing to the other. Sales Navigator has its own three viewing options, and [LinkedIn's Sales Navigator Help](https://www.linkedin.com/help/sales-navigator/answer/a105135) states it directly: "Changing your profile visibility in Sales Navigator will not change your profile visibility settings for LinkedIn.com." A private view performed in Sales Navigator appears to the person you viewed the same way it does on LinkedIn.com – as an anonymous "LinkedIn Member" entry, per [Sales Navigator Help](https://www.linkedin.com/help/sales-navigator/answer/a567226). So a sales team that carefully set LinkedIn.com to show its name can still be browsing anonymously all day inside Sales Navigator without realising it, or the reverse. ## What private mode does not hide Private mode governs one thing only: what others see **when you view them**. It is routinely confused with three separate controls. - **It does not hide your profile.** Anyone who opens your profile still sees everything your public-profile settings allow. That is a different setting. - **It does not hide you from search.** Recruiters searching LinkedIn, and search engines indexing your public profile, are unaffected. - **It does not stop anyone from viewing you.** It only changes what you see about who did. If your goal is to be less findable rather than less visible while browsing, private mode is the wrong lever entirely – the public profile settings are. ## Does viewing someone's profile notify them? It depends, and the honest answer is less definite than most articles suggest. What is documented is that the view lands in the profile owner's "Who's viewed your profile" list, shown according to your viewing mode. A notification is a separate matter, and LinkedIn describes it as conditional rather than automatic: for the case it spells out – opening someone's mini profile from search suggestions – [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a568195) says the view "may result in a notification being sent to the member whose profile you viewed, **depending on your settings**". So: the visit is logged either way. Treat the notification as possible, not certain, and note that your viewing mode also decides what such a notification could even say. ## When should you stay visible? Being visible is what makes a profile visit usable as an introduction – a well-worn tactic, and not one we are claiming to have discovered. Someone sees your name in their viewer list, recognises it when your invitation arrives, and the request is no longer entirely cold. Recognition is possible; warmth and acceptance are not guaranteed, and any article that promises otherwise is selling something. ![What the other person ends up seeing in their Who's viewed your profile list. Browsing as yourself: the entry is shown with your name and headline, it may notify them depending on settings, and recognition is possible while acceptance is not. Browsing in private mode: the visit is still recorded but shows LinkedIn Member with no name, headline or photo attached, so they know someone came but not who](/images/guides/linkedin-private-mode-what-they-see.webp) The trade-off is symmetrical, so choose by what you actually want: - **Stay visible** when the visit is part of how you introduce yourself – business development, recruiting, networking before an event. - **Go semi-private** when you want the role to register but not the person. Remember it can be enough to identify you at a small company. - **Go fully private** when you are researching competitors, checking out a company before applying, or looking at anyone you would rather not signal to – and accept that on a free account you lose your own list for the duration. **Where this meets automation.** A profile visit can be generated deliberately: [Linked API](/)'s [`st.openPersonPage`](/docs/action-st-open-person-page) exists precisely so that the visit lands as a real, visible one – it opens [the person's page](/docs/visiting-person-page) on LinkedIn itself, so it shows up in their viewer list exactly as a manual visit would, under whatever mode your account is set to. What no API can do – ours included – is change the mode that visit lands under. Profile viewing options are a manual account setting, so an account running visit-first sequences while set to private mode is doing the work and discarding the signal. Check the setting before you build the sequence, not after. You can drive those visits from your own backend with the [REST API, the Node and Python SDKs, or the CLI](/sdks), or have an AI agent run them out of the box through the [MCP server](/mcp), the agent-friendly [CLI](/cli), or a ready-made [skill](/skills). And keep the pace human: profile views have [their own daily ceilings](/guides/understanding-linkedin-limits#person-profile-view-limits), and visits are one touch in a [multi-touch sequence](/guides/linkedin-cold-outreach-strategies-2026), not a volume game. What you can do after the visit – message, invite, or neither – is decided by [the connection degree](/guides/linkedin-connection-degrees), which the visit itself does not change. ## What you can read back, and where LinkedIn draws the line The viewer list can also be read as data, and that shows the difference between the two hiding options more plainly than the interface does. [`st.retrieveProfileViewers`](/docs/action-st-retrieve-profile-viewers) reads your own profile viewers page and returns what LinkedIn discloses and nothing more: - **A viewer who browsed as themselves** comes back `identified`, with a name and profile URL. - **A semi-private viewer** comes back `anonymous`, carrying only the description LinkedIn placed on the row – "Someone at Example Company", or a job title with an industry and a region – and the people-search link LinkedIn built from it. Nothing in the API turns that row into a name. - **A viewer in full private mode** discloses nothing, so the action does not return that visit at all. That is the concrete reason semi-private is the mode people misjudge – it is the one that leaves something behind. Where to find your own list, what each plan shows, and the full field-by-field breakdown with code are in [who viewed my LinkedIn profile](/guides/linkedin-who-viewed-my-profile). What no API changes is the setting itself. Your profile viewing options stay a manual choice in your own account – Linked API can read who came and generate visits of its own, but it cannot decide how you appear when you go looking. Every action runs on your own account through a dedicated cloud browser at a human pace, on LinkedIn's own pages, and Linked API never stores your password. ## Frequently Asked Questions (FAQ) #### Can someone see I viewed their LinkedIn profile if I'm in private mode? No. They see an anonymous entry reading "LinkedIn Member - This person is viewing profiles in private mode", with no name, headline or photo. The visit is still recorded in their viewer list, so they know somebody came – they just cannot tell who. #### Is LinkedIn private mode actually private? For browsing, yes – your identity is genuinely withheld from the people you view, and no account tier can reverse that. But it is narrower than the name suggests: it does not hide your own profile, does not remove you from recruiter searches, and does not affect what search engines index. Those are controlled by your public profile settings. #### What are the disadvantages of private mode on LinkedIn? Two. On a free account you lose access to your own list of who viewed you for as long as you stay private. And any visit you make stops working as an introduction, because the person cannot see who you are – which matters if profile visits are part of how you reach people. #### Does LinkedIn notify someone when you view their profile? The view is recorded in their "Who's viewed your profile" list, shown according to your viewing mode. A notification is possible but not certain. LinkedIn documents that conditional wording for one specific case – opening someone's mini profile from search suggestions, which "may result in a notification being sent to the member whose profile you viewed, depending on your settings" – and does not publish a general rule for ordinary profile views. Do not count on a notification firing every time. #### Can you see who viewed your profile in private mode with Premium? Premium lets you browse in private mode and still see the people who viewed you in the last 90 days, which a free account cannot do. It does not let you identify anyone who viewed you privately – that is closed to every account type. #### Can you get the list of who viewed your profile through an API? Yes, for your own profile: Linked API's `st.retrieveProfileViewers` returns it, and it shows only what LinkedIn discloses – semi-private viewers arrive as the description LinkedIn displayed, and viewers in full private mode are not returned at all. What each plan shows and how to use the list are covered in [who viewed my LinkedIn profile](/guides/linkedin-who-viewed-my-profile). #### Does private mode hide my profile from recruiters? No. Recruiters can find and open your profile exactly as before. Private mode only changes what they see when *you* look at *them*. To change how findable you are, use your public profile visibility settings instead. ## Wrapping up Private mode is a single switch with a symmetrical price: the anonymity you gain is paid for with the signal you send and, on a free account, with your own view of who is interested in you. Decide which side you need for the situation you are in, and remember it is a manual setting that no tool changes for you. If profile visits are part of a workflow rather than something you do by hand, [Linked API](/) runs them on your own account through a cloud browser – build them into your own product with the [REST API, SDKs or CLI](/sdks), or hand the job to an AI agent through the [MCP server](/mcp), the [CLI](/cli), or a ready-made [skill](/skills). Flat per-seat [pricing](/pricing) from $49 a month, billed annually. *Facts verified 18 August 2026 – LinkedIn viewing-mode, viewer-list and Sales Navigator behaviour checked against LinkedIn's own Help pages on that date.* ## What 1st, 2nd and 3rd Mean on LinkedIn: Degrees of Connection Explained LinkedIn marks most names with a small label – 1st, 2nd or 3rd – and it quietly decides what you are allowed to do next. Whether you can message someone, whether the Connect button appears, even how much of their profile you get to see, all follow from that one number. People Search adds a broader 3rd+ filter on top of those. > **The short version.** The number is your network distance. **1st** means you are directly connected – LinkedIn lets you message them. **2nd** means they are connected to one of your 1st-degree connections, and you can send an invitation with the Connect button. **3rd** means they are connected to one of your 2nd-degrees. **"LinkedIn Member (Out of Network)"** covers people outside LinkedIn's listed network categories, whose profiles show you limited information and who you can reach with an InMail where that option is available. What the label does **not** distinguish is where you stand right now: the same non-1st badge appears whether neither side has an active invitation, your invitation is already waiting with them, or theirs is waiting with you – and those need three different actions. ## What 1st, 2nd and 3rd mean on LinkedIn | Label | What it is | Can you message them | Can you invite them | Profile visibility | | --- | --- | --- | --- | --- | | **1st** | Directly connected | Yes | Already connected | Full | | **2nd** | Connected to one of your 1st-degrees | Not directly | Yes, via Connect | Full | | **3rd** | Connected to one of your 2nd-degrees | Not directly | Usually, where Connect is offered | Full | | **3rd+** | A People Search filter, not a relationship type | Not directly | Only where Connect is offered | Varies | | **LinkedIn Member (Out of Network)** | Outside LinkedIn's listed network categories | InMail where available, or free if they have Open Profile | Rarely offered | Some fields limited | ## What "1st" means on LinkedIn A 1st-degree connection is mutual and it is always the result of an accepted invitation. Per [LinkedIn's help page](https://www.linkedin.com/help/linkedin/answer/a545636/your-network-and-degrees-of-connection), these are "people you're directly connected to because you've accepted their invitation to connect, or they've accepted your invitation" – and you can "contact them by sending a message on LinkedIn." Two consequences worth knowing: - **It is the only relationship LinkedIn creates in both directions at once.** You appear in their network exactly as they appear in yours, and either side can end it. - **There is a ceiling.** LinkedIn allows a "maximum of 30,000 1st-degree connections" per account. Followers, which are one-way, are not counted against it. ## What "2nd" means on LinkedIn A 2nd-degree connection is, in [LinkedIn's words](https://www.linkedin.com/help/linkedin/answer/a545636/your-network-and-degrees-of-connection), one of the "people who are connected to your 1st-degree connections" – and you "can send them an invitation by clicking the Connect button on their profile page." The useful part is the path rather than the definition. Because a 2nd-degree relationship exists only through someone you already know, LinkedIn shows you the shared connection on their profile. That gives you two options a cold approach does not have: mention the mutual contact in your invitation note, or ask that contact to introduce you. It is the difference between arriving as a stranger and arriving as a colleague of someone they trust. ## What "3rd", "3rd+" and "LinkedIn Member (Out of Network)" mean These three are not three rungs on one ladder, and treating them that way is where most explanations go wrong. **3rd-degree is a relationship. 3rd+ is a search filter. Out of Network is LinkedIn's label for members outside its listed network categories.** **3rd-degree** members are, per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a545636/your-network-and-degrees-of-connection), the "people who are connected to your 2nd-degree connections" – a friend of a friend of a friend. In practice the Connect button is usually still there. **3rd+** is not a separate kind of relationship, and LinkedIn does not document it as a profile label. Where it is defined is the Connections filter in People Search, and the definition is broad: [filtering by 3rd+](https://www.linkedin.com/help/sales-navigator/answer/a528043) "will include everyone that falls in your search criteria." Treat it as a search bucket rather than a degree. **LinkedIn Member (Out of Network)** is the label for people "who fall outside the categories listed above." Two things change here. First, visibility: LinkedIn notes that "some fields of profiles out of your network have limited visibility," which is why you sometimes see an abbreviated name instead of a full one. Second, the route: LinkedIn names InMail as the way to "introduce yourself" to these members, where that option is available to you. **Followers are not a degree.** Following is one-way: someone who follows you "chooses to follow your public updates" without any connection existing. It creates no degree, changes nothing about who can message whom, and does not count toward the 30,000 cap. ## The badge shows distance, not state Here is what none of this tells you: **where you actually stand with that person right now.** The badge answers a question about the network graph – how many steps away they are. It does not distinguish what has already happened between you. A profile showing "2nd" looks the same in three situations that call for three different actions: - **Neither side has an active invitation.** The invitation still has to be sent. - **Your invitation is already sitting with them,** unanswered. Sending another does nothing; your real options are to wait or to withdraw it. - **Their invitation is already sitting with you.** The right move is to accept it – and if you send your own request instead, you are doing extra work to reach a state you could have had with one click. LinkedIn does surface the difference elsewhere: the profile's own buttons change, and both invitation queues live under My Network. What stays identical across all three is the badge itself. That is a small annoyance when you are looking at one profile and can check your queues by hand, and a real problem the moment anything runs across a list, because the correct action differs in each case and the label you are reading does not. This is why an API answers a different question than the badge does. Instead of a degree, [Linked API](/)'s `checkConnectionStatus` returns the relationship state, and there are four of them: `connected`, `notConnected`, `pending` and `incoming`. Note what `notConnected` does and does not mean: no connection and no active invitation in either direction right now – not a guarantee that you have never been in touch before. And `incoming` is the state that is easy to miss if you look only at the badge: it means the other person invited you first. The product is built to expect this. If you skip the check and send a connection request to someone whose invitation is already waiting, Linked API does not quietly send a duplicate – it returns the error `invitationAlreadyReceived`, whose message is exactly the advice you needed: *"This person has already sent you a connection request. Accept it instead of sending a new one."* Useful, but it costs you a failed action and an error to handle, when one status check would have routed you to the right branch in the first place. ### Checking connection state in code The whole pattern is one call and four branches. Install the SDK with `npm install -S @linkedapi/node` or `pip install linkedapi`, then: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const NOTE = 'Hi! We share a few contacts in this space - would love to connect.'; async function reach(personUrl: string, text: string): Promise { const check = await linkedapi.checkConnectionStatus.execute({ personUrl }); const { data } = await linkedapi.checkConnectionStatus.result(check.workflowId); switch (data?.connectionStatus) { case 'connected': await linkedapi.sendMessage.execute({ personUrl, text }); return; case 'incoming': // They invited you first - accept, do not send your own request. await linkedapi.acceptInvitation.execute({ invitationType: 'connect', personUrl }); return; case 'pending': // Your invitation is already with them: wait, or withdrawConnectionRequest. return; case 'notConnected': await linkedapi.sendConnectionRequest.execute({ personUrl, note: NOTE }); return; } } ``` ```python from linkedapi import ( LinkedApi, LinkedApiConfig, CheckConnectionStatusParams, SendMessageParams, SendConnectionRequestParams, AcceptInvitationParams, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) NOTE = "Hi! We share a few contacts in this space - would love to connect." def reach(person_url: str, text: str) -> None: check = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url=person_url) ) status = linkedapi.check_connection_status.result(check.workflow_id).data.connection_status if status == "connected": linkedapi.send_message.execute(SendMessageParams(person_url=person_url, text=text)) elif status == "incoming": # They invited you first - accept, do not send your own request. linkedapi.accept_invitation.execute( AcceptInvitationParams(invitation_type="connect", person_url=person_url) ) elif status == "pending": # Your invitation is already with them: wait, or withdraw_connection_request. pass elif status == "notConnected": linkedapi.send_connection_request.execute( SendConnectionRequestParams(person_url=person_url, note=NOTE) ) ``` Note that `acceptInvitation` takes the invitation type alongside the URL – `invitationType: 'connect'` with a `personUrl` – because the same action also accepts company-follow and newsletter invitations. What happens after the request is sent – the `emailRequired` edge, withdrawing invitations that went stale, detecting the moment one is accepted – is its own lifecycle, covered in [How to Automate LinkedIn Connection Requests](/guides/how-to-automate-linkedin-connection-requests). Once someone is connected, [automating the message](/guides/how-to-automate-linkedin-messages) is the next step. ![Diagram: resolving LinkedIn connection state before acting. From a person URL, checkConnectionStatus returns one of four states. Connected leads to sending a message. NotConnected leads to sending a connection request, or a Sales Navigator InMail to skip the acceptance wait. Pending means your invitation is already with them, so wait or withdraw it. Incoming means they invited you first, so accept the invitation you already have. A footer notes that the visible profile badge is identical across the notConnected, pending and incoming branches.](/images/guides/linkedin-connection-state-router.webp) ## How to reach someone at each degree | Route | Who it reaches | Best for | Cost | | --- | --- | --- | --- | | Message | 1st-degree | Anyone already connected | Free | | Connection request (± note) | 2nd; 3rd where Connect is offered | Building an ongoing relationship rather than a one-off contact | Free; weekly invitation limits apply | | Sales Navigator InMail | 2nd, 3rd, out of network | Reaching someone without waiting for an invitation to be accepted | One credit; refunded if they accept, decline or reply within 90 days | | Open Profile message | Premium members who enabled Open Profile | Contacting a non-connection without using a credit | Free, including from free accounts | | Follow | Anyone who allows followers | Seeing someone's posts without a connection | Free; creates no degree | | Shared-connection introduction | 2nd | Warm outreach at low volume | Free; manual | | Programmatically, on your own account | The same eligible people reached by the supported routes: 1st-degree messages, Connect where it is offered, and Sales Navigator InMail. It unlocks nobody new | Running those routes across a list, or wiring them into your own product, CRM or agent | Paid, flat per seat; LinkedIn's own limits and credits still apply | Where a 3rd-degree profile offers no Connect button, InMail is the paid fallback – but check the free routes first: a Premium member with Open Profile can be messaged at no cost, and a shared group can open a direct route too. How InMail credits, caps and refunds work is covered in the [LinkedIn InMail guide](/guides/linkedin-inmail), including that Open Profile exception. That last row is worth one caveat. Doing this programmatically does not move anyone closer to you: degrees still apply, invitation limits still apply, InMail still costs a credit. What changes is that the routing happens in your code instead of your afternoon. Linked API runs each action on your own account through a human-paced cloud browser and surfaces LinkedIn's own limit signals rather than pushing past them, which is what keeps volume sane – our [connection limit guide](/guides/linkedin-connection-limit-2026) and [limits overview](/guides/understanding-linkedin-limits) have the numbers, and the [safety model](/safety) has the approach. You can build it in with the REST API, the Node and Python SDKs or the [shell CLI](/cli/getting-started), or get it out of the box through the [MCP server](/mcp/overview), the AI-agent-friendly CLI in Claude Code, Cursor and Codex, or a ready-made [skill](/skills). ## Frequently Asked Questions (FAQ) #### Do LinkedIn connection requests expire? Yes. An invitation nobody answers expires after six months, and LinkedIn sends up to two reminders before that happens. Once it has expired you are free to send a new one. Withdrawing an invitation yourself is different: the recipient is not notified, but you cannot re-invite the same person for up to three weeks. Our guide to [pending invitations](/guides/linkedin-pending-invitations) covers both queues and how to clear them. --- Building connection logic into your own product, CRM or agent? [Start with Linked API](/pricing) – check the real relationship state and act on it through the [API and SDKs](/sdks/installation), the [CLI](/cli/getting-started), the [MCP server](/mcp/overview) or ready-made [skills](/skills), on your own account and at a human pace. ## LinkedIn Sales Navigator Cost in 2026: Pricing by Plan Sales Navigator is priced per licence, and the published number is only half the decision. The other half is which tier that licence should be, and whether a given person on your team needs one at all. > **The short version.** Sales Navigator starts at US$119.99 per licence per month, or US$1,079.88 per year, for Core. Advanced starts at US$159.99 per month or US$1,799.88 per year. Advanced Plus has no published price and is quoted per customer. Core and Advanced give you the same search and InMail allocation – what the step up buys is buyer intelligence, warm-path tooling and team administration. And the annual discount is uneven: LinkedIn takes 25% off Core but only about 6% off Advanced, which makes the gap between the two tiers wider on annual billing than on monthly. ## How much does LinkedIn Sales Navigator cost in 2026? Per [LinkedIn's compare-plans page](https://business.linkedin.com/sell/sales-navigator/compare-plans), in US dollars: | Plan | Monthly, per licence | Annual, per licence | Effective monthly on annual | LinkedIn's "best for" | |---|---|---|---|---| | **Core** | US$119.99 | US$1,079.88 | US$89.99 | Individual sellers | | **Advanced** | US$159.99 | US$1,799.88 | US$149.99 | Sales teams | | **Advanced Plus** | Contact sales | Contact sales | – | Sales teams using integrated CRM | Both published tiers say "starts at", and pricing is per licence rather than per company, so a five-person team on Core is five times the number above. LinkedIn attaches a caveat to those figures worth reading before you build a budget on them: "prices listed above are estimates and may exclude Value-Added Tax (VAT), Goods & Services Tax (GST), and/or promotional discounts. Pricing is subject to change. Slight variations may occur across device types." ## Does annual billing actually save you money? Yes, but by very different amounts depending on which tier you are on. LinkedIn states both discounts itself: "a 25% savings with annual billing" on Core, and "a 6% savings with annual billing" on Advanced. The arithmetic behind those two numbers: | Plan | 12 months at the monthly rate | Annual rate | Saving | Discount | |---|---|---|---|---| | **Core** | US$1,439.88 | US$1,079.88 | US$360.00 | **25.0%** | | **Advanced** | US$1,919.88 | US$1,799.88 | US$120.00 | **6.25%** | The consequence is the part that changes a decision. Because Core gets four times the discount rate Advanced does, the price gap between the two tiers is not constant – it widens when you commit annually: - Paying monthly, Advanced costs **US$480 more per licence per year** than Core. - Paying annually, Advanced costs **US$720 more per licence per year** than Core. ![Sales Navigator Core versus Advanced on monthly and annual billing: the annual per-licence gap widens from US$480 to US$720 because Core receives a 25% annual discount while Advanced receives 6.25%](/images/guides/linkedin-sales-navigator-cost-annual-gap.webp) Nothing reverses here – annual Advanced is still cheaper than monthly Advanced. But the upgrade itself gets 50% more expensive under annual billing, so if you are sizing a team where some people need Advanced and some do not, price the mix on the billing basis you will actually use, not on the monthly rate. ## What the Core → Advanced step actually buys Not more search. Core and Advanced carry an identical search-and-reach allocation: the same 50+ advanced search filters, the same 50 InMail per month, and the same Relationship Explorer, Relationship Map and Alerts. What the step adds is buyer intelligence, engagement tooling, and the machinery a team needs to run seats together. | Capability | Core | Advanced | Advanced Plus | |---|:--:|:--:|:--:| | Advanced Search Filters | ✓ | ✓ | ✓ | | InMail | 50/month | 50/month | 50/month | | Relationship Explorer | ✓ | ✓ | ✓ | | Relationship Map | ✓ | ✓ | ✓ | | Alerts | ✓ | ✓ | ✓ | | Account IQ & Lead IQ | – | ✓ | ✓ | | Message Assist (public beta) | – | ✓ | ✓ | | TeamLink | – | ✓ | ✓ | | Smart Links | – | ✓ | ✓ | | Buyer Intent | – | ✓ | ✓ | | Team seat management & centralized billing | – | ✓ | ✓ | | Team reporting | – | ✓ | ✓ | | Manage your book of business | – | ✓ | ✓ | | Dedicated account team & tailored training\* | – | ✓ | ✓ | | Advanced CRM Integrations | – | – | ✓ | | Lead/Contact Creation | – | – | ✓ | | CRM Embedded Experiences and Profiles | – | – | ✓ | \* LinkedIn footnotes this row as "if purchased through a sales representative". So the question to ask about the extra US$720 a year is not "do we need better search" – you already have the same search on Core. It is whether these five things are worth it to the individual seller: knowing which accounts are already showing interest (Buyer Intent), AI-written account and lead research (Account IQ and Lead IQ), warm introduction paths through colleagues' connections (TeamLink), trackable content links (Smart Links), and drafted outreach (Message Assist) – plus, for the manager, seat administration and team reporting. LinkedIn's own framing matches: Core is for "individual sellers who want to find high-quality leads and build client relationships", Advanced for "sales teams that need AI-powered lead and account research, actionable insights, and collaboration tools". ## How Advanced Plus pricing works Advanced Plus has no list price. LinkedIn quotes it per customer, and the variables it names are team size, CRM integration requirements, and onboarding and training. What you get for the quote is narrower than the tier name suggests. In LinkedIn's own comparison matrix, exactly three capabilities separate Advanced Plus from Advanced, and all three are CRM: - **Advanced CRM Integrations** – keeping the CRM current from Sales Navigator. - **Lead/Contact Creation** – writing leads and accounts into the CRM. - **CRM Embedded Experiences and Profiles** – LinkedIn surfaces rendered inside the CRM. That makes the decision unusually clean. If real CRM synchronisation is the thing you need, request the quote. If it is not, Advanced already contains everything else Advanced Plus does, and there is nothing else in the tier to buy. Published estimates for the quote do not agree with each other, which is a reason to treat all of them as indicative: GigRadar puts Advanced Plus at roughly US$1,300–1,600 per seat per year, while Factors states US$1,600 and up. Neither is LinkedIn, and LinkedIn does not publish a figure. ## Which accounts actually need a Sales Navigator licence? Before choosing a tier, it is worth asking whether a particular person needs a licence at all – and the answer turns on one rule that decides more budget than any tier comparison. **A Sales Navigator licence attaches to a user, not to a task.** You cannot detach the ordinary parts of someone's work to shrink their subscription. If a seller needs advanced filters, saved lead and account lists, Sales Navigator's 50-a-month InMail allocation, alerts, or any other Sales Navigator surface for *any* part of their job, they need a licence, and moving the rest of their work elsewhere does not reduce what you pay for them. Where the rule pays off is the account that never touches those surfaces at all. | The work | Sales Navigator status | |---|---| | Advanced-filter and saved-search prospecting | **Nav-exclusive** – needs a licence | | Saved lead and account lists, book of business | **Nav-exclusive** – needs a licence | | [InMail](/guides/linkedin-inmail) at 50 a month | **The allocation is Nav-exclusive** – Premium Business includes 15 a month | | Buyer Intent, Account IQ, Lead IQ, TeamLink, Smart Links | **Nav-exclusive** – needs a licence, on Advanced or above | | CRM sync of Sales Navigator data | **Nav-exclusive** – needs Advanced Plus | | Standard LinkedIn people and company search | Not Nav-exclusive by itself | | Profile, company and post data | Not Nav-exclusive by itself | | Connection requests and ordinary messages | Not Nav-exclusive by itself | Read the right-hand column carefully: "not Nav-exclusive by itself" is not the same as "free". It means only that this work does not *require* Sales Navigator. An account that does some of it alongside Nav-exclusive work still needs a licence. The case where this genuinely saves money is narrower and specific: an account whose workflow never needs a Nav-exclusive capability – a research or data-collection account, an operations account feeding a CRM, an account behind a product feature – can run standard LinkedIn work on no Sales Navigator subscription at all. Two honest qualifications. Standard LinkedIn search is meaningfully narrower than Sales Navigator search, not an equivalent with a different label: our own [standard people search](/docs/searching-for-people) exposes eight filter fields – first name, last name, position, locations, industries, current companies, previous companies and schools – against the 50+ filters LinkedIn markets Sales Navigator on. And a licence does not lift every ceiling: LinkedIn says a paid plan increases the number of profile searches and views, but the connection-invite cap is unchanged – see [search query limits](/guides/understanding-linkedin-limits#search-query-limits) and the [2026 connection limits guide](/guides/linkedin-connection-limit-2026). ## When Sales Navigator is the right purchase The eligibility question above is about whether an account needs a licence. This one is different: given that the work needs doing, when is Sales Navigator itself the better tool than any alternative, ours included? - **A person is doing the searching.** Sales Navigator's filter interface, saved searches, and lead and account lists are built for a human working a pipeline interactively. No API is a better experience for that job. - **You want warm paths, not just names.** TeamLink surfaces which colleague can introduce you. That mapping is proprietary to LinkedIn and cannot be reconstructed from outside. - **You want LinkedIn's own account research.** Account IQ and Lead IQ summarise accounts and leads inside the product, with LinkedIn's data behind them. - **You want buying signals.** Buyer Intent reflects activity LinkedIn observes and does not expose anywhere else. - **Your requirement is CRM synchronisation.** Advanced Plus, or an approved SNAP integration through your CRM vendor, will beat anything you assemble – see our [Sales Navigator API guide](/guides/linkedin-sales-navigator-api). - **You need one spreadsheet, once.** An export tool is the right answer, not a subscription decision at all. Our [Sales Navigator export guide](/guides/linkedin-sales-navigator-scraper) compares the routes. - **You need bulk records with email addresses or phone numbers.** Sales Navigator does not provide those, and neither do we. A dataset provider is the honest fit. ## Getting more out of a licence you already pay for If you are paying for licences, the cost per seat is fixed but the output is not – it is capped by how many hours a person can spend in the interface. Sales Navigator work can also run programmatically on the same licence you already own, which is where [Linked API](/pricing) fits. This does not replace the subscription and cannot: Sales Navigator actions run *through* your seat, so the seat has to exist. What changes is that searching, opening a lead, retrieving a company's employees or decision makers, sending a message and syncing the conversation become composable steps you can run on a schedule or from inside your own product. There are two first-class ways to reach it, and neither is a lesser version of the other. **Build it in.** Compose the primitives yourself through the [REST API, Node and Python SDKs, and the shell CLI](/sdks), and embed them in your product, backend or CRM. The full action set, including which actions are Sales Navigator-only, is in the [actions overview](/docs/actions-overview). **Get it out of the box.** Connect the [MCP server](/mcp), point an AI agent at the [CLI](/cli) from Claude Code, Cursor or Codex, or install ready-made [skills](/skills) with `npx @linkedapi/skills`. You describe the job in plain language and the agent composes and runs the same workflow. Nothing to wire, no blocks to drag. Both run on a dedicated cloud browser against your own account, at a human pace, with per-action limits enforced by the platform. Pricing is flat per seat, from $49 per month billed annually. One limit worth stating plainly, because it decides whether this fits at all: Sales Navigator result types carry no email address and no phone number. If contact data is what you need, this is not the route. ## Frequently Asked Questions (FAQ) #### Is Sales Navigator included in LinkedIn Premium? No – LinkedIn Premium does not include Sales Navigator; the inclusion runs the other way. LinkedIn states that "a Sales Navigator subscription includes access to Premium Business features". Premium Business gives you basic search filters and 15 InMail per month; Sales Navigator gives you 50+ advanced filters, 50 InMail per month, Relationship Map, TeamLink, alerts and CRM integrations on top of those Premium Business features. #### Is there a free trial, and will you be charged for it? There is a free trial, and LinkedIn asks for payment information up front. Its stated terms are that you can "cancel for any reason before your Sales Navigator trial ends to avoid being charged", and that it sends "an email reminder seven (7) days before your free trial expires". #### Are the listed prices what you actually pay? Not necessarily. LinkedIn describes its published figures as estimates that "may exclude Value-Added Tax (VAT), Goods & Services Tax (GST), and/or promotional discounts", adds that pricing is subject to change, and notes that "slight variations may occur across device types". Both published tiers are also quoted as "starts at" prices. #### Does a paid tier raise your LinkedIn connection or search limits? Those are two different answers. It does **not** raise the connection-invite ceiling – that cap is the same whether you pay or not, and the current figures are in the [2026 connection limits guide](/guides/linkedin-connection-limit-2026). It **does** increase search access: LinkedIn's [commercial use limit](https://www.linkedin.com/help/linkedin/answer/a564226) page says you can upgrade to Premium Business, Recruiter Lite or Sales Navigator "to increase the number of profile searches and views", while adding on the same page that "we are not able to display the exact number of searches or views you have left". So the allowance goes up, and LinkedIn does not publish what it goes up to. What Sales Navigator does publish is how much one search returns: "up to 2,500 lead results across 100 pages or 1,000 account results across 40 pages", per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a106030). Our own breakdown sits in [search query limits](/guides/understanding-linkedin-limits#search-query-limits) and [search results limits](/guides/understanding-linkedin-limits#search-results-limits). #### Does Sales Navigator give you email addresses or phone numbers? No. Sales Navigator surfaces LinkedIn profile and company data, not contact details. The same applies through Linked API: our Sales Navigator result types carry no email or phone field. If you need verified contact data, that is a separate category of tool. #### Do I need a Sales Navigator subscription to automate LinkedIn? Only for Sales Navigator actions. Anything that runs through the Sales Navigator interface requires the licence, because it works through your own seat. Standard LinkedIn people, company and post data, connection requests and ordinary messages do not require a Sales Navigator subscription – see [Sales Navigator API access](/guides/linkedin-sales-navigator-api) for how the routes differ. ## Price the tier, then price the seat Sales Navigator's list price is the easy part: US$119.99 or US$159.99 per licence per month, less 25% or 6% respectively if you commit for a year. The decisions that actually move the bill are which of your people need a Nav-exclusive capability at all, and whether the seats you keep are producing everything they could. If the answer to the second one is no, [Linked API](/pricing) runs Sales Navigator workflows on the licences you already own – built into your own product through the [REST API, SDKs and CLI](/sdks), or run out of the box by an agent through [MCP](/mcp), the [CLI](/cli) or ready-made [skills](/skills). Flat per seat, from $49 per month billed annually. *Facts verified 10 August 2026 – Sales Navigator prices, plan features, trial terms and Premium comparison checked against LinkedIn's live pages, and third-party Advanced Plus estimates checked against their live pages, on that date.* ## LinkedIn X-Ray Search: Operators, Examples, and What Google's Index Can't See (2026) LinkedIn X-ray search means using Google to find LinkedIn profiles instead of searching inside LinkedIn. You point Google at LinkedIn's profile pages with `site:linkedin.com/in/`, add the words you expect to find on the profile, and read the results while logged out. It is free, it needs no account, and it exists because there is no general-purpose LinkedIn people-search API open to ordinary developers – see [what LinkedIn's API actually gives you](/guides/linkedin-api-access). This guide covers the operators that genuinely work, strings you can copy, the boundary where Google's copy of LinkedIn stops being enough, and what to do at that point. > **The short version.** X-ray search uses Google operators – `site:linkedin.com/in/`, quotes and `-`, plus a few Google does not document – to find public LinkedIn profiles without logging in. It works because members' public profiles are visible to search tools, and it is genuinely free. Its ceiling is architectural rather than a bug: you are searching Google's copy of LinkedIn, so you get what members chose to make public, as Google last crawled it, matched on keywords. There is no reliable structured filter for current versus past employer. When that distinction matters, run the search inside LinkedIn instead. **X-ray search is not the same thing as LinkedIn boolean search.** Both use boolean logic, which is why the two get mixed up constantly, but they query different indexes. Boolean search runs **inside** LinkedIn, on your own account, using LinkedIn's operators and filter fields – a separate technique with [its own operators and rules](/guides/linkedin-boolean-search). X-ray runs **outside** LinkedIn, in Google, against public copies of profiles. Everything below is about the second one. ## What LinkedIn X-ray search is (and why it works at all) X-ray search works because LinkedIn members can expose their profile to search engines. LinkedIn's own help page tells members they can "control what sections of your profile are eligible for display to people who are not signed in to LinkedIn, or can be viewed on search tools such as Google or Bing" ([LinkedIn public profile visibility](https://www.linkedin.com/help/linkedin/answer/a518980)). That single setting is the whole foundation. Google crawls those public profiles, stores them, and lets you query its copy with ordinary search operators. Nothing is bypassed and nothing is unlocked – you are reading pages members published deliberately. The technique got its name because it feels like seeing through LinkedIn's own search, which caps results by account tier and biases toward your network. Google has no such view of you. What it does not give you is LinkedIn's structured fields – there is nothing to filter on for who currently works where, which is the point at which the method eventually runs out. ## The X-ray operators that work The table below carries only the operators that are useful for finding profiles. Whether Google documents an operator is a separate question from whether it helps here, so it is noted per row rather than used to decide what belongs in the table. **Documented by Google and useful here** ([Refine Google searches](https://support.google.com/websearch/answer/2466433)): | Operator | What it does | Constraint to know | |---|---|---| | `site:` | Restricts results to one site or path. `site:linkedin.com/in/` returns profiles only. | Retrieval is **not exhaustive** – see below. | | `"exact phrase"` | Matches the phrase as written. | Quote every multi-word title, or Google matches the words separately and independently. | | `-` | Excludes a term. | No space between the operator and the word: `-recruiter`, never `- recruiter`. | **Not documented by Google, but working in practice.** These four appear on every X-ray guide as though they were official. They are not on Google's operator help page, so treat them as best-effort: use them, then sanity-check the results rather than trusting them silently. | Operator | What it does | Constraint to know | |---|---|---| | `intitle:` | Matches text in the page title. A LinkedIn profile title carries the name and headline. | Best-effort; verify a sample of results. | | `inurl:` | Matches text in the URL. Useful for separating `/in/` from `/company/`. | Best-effort. | | `OR` | Either term matches. | Must be uppercase. Lowercase `or` is treated as a normal word. | | `*` | Stands in for a whole word. | A whole word, not a partial stem. Most reliable inside a quoted phrase: `"head of * marketing"`. | **Documented by Google but not useful for this job.** `filetype:` filters by document format, and LinkedIn profiles are ordinary HTML pages, so it narrows nothing here. `before:` and `after:` filter on Google's document dates – not on employment dates, and not a dependable signal for how fresh a profile is. If you have seen them recommended for X-ray, that is why they are absent from the tables above. One limit applies to every string you will write. Google states plainly that the `site:` operator "doesn't necessarily return all the URLs that are indexed under the prefix specified in the query" ([Google Search Central](https://developers.google.com/search/docs/monitor-debug/search-operators/all-search-site)). An X-ray search is a sample of what Google chose to show you, never a complete list of matching profiles. ### Common X-ray syntax mistakes - **A space after the operator.** Google is explicit: "Do not put spaces between the operator and your search term." - **Lowercase `or`.** It stops being an operator and becomes a search word. - **Unquoted multi-word titles.** `head of growth` matches the three words anywhere on the page; `"head of growth"` matches the title. - **`site:linkedin.com` when you wanted people.** That includes posts, jobs and company pages. Use `site:linkedin.com/in/` for profiles. ## X-ray search strings you can copy Each string is labelled with what it actually does, which is not always what it looks like it does. **By title and location** ``` site:linkedin.com/in/ "head of growth" "Berlin" ``` **Mentions a company – not a current-employer filter** ``` site:linkedin.com/in/ "product manager" ("Acme" OR "Globex") ``` This finds profiles where those company names appear anywhere: current role, a job from six years ago, or a recommendation someone wrote. A snippet often hints at which, but there is no structured employer field to filter on, so you sort it out by reading the results. **By school** ``` site:linkedin.com/in/ "data scientist" "Technical University of Munich" ``` **By localized LinkedIn host** ``` site:de.linkedin.com/in/ "vertriebsleiter" ``` This selects LinkedIn's German-language host. It is **not** a "people located in Germany" filter – plenty of people in Germany are served on `www.linkedin.com`, and this string will miss them. **Excluding the noise** ``` site:linkedin.com/in/ "software engineer" "Amsterdam" -recruiter -intern -"talent acquisition" ``` **Company pages instead of people** ``` site:linkedin.com/company/ "solar installation" "Netherlands" ``` ## What Google's index can't know X-ray is not broken, and the strings above genuinely work. But five limits are structural, and no amount of operator skill removes them. **1. The index lags, sometimes by months.** LinkedIn is direct about this: "After you make changes to your public profile, it can take several weeks or months at times for search tools like Google, Yahoo, or Bing to detect changes and refresh. LinkedIn doesn't control that refresh process" ([LinkedIn public profile visibility](https://www.linkedin.com/help/linkedin/answer/a518980)). A person who changed jobs in March may still read as their old title. ![Diagram of the X-ray refresh path: a member edits their profile, the change appears on their public LinkedIn profile immediately, then Google re-crawls the page on its own schedule taking weeks to months, and only then does the change appear in X-ray results. A separate short arrow shows native authenticated search reading LinkedIn directly with no lag.](/images/guides/linkedin-xray-refresh-path.webp) **2. No reliable structured current-versus-past employer filter.** Google matches words on a page, so a profile mentioning Acme may be a current role, a former one, or a line in someone's recommendation. A snippet often hints at which, but there is no field meaning "currently works here" that you can filter or sort on – so at any volume you are verifying by hand. **3. Google operators are not LinkedIn fields.** `intitle:` searches text. LinkedIn's own search has structured fields for position, industry, location, school and employer. These are different mechanisms, and the second is not reachable from Google. **4. You only ever see what members allowed.** Public-profile visibility is member-controlled per section. Someone who narrowed theirs is invisible to X-ray no matter how precise your string is. **5. Retrieval is not exhaustive.** As Google states above, `site:` does not necessarily return everything indexed under that prefix. You cannot know what you missed, which makes X-ray unsuitable as a coverage-complete source list. ## X-ray vs LinkedIn-native search: which to use when | | X-ray search (Google) | LinkedIn-native search (Linked API) | |---|---|---| | Where the search runs | Google's index of public profiles | LinkedIn itself, authenticated | | What it matches on | Keywords in a public snippet | LinkedIn's own structured fields | | Freshness | Google's crawl – weeks to months of lag | LinkedIn as it is now | | Current vs past employer | No reliable structured filter | `currentCompanies` and `previousCompanies`, separately | | Filters available | Google operators | Position, locations, industries, companies, schools | | Output | A page of links you copy by hand | Structured results your code can consume | | Login required | None – runs logged out | Your own LinkedIn account | | Cost | Free | Flat per seat, from $49/mo billed annually | | Best for | One-off lookups, no account, no budget | Repeatable searches, products, CRM enrichment | ## When the job outgrows Google: run the search inside LinkedIn At the point where the current-versus-past distinction matters, or you need the same search to run every week, the answer is to stop searching Google's copy and search LinkedIn. There are two ways in, and they are equally first-class – pick the one that matches how you work. ### Ask an agent (nothing to build) If you would rather not write code, you do not have to. Ask in plain language: > Find heads of growth in Berlin who currently work at Acme or Globex and give me their LinkedIn URLs. That runs through the [MCP server](/mcp/overview) in any MCP client, through the AI-agent-friendly [CLI](/cli/getting-started) in Claude Code, Cursor or Codex, or through a ready-made skill installed with `npx @linkedapi/skills`. Install it, ask, read the results. There are no blocks to drag and nothing to wire, and the agent composes the search from the same filters described below. ### Build it in (compose it yourself) To embed the search in your own product, backend or CRM, call it directly. Pass a keyword `term` alongside the structured `filter` – this is what makes the current-versus-past distinction available at all: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const search = await linkedapi.searchPeople.execute({ term: 'growth', limit: 25, filter: { position: 'Head of Growth', locations: ['Berlin'], currentCompanies: ['Acme', 'Globex'], }, }); const { data: people } = await linkedapi.searchPeople.result(search.workflowId); for (const person of people ?? []) { console.log(person.name, person.headline, person.publicUrl); } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, SearchPeopleFilter, SearchPeopleParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) search = linkedapi.search_people.execute( SearchPeopleParams( term="growth", limit=25, filter=SearchPeopleFilter( position="Head of Growth", locations=["Berlin"], current_companies=["Acme", "Globex"], ), ) ) people = linkedapi.search_people.result(search.workflow_id).data for person in people or []: print(person.name, person.headline, person.public_url) ``` Swap `currentCompanies` for `previousCompanies` and you have the search Google cannot express: people who used to work somewhere and no longer do. The same search runs from the shell: ```bash linkedin person search --term "growth" --limit 25 --position "Head of Growth" --locations "Berlin" --current-companies Acme,Globex --json ``` Two honest notes on the output. A search returns search results – name, profile URL, headline, location – and not full profiles; pulling experience, education, skills or posts is a separate `fetchPerson` call, covered in the [profile scraper guide](/guides/linkedin-profile-scraper). And results never include personal email addresses or phone numbers. Full parameters are in the [people search docs](/docs/searching-for-people) and [person data docs](/docs/retrieving-person-data); if you would rather build the query in LinkedIn's UI and hand over its URL, the [boolean search guide](/guides/linkedin-boolean-search) covers that route. If you source in Sales Navigator, the `nv` variants take the same shape with extra filters such as years of experience – see the [Sales Navigator scraper guide](/guides/linkedin-sales-navigator-scraper). For the wider picture of getting LinkedIn data without maintaining a scraper, start from [how to scrape LinkedIn](/guides/how-to-scrape-linkedin). ## When X-ray is still the right tool X-ray is not a worse version of API search. For several jobs it is simply the correct choice, and switching would be overkill: - **One-off lookups.** Finding one person once does not justify an account, a seat or a line of code. - **No LinkedIn account, or a locked-down one.** X-ray runs logged out. It never touches your account, so it consumes nothing and risks nothing. - **Zero budget.** It is free, permanently. - **Checking what is publicly visible.** If you want to see how someone appears to the outside world, X-ray shows exactly that, because that is all it can see. There is a third option worth naming honestly. If what you want is a finished interface with sourcing, sequences and campaign management already built in, a closed automation suite is a reasonable answer, and several exist. Linked API is the opposite trade: primitives you compose, embedded wherever you need them, or driven by an agent – which is the right shape when the search feeds your own product or workflow, and the wrong shape when you wanted a ready-made UI. ## Is X-ray search safe for your account? X-ray itself touches nothing. You are logged out and running a Google query, so LinkedIn sees no activity from you at all, and none of your account's search allowances are consumed. The [limits guide](/guides/understanding-linkedin-limits) covers what LinkedIn's own in-app search costs you by comparison. Running searches through the API is different, because that genuinely uses your account – so it is built to behave like you. Each account gets a real, dedicated cloud browser that you authorize once through LinkedIn's own sign-in flow; Linked API never stores your password. Actions run at a human pace, and you set the per-action ceilings yourself, per day, week or month. See [safety](/safety) for how that works in practice. ## Frequently Asked Questions (FAQ) #### What is LinkedIn X-ray search? It is the technique of finding LinkedIn profiles through Google instead of LinkedIn's own search, by restricting Google to LinkedIn's profile pages with `site:linkedin.com/in/` and adding the words you expect on the profile. It works on public profiles, while logged out, and requires no LinkedIn account. #### Is X-ray search the same as LinkedIn boolean search? No, though they are easy to confuse because both use boolean logic. Boolean search runs inside LinkedIn on your own account, using LinkedIn's operators and its filter fields. X-ray runs in Google, against public copies of profiles, using Google's operators. The difference is which index you are querying, not which logic you use – see the [boolean search guide](/guides/linkedin-boolean-search) for the in-LinkedIn technique. #### Is LinkedIn X-ray search free? Yes, entirely. It uses ordinary Google search with standard operators, so there is nothing to buy and no seat to hold. The cost is your time building strings and copying results by hand. #### Do I need a LinkedIn account to run an X-ray search? No. That is one of its real advantages. X-ray reads public profiles through Google while you are signed out, so it never touches a LinkedIn account and never consumes any account's search allowance. #### Why does my X-ray search return so few results? Usually one of four reasons: your quoted phrases are stricter than the wording on the profiles; the `site:` operator does not necessarily return everything Google has indexed under that path; some members have narrowed which sections of their public profile are visible to search tools; or Google has not re-crawled the profiles since they changed. There is no fixed result cap to work around – tighten or loosen your terms and compare. #### Does `intitle:` work in LinkedIn's own search box? No. `intitle:` is a Google operator and only works in Google. LinkedIn's own search has no `title:`-style field command – you use its boolean operators and its filter fields instead, which the [boolean search guide](/guides/linkedin-boolean-search) covers. #### Can X-ray search find email addresses or phone numbers? Only by accident. If a member published contact details in a public section of their profile and Google indexed that text, it can surface – but it is not a reliable contact source and most profiles will not yield one. Linked API does not return personal email addresses or phone numbers either; searches and profile fetches return LinkedIn profile data. #### Does X-ray search count toward LinkedIn's search limits? No. You are querying Google, not LinkedIn, so LinkedIn's commercial-use limit and tier-based result caps do not apply. Those limits apply to searching inside LinkedIn, whether by hand or through an API – the [limits guide](/guides/understanding-linkedin-limits) has the working numbers. #### Can I run LinkedIn searches programmatically instead? Yes. [Linked API](/sdks/installation) runs the search inside LinkedIn on your own account and returns structured results, with filters for position, location, industry, school, and current versus previous employer. You can call it from the REST API, the Node and Python SDKs or the [CLI](/cli/getting-started), or have an agent run it through the [MCP server](/mcp/overview) or a [ready-made skill](/skills). ## Start where the search stops Use X-ray for what it is genuinely good at – free, logged-out, one-off lookups on public profiles. When the search needs to be repeatable, current, or feed something you are building, run it inside LinkedIn instead: ask an agent through the [MCP server](/mcp/overview), the [CLI](/cli/getting-started) or a [ready-made skill](/skills), or build it in with the [REST API and SDKs](/sdks/installation). Every route runs on the same dedicated cloud browser, on an account you own, inside the limits you set. See [pricing](/pricing) for plans. *Facts verified 7 August 2026 – LinkedIn public-profile visibility and refresh statements checked against LinkedIn Help, and Google search-operator behaviour checked against Google Search Help and Google Search Central, on that date.* ## LinkedIn AI Agent: How to Give Your Agent Access to Your Account A LinkedIn AI agent is one that *does* things in your account rather than writing text for you to paste: it runs the search, opens the profiles, sends the connection request, replies in the thread. Getting one working turns out to be less about the agent than about what sits between it and LinkedIn, because that connector decides both what your agent can do and what it holds of yours. > **The short version.** An AI agent needs an authorized way to act on LinkedIn – typically a scoped access token or an authenticated account session. Compare connectors on three things: what authentication material they receive and retain, where the actions execute, and which actions and limits they expose. Those answers shape your exposure, your control and your capability. ## What is a LinkedIn AI agent? Two different things share the name, and mixing them up is why setup advice often does not match what you wanted. | | What it does | Where it runs | The ask that fits | |---|---|---|---| | **Writing assistant** | Drafts posts, comments and replies for you to publish yourself | In the chat window | "Write me a post about our launch" | | **Acting agent** | Runs actions in your account – search, read, connect, message | Through a connector holding an authorized session | "Find heads of sales in Berlin and connect with the ones who fit" | This guide covers the second kind. If all you want is better copy, a writing assistant is the cheaper answer and none of the setup below applies. ## How can an agent reach your account? An agent has no standing on LinkedIn by itself. Something has to carry authorization on your behalf, and the options differ on two axes: **what authentication material that connector receives and retains**, and **where the actions actually execute**. These are the common models, not an exhaustive taxonomy: | Model | Authentication material held | Where actions execute | What that means for you | |---|---|---|---| | Credential-based | Your LinkedIn password, used to obtain session cookies | Vendor side, varies by implementation | The connector holds your credentials | | Cookie-based | Your `li_at` session cookie | Vendor side, varies by implementation | The cookie expires, so you reconnect on a cycle | | Official tokens | An OAuth token, scoped to what LinkedIn grants | LinkedIn's public API | A narrow, well-defined surface – see [LinkedIn API access](/guides/linkedin-api-access) | | Local browser extension | Your own logged-in browser session | Your machine, your browser | Requires your browser and device to stay available; controls and pacing vary by implementation | | Wire-level client | Session material, held vendor side | Reverse-engineered calls to private endpoints | Traffic that is not a browser session | | Hosted browser session | An authorized session inside a browser bound to your account | That hosted browser | You connect the account once and the session persists | Two things are worth being precise about. First, every logged-in browser necessarily contains session state, so the useful question is never "cookies or no cookies" – it is who *receives and retains* what. Second, the third comparison dimension from the summary above, which actions and limits a connector exposes, is not in this table on purpose: it is answered by the next two sections. [Linked API](/) is one implementation of the last row. You sign in once through LinkedIn's own flow, the account gets its own dedicated cloud browser, and every action afterwards happens on LinkedIn's real pages at a human pace. Linked API never stores your password. ## What can an AI agent actually do on LinkedIn? The ceiling is set by the actions your connector exposes, not by how clever the model is. These are the operations available to an agent through Linked API, grouped by what the agent is trying to accomplish: | Group | Operations | Typical agent task | |---|---|---| | **Find** | `search_people`, `search_companies`, `search_jobs` | "Find product managers at fintech companies in Amsterdam" | | **Read** | `fetch_person`, `fetch_company`, `fetch_post`, `fetch_job`, `retrieve_connections`, `retrieve_feed`, `retrieve_ssi`, `retrieve_performance` | "Pull their experience and skills, and tell me who moved roles recently" | | **Act** | `send_connection_request`, `withdraw_connection_request`, `accept_invitation`, `send_message`, `react_to_post`, `comment_on_post`, `reply_to_comment` | "Connect with the ones who fit, with a note referencing their last post" | | **Monitor** | `sync_inbox` and `get_inbox`, `sync_network` and `get_network`, `check_connection_status`, `retrieve_pending_requests` | "Tell me who accepted and who replied since yesterday" | Each of these has a guide of its own with the depth this page skips: [profile data](/guides/linkedin-profile-scraper), [company data](/guides/linkedin-company-scraper), [messaging](/guides/how-to-automate-linkedin-messages), [connection requests](/guides/how-to-automate-linkedin-connection-requests), [posting](/guides/how-to-automate-linkedin-posts), [pending invitations](/guides/linkedin-pending-invitations) and [your SSI](/guides/linkedin-social-selling-index). The full list, including the Sales Navigator operations, is in [available tools](/mcp/available-tools). Sales Navigator has its own parallel set of operations, prefixed `nv_`. Those need a Sales Navigator seat on the account; everything in the table above does not. ## How do you connect an agent and run a first action? The sequence is the same whichever surface you pick, and the order matters – limits before the first run, reads before writes. **1. Connect the account once.** Sign in through the platform, which opens the account's dedicated cloud browser. This is the only step that touches LinkedIn credentials, and it happens on LinkedIn's own pages. **2. Choose a surface.** Two working modes – run it out of the box, or build it in – across four surfaces, all first-class. Pick by how you want to work, not by how much you want to build: | Surface | How you start | Best for | What to know | |---|---|---|---| | **Ready-made skills** | `npx @linkedapi/skills add linkedin`, or `add linkedin-growth` for hands-off network growth | You want the outcome, not the wiring – install and ask | Two published skills; needs Node.js 20+, the CLI and your tokens, and the installer checks for them | | **MCP server** | [Installation](/mcp/installation) | Claude, Claude Code, ChatGPT, Codex, Cursor, VS Code, Windsurf | Your agent has to speak MCP | | **Shell CLI** | `npm install -g @linkedapi/linkedin-cli`, then `linkedin setup` | Agents that run shell commands, plus your own scripts and CI | Shell-shaped output rather than a typed client | | **REST API and SDKs** | [Node and Python SDKs](/sdks) | Embedding the actions inside your own product or backend | You build and maintain the orchestration | ![The execution path for a LinkedIn AI agent: an agent such as Claude, Claude Code, ChatGPT, Codex or Cursor reaches Linked API through four surfaces – a ready-made skill, which runs on the shell CLI; the shell CLI directly; the MCP server; or the REST API and SDKs; inside Linked API the configured per-action limits gate every request before it reaches the account's dedicated cloud browser, which acts on LinkedIn](/images/guides/linkedin-ai-agent-execution-path.webp) **3. Set your tokens and your limits before the first run.** The surface needs your Linked API token and identification token. This is also the moment to configure per-action limits, covered in the next section. Set your own caps before the first run; otherwise that run uses the system defaults rather than your custom limits. **4. Test with a read.** Ask the agent for something that changes nothing: fetch a profile, run a search, pull your connections. You are checking that the account is connected, the output is what you expected, and the agent is calling the operation you thought it would. **5. Then approve a write.** Only once reads behave, let it send a connection request or a message – and read the copy it drafted before it goes out. ## What limits can you put on what the agent does? An agent will do exactly as much as you let it, which is a problem when the instruction was vague. Two distinct things help here, and they are worth keeping apart, because only the first is enforced by the platform. **Enforced by the platform.** You configure per-action limits on the account by category and period – 12 categories, including connection requests, messages, searches, reactions, comments, posts, profile views and skill endorsements, each settable for a `daily`, `weekly` or `monthly` window. An action that would cross a cap returns a `limitExceeded` error instead of executing. An enforced ceiling makes the agent's maximum activity predictable by action category and period. The [Limits API](/docs/admin-limits) covers reading defaults, setting your own and resetting them. Worth stating plainly: limits govern the volume and pace you configured. They are a control you set, not a guarantee about anything else. **Your own operating practices.** These are not platform behaviour, they are how you work: read before you write on a new setup, review outbound copy before it sends while you are still learning what the agent drafts, and start below the volume you eventually want. For LinkedIn's own position on automation and account restrictions, see our guide to a [restricted LinkedIn account](/guides/linkedin-account-restricted); the working numbers live in [understanding LinkedIn limits](/guides/understanding-linkedin-limits). ## When an agent is the wrong route An agent driving a session is interactive and non-deterministic. That is the point when you are exploring, and a drawback when you are not: - **You need the same job to run identically every night.** Scheduled, repeatable, auditable work belongs in your own code, calling the same operations through the REST API or the SDKs. You give up the plain-language interface and get determinism and logs in exchange. - **Your volume exceeds what an account can do.** Everything here runs through one LinkedIn account at a human pace, whichever surface you choose – the API is not a faster lane. Work that needs to move faster than an account can act does not fit this model at all. - **You only want better copy.** A writing assistant drafts your posts and comments without any of this setup. ## Frequently Asked Questions (FAQ) #### What is a LinkedIn AI agent? An AI agent that performs actions in a LinkedIn account rather than only generating text – searching for people, reading profiles, sending connection requests and messages, reacting and commenting. It reaches the account through a connector that holds an authorized session or token, and what that connector exposes sets what the agent can do. #### Can ChatGPT or Claude use my LinkedIn account directly? Not on their own. A model has no standing on LinkedIn until you connect it to something that carries authorization on your behalf. In practice that means adding a connector – an MCP server, a command-line tool the agent can run, or an API your own code calls – and pointing your agent at it. #### Do I have to give an AI agent my LinkedIn password? That depends entirely on the connector, and it is worth checking before you pick one. Some models take your password or your session cookie; token-based routes take neither. With Linked API you sign in once through LinkedIn's own flow to authorize a dedicated cloud browser for the account, and your password is never stored. #### Which AI agents and clients work with Linked API? Two separate paths. Any MCP client connects through the MCP server – Claude, Claude Code, ChatGPT, Codex, Cursor, VS Code and Windsurf among them. Separately, any agent that can run shell commands works through the CLI, which is how Claude Code, Cursor and Codex are typically driven, and which also suits scripts and CI. #### What can an AI agent not do on LinkedIn? It cannot exceed what the connector exposes or what the account itself can do. It has no access to data LinkedIn does not show that account, it cannot outpace a human-paced session, and it does not return personal email addresses or phone numbers. Sales Navigator operations additionally require a Sales Navigator seat. #### How many actions per day should I let an agent run? You decide, per action category and per day, week or month, and the platform holds that ceiling. There is no published universal number that applies to every account, so treat the cap you configure as your control rather than looking for an official figure. The [limits guide](/guides/understanding-linkedin-limits) collects the working numbers. #### Do I need Sales Navigator for an AI agent to work? No. Standard search, profile and company data, messaging, connections, posts and analytics all work on a regular LinkedIn account. A Sales Navigator seat is needed only for the `nv_` operations, which run inside Sales Navigator itself. #### How much does it cost to run an AI agent on LinkedIn? Linked API is flat per seat, from $49 per month billed annually, with no per-lead or per-credit metering – the agent calling an operation a hundred times costs the same as calling it once. See [pricing](/pricing) for the current plans. ## Give your agent an account it can actually use Pick the route that matches how you work. Install a [ready-made skill](/skills) and ask in plain language, connect the [MCP server](/mcp/overview) to your assistant, drive the [CLI](/cli/getting-started) from Claude Code, Cursor or Codex, or build it into your own product with the [REST API and SDKs](/sdks). Every route runs on the same dedicated cloud browser, on an account you own, inside the limits you set. ## LinkedIn API Access: What Standard Developer Access Gets You LinkedIn API access begins the same way for everyone: you arrive at the developer portal with a specific job in mind, create an application, open the Products tab, and find the list of things you can switch on is far shorter than expected. This guide covers exactly what standard developer access grants, how to set it up, and what to do about the jobs it does not cover. > **The short version.** Only a small set of LinkedIn products can be enabled without review, and every one of them is scoped to the member who authenticates: reading that member's own name, headline, photo and primary email through Sign in with LinkedIn, and posting or commenting as that member through Share on LinkedIn. Verified on LinkedIn adds a self-serve Development tier for testing. There is no endpoint a standard developer account can call to search for arbitrary people, look up another member by profile URL, send ordinary member-to-member messages, or read a member's inbox. For those jobs you drive an account you already own, which is what [Linked API](/pricing) does. ## What you can enable self-serve today LinkedIn states the rule plainly in its [access documentation](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access): > "Most permissions and partner programs require explicit approval from LinkedIn. Open Permissions are the only permissions that are available to all developers without special approval." These are the Open Permissions, added through the Products tab on your own application: | Product | Permission | What it grants | |---|---|---| | Sign in with LinkedIn using OpenID Connect | `profile` | **Member Auth**: Retrieve authenticated member's name, headline, and photo. | | Sign in with LinkedIn using OpenID Connect | `email` | **Member Auth**: Retrieve authenticated member's primary email address. | | Share on LinkedIn | `w_member_social` | **Member Auth**: Post, comment and like posts on behalf of an authenticated member. | One more route is self-serve and often missed. **Verified on LinkedIn** offers a Development tier you can enable immediately, restricted to admin accounts and intended for testing, plus a Lite tier you request self-serve for consenting members, per [LinkedIn's Verified API documentation](https://www.linkedin.com/help/linkedin/answer/a9385085). Its endpoints take their own scopes: [`/identityMe`](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/verified-on-linkedin/api-reference/identity-me) requires `r_profile_basicinfo`, and [`/verificationReport`](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/verified-on-linkedin/api-reference/verification-report) requires `r_verify_details`, with the older `r_verify` still accepted. Read the pattern rather than the list. **Every self-serve permission acts on the member who just signed in.** You get their details because they authenticated, and you publish as them because they authorised it. Nothing in this set reaches outward to other members. ## How to get self-serve LinkedIn API access The self-serve path is short, and most of the friction is in details that are easy to get wrong. 1. **Create an application** in the LinkedIn Developer Portal. You will be asked to associate it with a LinkedIn Page, so have one ready or create it first – an application cannot exist without it. 2. **Enable your products** on the application's Products tab. Sign in with LinkedIn using OpenID Connect and Share on LinkedIn appear immediately. Verified on LinkedIn is requested from the same tab: accept the terms and the Development tier is provisioned automatically, while Lite is a self-serve request. Anything reviewed shows an application flow instead of a switch. 3. **Configure your redirect URL** on the Auth tab. It must match byte for byte what your application sends, including the scheme and any trailing path, per LinkedIn's [3-legged OAuth flow](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow). A mismatch fails the authorisation request rather than degrading. 4. **Request only the scopes your enabled products grant.** A scope your application has not been granted is rejected outright, so a request that mixes granted and ungranted scopes returns nothing at all. 5. **Send the member through the authorisation flow**, exchange the returned code for an access token, and make your first call as that member. ## How do I get a LinkedIn API key? LinkedIn does call it an API key – but it is not what authenticates your calls, and that mismatch is why this step stalls. The [3-legged OAuth documentation](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow) names the credentials in one line: > "Each application is assigned a unique **Client ID** (Consumer key/API key) and **Client Secret**." So the Client ID **is** the API key, and it sits with the Client Secret on your application's **Auth** tab. Neither goes in a request header. You exchange them, plus the member's consent, for an **OAuth bearer access token**, and that token is what authenticates every call – `Authorization: Bearer {token}`. People searching for a LinkedIn API key usually want the second thing and find the first. Three lifespans decide your architecture, and all three come from the same page: | Credential | Lifespan | What it means for you | |---|---|---| | Authorization code | **30 minutes**, "must be used immediately" | Exchange it in the same request cycle, never queue it | | Access token | **60 days** – "all access tokens are issued with a 60-day lifespan" | Nothing you build runs unattended past that window | | Refresh | Repeat the browser authorization flow | Can be **silent** – if the member is still logged in to LinkedIn and their token has not expired, "the authorization screen is bypassed" | The consequence is the part that surprises people. LinkedIn states that "to protect members' data, LinkedIn does not generate long-lived access tokens", and that **"programmatic refresh tokens are available for a limited set of partners"**. A standard application therefore has to send the member back through the browser authorization flow – often invisibly, while their session is alive and the token has not lapsed, but always through the browser. Only approved partners get a server-side refresh token. For a feature a member clicks, that is fine. For a backend job that runs every night, it is a standing dependency on a live member session. ## Is the LinkedIn API free? Yes, today – and LinkedIn says so in writing while reserving the right to change it. Section 8.2 of the [API Terms of Use](https://www.linkedin.com/legal/l/api-terms-of-use) is one sentence: > "The APIs are currently provided for free, but LinkedIn reserves the right to charge for the APIs in the future." The catch is that price was never the barrier. Creating an application and switching on the Open Permissions costs nothing, and everything past that set is not priced but **applied for**, per LinkedIn's [Getting Access](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access) documentation: - **Marketing** – Advertising API access "must be approved", requested through the Developer Portal. - **Sales** – the Sales Navigator endpoints require approval as a **SNAP partner**. - **Talent** – Recruiter System Connect, Apply Connect and Premium Job Posting go through the talent-partner application. - **Compliance** – "listed for reference purposes only. Access is closed and may not be requested." So the question behind the question – *can I pay to unlock people search, profile lookup or messaging?* – answers itself. **Cost is not the gate. Approval is**, and Compliance is closed at any price. That is why searching for LinkedIn API pricing returns so little: the APIs that are free are the ones you can have, and the ones you want are not sold. A price appears only on the account-based route – [Linked API](/pricing) is flat per seat from $49/mo billed annually, with no per-lead or per-credit metering. ## Where the LinkedIn API documentation lives Not on linkedin.com, which is why searching the site for it comes up short. LinkedIn's developer documentation is published on **Microsoft Learn**, under [`learn.microsoft.com/en-us/linkedin`](https://learn.microsoft.com/en-us/linkedin/). | What you want | Where it is | |---|---| | The permissions table and what needs approval | [Getting Access](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access) | | OAuth flows, credentials, token lifetimes | [Authorization Code Flow](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow) | | A token for manual testing | The Developer Portal's [token generator](https://www.linkedin.com/developers/tools/oauth/token-generator) | | Your app's live scopes | The **Auth** tab in the [Developer Portal](https://www.linkedin.com/developers/apps) – it "will show current scopes available" | Read the Auth tab as the source of truth over any document, including this one: the docs describe what exists, while the tab shows what *your* application was actually granted. ## What standard developer access does not reach Four jobs come up constantly, and none of them is reachable this way: ![Self-serve LinkedIn access covers you, not other people: included are your own name, headline and photo, your email address, posting as you, and your verified identity, all granted when you sign in; not included are finding people, reading a profile, sending a message and reading an inbox, which need an account you already own](/images/guides/linkedin-api-access-scope.webp) Some of these capabilities do exist inside narrowly scoped reviewed and partner integrations, which are bound to a product context rather than exposed as general-purpose endpoints and are [approval-gated](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access). If you work in Sales Navigator, that picture is covered in our [Sales Navigator API guide](/guides/linkedin-sales-navigator-api). The practical position is simpler than the documentation makes it look: **for these four jobs there is nothing a standard developer account can switch on.** ## Official API or unofficial API: what people actually mean "Unofficial LinkedIn API" is a search term rather than a product category, and it covers several architectures that behave nothing alike. The distinction that decides your risk is not official versus unofficial – it is **whose account acts, and what the tool holds on your behalf**. | Route | Whose account acts | What it holds | Reaches beyond the signed-in member? | |---|---|---|---| | LinkedIn self-serve products | The member who authenticated | An **access token** you obtained with their consent | No – that is the design | | Wire-level clients | Whatever account you point it at | **Private-endpoint requests**, reverse-engineered | Yes, outside anything LinkedIn documents | | Credential tools | An account you own | Your **password**, used to obtain your **session cookies** | Yes | | Cookie tools | An account you own | Your **`li_at` session cookie** | Yes | | Linked API | An account you own | A **real, dedicated cloud browser** working on LinkedIn's own pages at a human pace | Yes | **Linked API never stores your password.** No route eliminates automation risk. What each tool holds on your behalf, however, is a fact you can check before you commit. Approved partner programs are a separate case again: they are official, and some of them do reach past the signed-in member – but they are entered by application, as the permissions table above shows. ## What to build on instead The alternative is to work through an account you already have. The member is you or your team, the account is one you own, and the primitives are exposed programmatically. Both routes below are first-class – pick by how you want to work, not by how much you want to build. ### Build it in – REST, Node and Python SDKs, CLI Compose the primitives and embed them in your product. A search returns each person's `publicUrl`, which you pass straight into a fetch: ```typescript import { LinkedApi } from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: 'your-linked-api-token', identificationToken: 'your-identification-token', }); const workflow = await linkedapi.searchPeople.execute({ term: "Head of Engineering", limit: 10, filter: { locations: ["San Francisco"], industries: ["Software Development"], }, }); const { data, errors } = await linkedapi.searchPeople.result(workflow.workflowId); if (errors?.length) { errors.forEach((error) => console.warn(`${error.type}: ${error.message}`)); } if (data) { for (const person of data) { const detail = await linkedapi.fetchPerson.execute({ personUrl: person.publicUrl }); const result = await linkedapi.fetchPerson.result(detail.workflowId); if (result.data) { console.log(result.data.name, result.data.headline, result.data.location); } } } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, SearchPeopleParams, FetchPersonParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.search_people.execute( SearchPeopleParams( term="Head of Engineering", limit=10, filter={ "locations": ["San Francisco"], "industries": ["Software Development"], }, ) ) result = linkedapi.search_people.result(workflow.workflow_id) for error in result.errors or []: print(f"{error.type}: {error.message}") if result.data: for person in result.data: detail = linkedapi.fetch_person.execute( FetchPersonParams(person_url=person.public_url) ) person_result = linkedapi.fetch_person.result(detail.workflow_id) if person_result.data: print( person_result.data.name, person_result.data.headline, person_result.data.location, ) ``` The same primitives cover the other three jobs. Messaging is `sendMessage`, and the inbox is a two-step pattern rather than a single read: `syncInbox` starts monitoring, then `pollInbox` retrieves what has arrived since. The same operations run from the shell as `linkedin person search`, `linkedin person fetch`, `linkedin message send`, `linkedin inbox sync` and `linkedin inbox get`. Each job has its own guide with the depth this page deliberately skips: [people search](/docs/searching-for-people), [profile data](/guides/linkedin-profile-scraper), and [messaging and inbox](/guides/how-to-automate-linkedin-messages). ### Run it out of the box – MCP, the AI-agent-friendly CLI, ready-made skills If you would rather not build the integration at all, an AI agent can do it. Connect the [MCP server](/mcp), point an agent at the [CLI](/cli) from Claude Code, Cursor or Codex, or install ready-made skills with `npx @linkedapi/skills`. You describe the job in plain language and the agent composes and runs the same workflows. Nothing to wire, no flows to configure. ## How to use the LinkedIn API with Python Two different things answer to "LinkedIn API in Python", and the split is the same one that runs through this whole page. LinkedIn publishes an official Python client for its REST products, [`linkedin-api-python-client`](https://github.com/linkedin-developers/linkedin-api-python-client); it changes the syntax, not the scope – the same self-serve permissions and the same approval gates apply, so it will not search people or read another member's profile. For those jobs the Python route is the account-based one: install the Linked API SDK, pass the two tokens from your dashboard, and call the same primitives shown in the search-then-fetch example above. ```python from linkedapi import LinkedApi, LinkedApiConfig, FetchPersonParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.fetch_person.execute( FetchPersonParams(person_url="https://www.linkedin.com/in/example") ) result = linkedapi.fetch_person.result(workflow.workflow_id) print(result.data.name if result.data else result.errors) ``` `pip install linkedapi` is the whole setup. Every action follows the same `execute` then `result` shape, and the [Python SDK reference](/sdks/methods) lists each one with its parameters; for scraping-specific Python patterns see the [LinkedIn scraper in Python](/guides/linkedin-scraper-python) guide. ## When the self-serve products are the right answer Reach for LinkedIn's own products, not an alternative, when the job actually matches what they do: - **You need members to sign in.** Sign in with LinkedIn using OpenID Connect is built for exactly this, and nothing else should be used for authentication. - **You need to publish as the member who authorised you.** Share on LinkedIn covers posting, commenting and liking on their behalf. - **You need identity verification signals.** Verified on LinkedIn is the route, starting on the Development tier. The account-based route earns its place when the job reaches beyond the authenticated member – reading data about other people, or acting continuously rather than at the moment someone clicks a button. ## Frequently Asked Questions (FAQ) #### What LinkedIn API access can I get without approval? The Open Permissions – `profile` and `email` through Sign in with LinkedIn using OpenID Connect, and `w_member_social` through Share on LinkedIn – plus Verified on LinkedIn, whose Development tier is provisioned automatically on accepting the terms and whose Lite tier is a self-serve request. Every one of these acts on the member who authenticated. #### How do I set up self-serve LinkedIn API access? Create an application in the Developer Portal and associate it with a LinkedIn Page, enable your products on the Products tab, set the redirect URL on the Auth tab so it matches your application exactly, request only the scopes your enabled products grant, then send the member through the authorisation flow and exchange the code for a token. #### Why can't I search for people with standard developer access? There is no general-purpose people-search endpoint available to a standard developer account. Search capabilities exist inside narrowly scoped reviewed and partner integrations tied to a product context, not as an open endpoint. The alternative is to run the search through an account you own. #### Can I read another member's profile with standard developer access? No. The self-serve permissions return the authenticated member's own details, not other members'. Reading another member's profile from a URL is not something a standard developer account can enable, which is why account-based routes exist. #### Is the LinkedIn API free? Yes for now: section 8.2 of LinkedIn's API Terms of Use states that "the APIs are currently provided for free, but LinkedIn reserves the right to charge for the APIs in the future". Price was never the barrier, though – everything beyond the Open Permissions is gated by approval rather than payment, with Marketing needing Advertising API approval, Sales needing SNAP partner status, Talent going through the partner application, and Compliance "closed and may not be requested". You cannot pay to unlock people search, profile lookup or member-to-member messaging. #### How do I get a LinkedIn API key? LinkedIn calls your Client ID the API key, but it is not what authenticates a call. The Client ID and Client Secret sit on your application's Auth tab, and you exchange them plus the member's consent for an OAuth bearer access token, which is what the API actually accepts. Access tokens carry a 60-day lifespan, and programmatic refresh tokens go only to a limited set of partners, so a standard application repeats the browser authorization flow – silently while the member is still logged in and the token has not expired. #### Where is the LinkedIn API documentation? On Microsoft Learn, not on linkedin.com – `learn.microsoft.com/en-us/linkedin` holds the permissions table, the OAuth flows and each partner program's space. Your application's Auth tab in the Developer Portal shows the scopes actually granted to you, which is the figure to trust over any document. #### Is there a LinkedIn API for Python? Two, with the distinction that runs through this page: LinkedIn's official `linkedin-api-python-client` wraps its REST products under the same approval gates, so it cannot search people or read other members' profiles; the Linked API Python SDK (`pip install linkedapi`) exposes the account-based primitives – search, fetch, message – on an account you own. #### Is there an unofficial LinkedIn API? Not as an official product, and the phrase covers two different things: wire-level clients that call LinkedIn's private endpoints directly, and account-driven services that operate an account you already own. The useful question is not whether a route is official but whose account acts and what the tool holds on your behalf. #### What do I use when self-serve products don't cover the job? An account-based API that drives a LinkedIn account you already own. Linked API exposes people search, profile fetch, messaging and inbox as programmable primitives through the REST API, Node and Python SDKs and the CLI, or out of the box through MCP, the AI-agent-friendly CLI and ready-made skills. ## Build on the account you already have If the self-serve products cover your job, use them. If your job needs to reach past the member who signed in, Linked API runs those operations on an account you own, through a dedicated cloud browser at a human pace. Embed it through the [REST API, SDKs and CLI](/sdks), or skip the build and let an agent run it through [MCP](/mcp) or [ready-made skills](/skills). Pricing is flat per seat – see [pricing](/pricing). *Facts verified 24 August 2026 – LinkedIn permission names, product availability, endpoint scopes, credential terminology and token lifespans checked against LinkedIn's official developer and help documentation on that date.* ## LinkedIn Sales Navigator API: What Exists, Who Can Use It, and What to Build On Sales Navigator holds the search filters, lead lists, and account data your team actually works from, and sooner or later someone asks whether the LinkedIn Sales Navigator API can drive all of it from code. The answer that circulates online is a muddle: some pages walk you through applying for access, others say applications are closed, and a few manage both on the same page. This guide states what the official API is, who can actually use it, and what the remaining routes give you. > **The short version.** LinkedIn does run an official Sales Navigator API – the Sales Navigator Application Platform, with three service families – but its own documentation says LinkedIn is **not currently accepting new partners**, adding that it reviews onboarding capacity periodically and will update the page if that changes. So the practical question is which of four situations you are in: you are already an approved partner; your CRM vendor already ships an approved integration; you need a one-off extraction, where an export tool beats any API; or you need repeatable automation on a Sales Navigator seat you own, which is where an account-based platform like [Linked API](/pricing) fits. ## Is there an official LinkedIn Sales Navigator API? Yes, and it is a real product with real documentation: the **Sales Navigator Application Platform (SNAP)**. It is also gated. LinkedIn's [SNAP documentation](https://learn.microsoft.com/en-us/linkedin/sales/) states plainly: > "We are not currently accepting new partners for access to the LinkedIn Sales Navigator API. We periodically review our onboarding capacity and will update this page if availability changes." That second sentence matters. This is a current state, not a permanent one, and any page telling you the programme is simply "closed" is as wrong as one walking you through an application form. SNAP is organised into three service families: | Service family | What it does | Shape | |---|---|---| | **Display Services** | UI modules embedded in your application via iframe or JavaScript SDK: view profiles and accounts, see recent activity and posts, connect and send messages including InMail, plus the Profile Associations API for photos and profile links | Embedded interface | | **Analytics Services** | Bulk export API for activities performed by sales teams, plus seat-holder metadata such as daily [Social Selling Index](/guides/linkedin-social-selling-index), total connections, and total leads saved | Bulk data export | | **Sync Services** | APIs built on top of CRM Sync, using matches between CRM records and LinkedIn to enable data-integrity workflows and CRM UI experiences | CRM integration | One distinction gets blurred constantly, so it is worth being precise. Display Services **does** let a message or InMail be sent – but by a **seller acting inside an embedded UI module** in your application. That is not a raw messaging endpoint, and it is not an automated outbound API. If you read "the Sales Navigator API can send messages" somewhere and pictured a POST request in a nightly job, that is not what is on offer. Use of these APIs is governed by the LinkedIn SNAP Terms of Use unless you have executed a separate signed partnership agreement. ## How to get Sales Navigator API access There is no application to file today. SNAP partner onboarding is paused – LinkedIn's own documentation, quoted above, says it is not currently accepting new partners and will update that page if capacity changes. If your CRM already ships a SNAP integration, you get the official surfaces through it with no access request at all. For every other case, Sales Navigator API access means acting on a seat you own: a Sales Navigator subscription on that seat plus an account-based platform that exposes its actions programmatically, which is the route the rest of this guide describes. ## Where the Sales Navigator API documentation lives The official documentation is on Microsoft Learn, not on linkedin.com: [learn.microsoft.com/en-us/linkedin/sales](https://learn.microsoft.com/en-us/linkedin/sales/) holds the Display, Analytics and Sync service references, the SNAP Terms of Use and the onboarding notice. For the account-based route, the Sales Navigator actions are documented alongside the standard ones – start with [`nv.searchPeople`](/docs/action-nv-search-people) and [`nv.sendMessage`](/docs/action-nv-send-message) – with the same surface available from the [CLI](/cli/commands#sales-navigator) and as [LinkedIn Sales Navigator MCP tools](/mcp/available-tools#linkedin-sales-navigator-mcp-tools). ## Which route fits your situation Most guides route everyone through "apply to become a partner". That is the least likely path to be relevant to you. Find your row first. | Your situation | The right route | What you get | |---|---|---| | **You are an approved SNAP partner** | The official Sales Navigator APIs | Display, Analytics, and Sync services under your partnership agreement. Nothing here beats them. | | **Your CRM already ships a SNAP integration** | Your CRM vendor's existing feature | Sales Navigator data inside the CRM you already pay for, with no development work at all | | **You need a one-off extraction** | An export tool or a dataset | A CSV of a saved search, or bulk records with no owned seat | | **You need repeatable automation on a seat you own** | An account-based automation platform | Search, fetch, message, and conversation sync as programmable primitives on your own seat | ## What each route actually gives you ### If you are an approved SNAP partner Use the official APIs and stop reading. You have Display Services for embedded LinkedIn surfaces, Analytics Services for bulk activity export, and Sync Services on top of CRM Sync, all under a signed agreement. No third-party route competes with sanctioned access on a supported contract. ### If your CRM already ships a SNAP integration This is the situation nearly every guide omits, and it covers far more readers than partnership does. You do not need to become a SNAP partner to benefit from SNAP – you need a vendor who already is one. Major CRM platforms ship Sales Navigator integrations built on exactly these services, which is what Sync Services exists for. Before you write any code, check whether the integration you want already exists in your CRM's marketplace. Building an account-based workflow to replicate a feature your CRM already ships is wasted effort. ### If you need a one-off extraction If what you actually need is a spreadsheet of one saved search, an export tool is the right answer and an API is overkill. Sales Navigator does not export leads or accounts to CSV or XLS natively, which is precisely why this tool category exists. Our [Sales Navigator export guide](/guides/linkedin-sales-navigator-scraper) compares the options. The trade-off is that you get a snapshot. There is no workflow, nothing to trigger, and nothing to act on. ### If you need repeatable automation on a seat you own This is the route for teams building Sales Navigator behaviour into a product, backend, or CRM of their own: search, open a lead, send a message, sync the conversation, then poll for the reply, all executed on a Sales Navigator seat you already pay for. More than one product serves this. Unipile covers the same ground on capability, per its own product documentation – search, sending, replies, inbox and conversation sync, and webhooks – so this is not a question of who can do it. The difference is architectural: Linked API drives your own seat through a dedicated real browser session with pacing and per-action limits enforced by the platform, while a reverse-engineered client constructs wire-level requests against LinkedIn's private interface and, per Unipile's own API documentation, its GA v1 leaves pacing to the caller while its v2 beta adds enforced rate limits – a structurally higher-risk position for the account, not a matter of configuration. The full side-by-side is in [Linked API vs Unipile](/vs/unipile). Neither model eliminates risk; automating a real account never does. Neither product is affiliated with, endorsed by, or sponsored by LinkedIn – Unipile states this plainly on its own site, and the same is true of Linked API. What differs is where the safety engineering lives: on [Linked API](/safety) it is the platform's job end to end, in a cloud browser that loads LinkedIn's own pages at a human pace. ## Two ways to run it Both routes below are first-class. Pick the one that matches how you work, not how much you want to build. ### Build it in – REST, Node and Python SDKs, CLI Compose the primitives yourself and embed them in your product. [`nvSearchPeople`](/docs/action-nv-search-people?surface=sdk) returns each person's `hashedUrl`, which is the durable key you pass to [`nvFetchPerson`](/docs/action-nv-open-person-page?surface=sdk): ```typescript const workflow = await linkedapi.nvSearchPeople.execute({ term: "Head of Engineering", limit: 10, filter: { locations: ["San Francisco"], industries: ["Software Development"], }, }); const { data, errors } = await linkedapi.nvSearchPeople.result(workflow.workflowId); if (errors && errors.length > 0) { errors.forEach((error) => console.warn(`${error.type}: ${error.message}`)); } if (data) { for (const person of data) { const detail = await linkedapi.nvFetchPerson.execute({ personHashedUrl: person.hashedUrl, }); const result = await linkedapi.nvFetchPerson.result(detail.workflowId); if (result.data) { console.log(result.data.name, result.data.position, result.data.companyName); } } } ``` ```python from linkedapi import NvSearchPeopleParams, NvOpenPersonPageParams workflow = linkedapi.nv_search_people.execute( NvSearchPeopleParams( term="Head of Engineering", limit=10, filter={ "locations": ["San Francisco"], "industries": ["Software Development"], }, ) ) result = linkedapi.nv_search_people.result(workflow.workflow_id) for error in result.errors or []: print(f"{error.type}: {error.message}") if result.data: for person in result.data: detail = linkedapi.nv_fetch_person.execute( NvOpenPersonPageParams(person_hashed_url=person.hashed_url) ) person_result = linkedapi.nv_fetch_person.result(detail.workflow_id) if person_result.data: print( person_result.data.name, person_result.data.position, person_result.data.company_name, ) ``` The same primitives are available over the [REST API](/docs/actions-overview) and the [shell CLI](/cli/commands#sales-navigator) – `navigator person search`, `navigator person fetch`, `navigator message send`. Outreach itself is a separate job with its own rules; the messaging flow is covered in [How to Automate LinkedIn Messages](/guides/how-to-automate-linkedin-messages). ![Sales Navigator workflow architecture: nv.searchPeople feeds an nv.doForPeople iteration block, which opens each nv.openPersonPage, executed sequentially in a dedicated cloud browser on your own Sales Navigator seat and returning structured JSON, with conversation polling running outside the workflow](/images/guides/linkedin-sales-navigator-api-architecture.webp) Composed as a single workflow, [`nv.searchPeople`](/docs/action-nv-search-people) fans out through an [`nv.doForPeople`](/docs/action-nv-do-for-people) block into an [`nv.openPersonPage`](/docs/action-nv-open-person-page) per result, and the platform runs the whole tree sequentially on your seat. Replies are handled separately: send the message, sync the conversation, then poll for the reply later. ### Run it out of the box – MCP, the AI-agent-friendly CLI, ready-made skills If you would rather not build the integration at all, an AI agent can do it for you. Connect the [MCP server](/mcp) – its [LinkedIn Sales Navigator MCP tools](/mcp/available-tools#linkedin-sales-navigator-mcp-tools) cover search, messaging and inbox sync on a Plus seat – or point an agent at the [CLI](/cli) from Claude Code, Cursor, or Codex, or install ready-made skills with `npx @linkedapi/skills`. You describe the job in plain language – "find heads of engineering at Series B software companies in San Francisco and summarise their recent activity" – and the agent composes and runs the same workflows. Nothing to wire, no blocks to drag. ## When another route is the better choice - **You need one spreadsheet, once.** Use an export tool. An API is a worse answer to a question that ends when the CSV downloads. - **You need bulk records and own no Sales Navigator seat.** A dataset provider is the honest fit. Account-based routes require a seat, because they work through it. - **You want Sales Navigator surfaces inside a CRM UI.** If an approved SNAP integration exists for your CRM, it will beat anything you assemble, and it comes with vendor support. - **You are already an approved partner.** Use what you have. ## Frequently Asked Questions (FAQ) #### Is there an official LinkedIn Sales Navigator API? Yes. The Sales Navigator Application Platform (SNAP) offers Display, Analytics, and Sync services. It is gated to approved partners, and LinkedIn's own documentation states it is not currently accepting new partners, while noting it reviews onboarding capacity periodically and will update that page if availability changes. #### Can I apply to become a SNAP partner? Not at the moment. LinkedIn's SNAP documentation says it is not currently accepting new partners. Third-party guides that walk you through an application flow are describing a process that is not open, though LinkedIn does say it will update the page if capacity changes, so the state is worth re-checking rather than assuming permanence. #### Can the official Sales Navigator API send messages? Partly, and the detail matters. Display Services lets a seller send messages and InMail from inside an embedded UI module in your application. That is a person acting through an interface you have embedded, not a raw messaging endpoint and not an automated outbound API. #### Do I need a Sales Navigator subscription to automate it? Yes. Every route that works through your own seat requires that seat to exist. Sales Navigator is only needed for Sales Navigator actions – standard LinkedIn people, company, and post data does not require it. For what that seat costs per tier, and which accounts need one at all, see our [Sales Navigator cost guide](/guides/linkedin-sales-navigator-cost). #### Can I get emails or phone numbers from Sales Navigator? No. Linked API's Sales Navigator result types carry no email or phone field. Search results return name, hashed URL, position, location, and avatar URL, and a person fetch adds headline, position, and company name. #### What is the difference between a Sales Navigator export tool and an account-based API? An export tool produces a snapshot, usually a CSV, and the job ends there. An account-based API exposes Sales Navigator actions as composable primitives – search, fetch, message, sync – on your own seat, so you can build a repeatable workflow that reads and acts from inside your own code. ## Build it on a seat you own If the official API is not open to you and a CSV is not enough, Linked API runs Sales Navigator workflows on your own seat through a dedicated cloud browser, with pacing and limits enforced by the platform. Embed it through the [REST API, SDKs, and CLI](/sdks), or skip the build entirely and let an agent run it through [MCP](/mcp) or [ready-made skills](/skills). Pricing is flat per seat, from $49 per month billed annually – see [pricing](/pricing). *Facts verified 29 July 2026 – SNAP service descriptions and partner-availability wording checked against LinkedIn's official documentation, and competitor capability claims checked against their live pages, on that date.* ## LinkedIn Scraper in Python: Libraries, Code, and What Each Option Costs Most guides to building a LinkedIn scraper in Python compare the routes on how much code you write. That is the smaller half of the decision. The larger half is what runs your LinkedIn account while the code executes, because the routes differ far more there than they do in what they can extract. This guide covers all three practical routes, with the same extraction written twice so you can compare them directly, and with the current release and repository status of the libraries people usually recommend. One thing to know before you start reading around: a good share of the Python LinkedIn scraping guides you will find are published by companies that sell proxy and unblocking infrastructure – ScraperAPI, Scrapfly and Bright Data among them. They cover the routes that need that infrastructure well. The route that runs through an authenticated account is the one that tends to be missing, which is why it gets equal space here. > **The short version.** The Python routes differ less in what they extract than in what runs your LinkedIn account while they work, and that is what decides whether your account survives the project. Three practical routes. **Linked API's authenticated account API** drives a browser you signed into yourself, at human pace, under per-action limits you set, and returns structured data with no HTML parsing, no selector upkeep and no proxy pool. An **open-source library** is free and fastest to start, but it runs an automated browser on a session file you exported – or, in the Voyager case, calls LinkedIn's private endpoints directly – and you inherit its maintenance: the most popular scraper library rewrote its entire API in its last major version, and the widely referenced Voyager library has not shipped a release since November 2024. A **DIY browser script** gives maximum control and maximum upkeep, with pacing, session handling and infrastructure all yours to build. **No route is risk-free for your account**, so the practical question is which one you can run at a pace your account tolerates. ## The Python options for scraping LinkedIn, compared This page compares the **Python routes**. If your question is which API *product* to buy rather than which library to write against – dataset, real-time scraper, or account-based – that comparison lives in [LinkedIn scraper API](/guides/linkedin-scraper-api), with per-entity coverage and current vendor prices. | Route | What it is | What LinkedIn's servers see | What it costs you | Best for | | --- | --- | --- | --- | --- | | **Authenticated account API (Linked API)** | Hosted browser per account, structured responses | A browser you signed into yourself, acting at human pace under configured limits | Subscription; account-paced | Production pipelines that must keep running | | **`linkedin-scraper`** | Playwright library for profiles, companies, jobs | An automated browser driving authenticated state you exported | Free; you follow its releases and API changes | Getting something working today | | **`linkedin-api`** | Client for LinkedIn's internal Voyager endpoints | Direct requests to private, undocumented endpoints | Free; no release since November 2024 | Reading the approach, not shipping on it | | **DIY Playwright / Selenium** | Your own browser automation | Same as above, with every detail your responsibility | Free; ongoing selector and infrastructure work | Full control, unusual requirements | | **Scrapy** | HTTP crawling framework, no browser | Plain HTTP requests; signed-out unless you build session handling | Free; auth and blocking are yours to solve | Crawling at scale beyond LinkedIn | | **Proxy / unblocker products** | Request infrastructure for public pages | Signed-out public-page requests spread across many addresses | Metered; a different dataset | Bulk signed-out collection | ## What LinkedIn's servers see from each route This is the part most comparisons skip, and it is the one that decides whether a scraper survives contact with production. What follows describes what each route *provides* architecturally. Nobody outside LinkedIn can tell you what its systems flag, and this guide does not try. ### Authenticated account API (Linked API) Each connected account runs in its own dedicated cloud browser on LinkedIn's real pages. You sign in yourself inside that browser, and Linked API never sees or stores your password. The browser keeps a stable residential IP from your region and a consistent device profile, and actions run at human pace – a simple visit-and-like takes around 20 seconds by design. You configure per-action daily, weekly and monthly limits, and the platform enforces them: an action that would cross one returns a `limitExceeded` error instead of executing. The [safety model](/safety) covers the architecture, and the [limits guide](/guides/understanding-linkedin-limits) covers sensible values. ### linkedin-scraper, and any DIY Playwright or Selenium script What this route gives you is **reusable authenticated state**. The library's own quick start calls `browser.load_session("session.json")`, which means a credential-equivalent artifact lives in your filesystem – and, if you are not careful, in your repository. What it does not provide is an account-bound stable device and network identity, or enforced pacing. Beyond that, the footprint depends entirely on your runtime: a script running on your own machine may well use the same region and network the account normally signs in from, and the session itself can be created by logging in manually inside that browser. ### linkedin-api This route is wire-level. In the library's own words, "To retrieve structured data, the LinkedIn Website uses a service they call Voyager. Voyager endpoints give us access to pretty much everything we could want from LinkedIn: profiles, companies, connections, messages, etc." Those endpoints are private and undocumented, which means there is no public compatibility contract behind them. If LinkedIn changes an endpoint, a client that has not adapted can keep sending an outdated request shape, and that shape is potentially distinctive on the server side. Read that alongside the release history below: nothing has shipped since November 2024. ### Scrapy and plain HTTP No browser is involved at all. Authenticated pages require session handling you build and maintain yourself, and without a session you receive only what LinkedIn shows signed-out, which is a much thinner page than the one you see logged in. ### Proxy and unblocker products These distribute signed-out public-page requests across many IP addresses. That is a different architecture solving a different problem, so it is not a like-for-like comparison with account-based throughput – there is no account in the picture at all. ### No route is risk-free Every route on this page, including ours, can end in a restricted account. Architecture changes the shape of the footprint your account leaves, not whether a footprint exists. Our guide to [scraping LinkedIn](/guides/how-to-scrape-linkedin) covers how restrictions actually happen, and the guide to a [restricted LinkedIn account](/guides/linkedin-account-restricted) covers what to do if you hit one. ## The open-source libraries: current release and repository status Both libraries below are recommended constantly in tutorials and forum threads. Their current state is worth checking before you build on either, and it is easy to check from primary sources. ### linkedin-scraper Actively maintained. The current release on [PyPI](https://pypi.org/project/linkedin-scraper/) is **3.1.2**, published 10 April 2026, with several releases earlier that year. Its GitHub repository has around 4,369 stars and 964 forks and was created in 2017. It describes itself as an "Async LinkedIn scraper built with Playwright for extracting profile, company, and job data from LinkedIn." The cost you inherit is API churn, and the project states it plainly in its own README: "**Version 3.0.0 introduces breaking changes and is NOT backwards compatible with previous versions.**" That release replaced Selenium with Playwright in a complete rewrite, moved everything to async/await, restructured the package, and switched to Pydantic data models. Code written against 2.x does not run on 3.x; the migration path offered is to pin the old version with `pip install linkedin-scraper==2.11.2`. That is healthy maintenance, not a defect – a scraper that never changes is a scraper that has stopped tracking its target. But it is work that lands on you rather than on a vendor. One detail to check before embedding it in a commercial product: the `LICENSE` file in the repository is the **GNU General Public License v3**, while the PyPI metadata and the README badge both say Apache 2.0. Those licenses have very different implications for closed-source distribution, so confirm which applies to your use before shipping. ### linkedin-api The current release on [PyPI](https://pypi.org/project/linkedin-api/) is **2.3.1**, published 7 November 2024, and nothing has shipped since. It requires Python 3.10 or newer, and its PyPI metadata lists MIT. Its linked sources are, at the time of writing, unreachable. Under PyPI's "Unverified details" the project lists a GitHub URL as both Homepage and Repository, and a ReadTheDocs URL as Documentation. Both return HTTP 404. The tomquirk GitHub account itself resolves normally, so this is specific to the repository rather than the account, and a control request to another LinkedIn scraping repository returned normally, so it is not a rate-limiting artifact. We report that as observed state and draw no conclusion about why. What it means practically is straightforward: the package is still installable, its source and documentation are not currently reachable at the addresses it publishes, and the wire-level approach described above has had no shipped adaptation for well over a year. ## Rolling your own with Playwright Here is the task both of the next two blocks solve, so you can compare them line for line: **given one LinkedIn profile URL, return the person's name, headline, and location.** The DIY version launches a browser, loads a session you saved earlier, opens the page, and reads three fields: ```python import asyncio from playwright.async_api import async_playwright async def scrape_profile(url: str) -> dict: async with async_playwright() as p: browser = await p.chromium.launch() # storage_state carries the logged-in session you exported earlier context = await browser.new_context(storage_state="session.json") page = await context.new_page() await page.goto(url, wait_until="domcontentloaded") name = await page.locator("main h1").first.inner_text() headline = await page.locator("main div.text-body-medium").first.inner_text() location = await page.locator("main span.text-body-small").first.inner_text() await browser.close() return { "name": name.strip(), "headline": headline.strip(), "location": location.strip(), } print(asyncio.run(scrape_profile("https://www.linkedin.com/in/john-doe"))) ``` Three lines in that snippet are the whole maintenance story. The selectors are illustrative and will not match forever – LinkedIn's class names are generated and its profile layout varies by account type, by whether sections are present, and by which experiment a given page is in. `storage_state="session.json"` is a file holding your authenticated session. And nothing in the script paces anything: it runs as fast as the event loop allows. Scaling this means solving, in order: selector drift, layout variants, pagination, session expiry, retry logic, and eventually request infrastructure. That work never finishes, which is the honest argument for and against the route – it is entirely yours to control, and entirely yours to maintain. ## The same extraction through an authenticated account API The same task, through a connected account. Install the SDK and initialize it with your tokens: ```bash pip install linkedapi ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, FetchPersonParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.fetch_person.execute( FetchPersonParams(person_url="https://www.linkedin.com/in/john-doe") ) # result() waits for the workflow to finish result = linkedapi.fetch_person.result(workflow.workflow_id) for error in result.errors or []: print("workflow error:", error.type, error.message) if result.data: print({ "name": result.data.name, "headline": result.data.headline, "location": result.data.location, }) ``` No selectors, no session file, no browser to launch. `fetch_person` takes flags for the heavier parts of a profile – experience, education, skills, languages, recent posts – and returns them as typed fields rather than text you parse; the [SDK reference](/docs/action-st-open-person-page?surface=python) has the full parameter list, and [people search](/docs/action-st-search-people?surface=python) covers finding profiles rather than reading a known one. Requests are not instant, and that is deliberate: the workflow runs in a real browser at human pace, so a simple read takes seconds and a heavy one can take minutes. What you get in exchange is that the pacing, the limits and the browser are handled rather than left to your code. **Two ways to use it, both first-class.** You can build it in – the REST API, the [Node and Python SDKs](/sdks/installation), or the [shell CLI](/cli/getting-started) – and embed extraction directly in your own backend. Or you can skip the wiring: an AI agent can run the same workflows through the [MCP server](/mcp/overview), the agent-friendly CLI in Claude Code, Cursor or Codex, or ready-made [skills](/skills) installed with `npx @linkedapi/skills` – building the extraction from a plain-language description instead of code. **On capacity.** Activity capacity is account-bound: exceeding what one account can do means connecting additional accounts, on any route. Each account you connect to Linked API gets its own isolated cloud browser, its own identification token, and its own configurable limits. For entity-specific extraction, the deeper guides carry working code: [profiles](/guides/linkedin-profile-scraper), [companies](/guides/linkedin-company-scraper), [jobs](/guides/linkedin-jobs-scraper), [posts](/guides/linkedin-post-scraper), and [Sales Navigator](/guides/linkedin-sales-navigator-scraper). ## When the DIY route is still the right call Plenty of cases, and they are not consolation prizes: - **You want full control and no vendor dependency.** Your scraper, your infrastructure, your roadmap. Nothing about it can be repriced or deprecated by someone else. - **You are collecting from sources beyond LinkedIn.** If LinkedIn is one target among many, one scraping stack across all of them is simpler than one integration per source. - **You need raw speed on a single account.** Linked API enforces sequential, human-paced execution, so a simple action takes around 20 seconds by design. A Playwright script, and especially a Voyager client issuing direct HTTP requests, leaves pace and concurrency entirely to your code, so its wall-clock rate on one account can be considerably higher. That speed is unmanaged, and the safety burden moves to you – but the raw number is real, and it is a different question from what volume is sustainable, which nobody here can quantify for you. - **You are collecting signed-out public pages in bulk.** At high volume that genuinely bursts harder than any account-based route. It is a thinner dataset, and the legal picture around it is its own subject – our [scraping guide](/guides/how-to-scrape-linkedin) covers it. - **You need a one-off dataset**, once, with no subscription attached. - **You are learning.** Writing the Playwright version teaches you more about how LinkedIn is built than any API call will. ## Frequently Asked Questions (FAQ) #### Can you scrape LinkedIn with Python? Yes, by three practical routes: an open-source library such as linkedin-scraper, your own browser automation with Playwright or Selenium, or an authenticated account API that returns structured data directly. They differ in how much code you maintain and in what runs your LinkedIn account while they execute. None of them is risk-free for the account doing the work. #### Which Python library is best for scraping LinkedIn? It depends on the route you want, and the current state of each is worth checking before you commit. The linkedin-scraper library is actively maintained on Playwright, at version 3.1.2 as of April 2026, with a major API rewrite in 3.0.0 that broke compatibility with 2.x. The linkedin-api library takes the wire-level approach against LinkedIn's internal Voyager endpoints and has not shipped a release since November 2024, with its published repository and documentation URLs currently returning 404. #### Does scraping LinkedIn with Python get your account banned? Account restriction is possible on every route, and nothing described here removes that risk. What triggers it, how LinkedIn's restrictions work, and what recovery looks like are covered in our [guide to scraping LinkedIn](/guides/how-to-scrape-linkedin) and our guide to a [restricted LinkedIn account](/guides/linkedin-account-restricted). #### Can Python scrape emails or phone numbers from LinkedIn? Sometimes, but not reliably, and it is the wrong tool for the job. Members can publish an email or phone number in the [Contact info section](https://www.linkedin.com/help/linkedin/answer/a565128) of their profile, and a logged-in route can read it when the visibility setting makes it visible to that account. But it is optional and visibility-controlled: per LinkedIn Help, the registered primary email is by default visible only to [1st-degree connections](https://www.linkedin.com/help/linkedin/answer/a523134), with member-chosen settings ranging from "only visible to me" to anyone on LinkedIn. That makes LinkedIn an unreliable contact source, and consented enrichment providers are the right lane for it. Linked API result types carry no email or phone field. #### Do I need proxies to scrape LinkedIn in Python? At low volume from an ordinary connection, no. Proxies commonly become necessary infrastructure once you are collecting public pages at scale, which is what proxy and unblocker products are built for. Two things worth separating: that is signed-out collection, and it is not a way to make a single logged-in account go faster. An authenticated account API does not require you to run a proxy pool at all. #### Is there an official LinkedIn API for Python? There is an official LinkedIn API, but not one that covers this use case. Its open access is Sign In with LinkedIn via OpenID Connect, which returns the profile of the member who just authenticated and consented – their own data, not other people's. Broader profile and community access sits behind restricted or vetted partner programs, as Microsoft's own [Getting Access to LinkedIn APIs](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access) documentation sets out, and there is no general people-search or profile-lookup product available by self-service. For the API route in Python – official client versus account-based SDK – see [How to use the LinkedIn API with Python](/guides/linkedin-api-access#how-to-use-the-linkedin-api-with-python). That gap is why the routes in this guide exist. --- If you are deciding between building and buying, the honest split is this: build when control matters more than upkeep, and buy when the extraction has to keep working while you do something else. Our [account safety model](/safety) covers how Linked API runs actions, and [pricing](/pricing) is flat per seat. *Library versions, release dates, and repository status verified 2026-07-28.* ## LinkedIn Account Restricted: Why It Happens and How to Get Back In A LinkedIn account restricted without warning is disorienting in a specific way: the notice names a policy area, and in most reports it does not name the action that crossed a line – while the timer on screen may not appear to move. Most advice you will find fills that gap with numbers nobody published – a "safe" weekly invitation figure, an appeal success rate, a universal timeline. This guide does the opposite. Every claim below is either quoted from LinkedIn's own Help pages and User Agreement, or explicitly labelled as an observation rather than a documented rule. That distinction matters most in the first hour, because the route back is different for each restriction type – and one of them is not a restriction at all, but a phishing message designed to look like one. > **The short version.** LinkedIn documents four restriction categories – content, profile, identity and automated tools – plus a separate invitation restriction and a separate route for accounts someone else got into. Which one you have decides your way back. Invitation restrictions lift on their own, most within a week, and Support cannot remove one if you ask. Automated-activity suspensions re-enable themselves at the time given in your notice, once you disable the tool. Policy restrictions may offer a 48-hour wait-then-agree path, and you can appeal a decision only once. Identity restrictions need ID verification through Persona; a compromised account goes through a different form entirely. Whether other people can still see your profile depends on the type – on the 48-hour path they can, under a full restriction they cannot. ## Which LinkedIn restriction do you have? **First, check that the restriction is real.** Fake "your account has been restricted" messages are an active phishing pattern, and LinkedIn publishes a sample of one: an email subjected "Account Suspended" telling the reader that "a temporary limitations has been placed on your Linkedin account" with a link to continue. The clearest rule is on [LinkedIn's phishing page](https://www.linkedin.com/help/linkedin/answer/a1339266): "LinkedIn will never ask you for your password or ask you to download any programs." Sender address is a weaker signal than people assume – LinkedIn states it "has several email domains, which are determined by our email service provider", and vouches for three specific addresses (`linkedin_support@cs.linkedin.com`, `linkedin@e.linkedin.com`, `linkedin@el.linkedin.com`) without presenting that as an exhaustive list. So a message from some other address is not automatically fake, and one that merely looks plausible is not automatically real. Never follow an appeal link that arrives in a comment, DM or email – open LinkedIn directly and see whether the notice is there. LinkedIn also warns to "be cautious of third-party sites offering assistance with our products." Suspicious email goes to `phishing@linkedin.com`; a suspicious message is reported in-app through the More icon → Report/Block → "It's spam or a scam". Once you have confirmed the notice on LinkedIn itself, match it to a type: | What you're seeing | Restriction type | What's blocked | Official route back | Duration LinkedIn publishes | | --- | --- | --- | --- | --- | | You can still sign in, but you can't send invitations | **Invitation restriction** | Sending new invitations | None needed – [it lifts on its own](https://www.linkedin.com/help/linkedin/answer/a551012) | **Most removed within one week** | | A suspension notice naming automated activity | **Automated-activity suspension** | Your account, for the stated period | [Disable the software or extension](https://www.linkedin.com/help/linkedin/answer/a1340567); it re-enables itself | **The time stated on your suspension notice** | | A policy notice offering another chance after a wait | **Content or policy violation** | Your account – but others can still view and message you | [Wait, then agree to the Professional Community Policies](https://www.linkedin.com/help/linkedin/answer/a1507211) | **48 hours** | | Parts of your profile removed, or access lost after repeated profile edits | **Profile violation** | Varies – from a removed photo to account access | [Sign in and follow the on-screen prompts](https://www.linkedin.com/help/linkedin/answer/a1340522) | Not published | | You're asked to verify your identity | **Identity violation** | Your account | [Verify your identity through Persona](https://www.linkedin.com/help/linkedin/answer/a1342692) | Not published – temporary *or* indefinite | | You can't sign in, or you see changes you didn't make | **Suspected compromise** | Your account | [Report Unauthorized Account Access or Changes](https://www.linkedin.com/help/linkedin/answer/a1340402) | Not published | Three of these six have no published duration. Any page that gives you one for them is guessing. ## Why LinkedIn restricts accounts – what's documented and what isn't [LinkedIn's Account restrictions page](https://www.linkedin.com/help/linkedin/answer/a1340522) lists four categories, and its invitation page lists three more specific triggers. Everything else circulating on this topic is inference. The table separates the two: | Possible trigger | Evidence status | What LinkedIn actually says | | --- | --- | --- | | Content that breaches the Professional Community Policies | **Documented** | "Some violations of our Professional Community Policies may result in permanent account restriction after a single violation." | | Profile content – Name, Profile Photo, Background Photo, Experience or Education | **Documented** | "If you repeatedly add violating content to your profile, we may restrict access to your account." | | A profile that is "intentionally fraudulent or do[es] not reflect your true identity" | **Documented** | Access "may be restricted either temporarily or indefinitely." | | Third-party software or browser extensions | **Documented** | "we don't allow the use of third-party software or browser extensions that scrape, modify the appearance of, or automate activity on LinkedIn's website." | | Invitation volume in a short window | **Documented** | "You've sent many invitations within a short amount of time." | | Invitation quality | **Documented** | "Many of your invitations have been ignored, left pending, or marked as spam by the recipients." | | Excessive invitations plus suspected tooling | **Documented** | "If you send an excessive number of invitations and we suspect the use of an automation tool, we may suspend or restrict your account." | | Size of your pending-invitation backlog | *Community-observed* | No numeric cap is published. See [our limits guide](/guides/understanding-linkedin-limits). | | Acceptance rate | *Community-observed* | No threshold is published. See [the connection limit guide](/guides/linkedin-connection-limit-2026). | | Profile-view velocity | *Community-observed* | No threshold is published. | | Pace of sending within a single day | *Community-observed* | No threshold is published. | Two numbers you will meet repeatedly are worth naming as unpublished: the "safe range is 100 to 200 connection requests per week" figure, and any stated appeal success rate. LinkedIn publishes neither. Its own position on limits is that "All LinkedIn members (Basic and Premium) are subject to invitation limits and restrictions" – with no member-facing number attached. On automation specifically, the [prohibited software page](https://www.linkedin.com/help/linkedin/answer/56347) points at [User Agreement](https://www.linkedin.com/legal/user-agreement) section 8.2, which bars members from: > "Develop, support or use software, devices, scripts, robots or any other means or processes (such as crawlers, browser plugins and add-ons or any other technology) to scrape or copy the Services, including profiles and other data from the Services" and from: > "Use bots or other unauthorized automated methods to access the Services, add or download contacts, send or redirect messages, create, comment on, like, share, or re-share posts, or otherwise drive inauthentic engagement" That is the rule as written, and it applies regardless of which tool you use. We cover what it means in practice, including the *hiQ* case law, in [our guide to LinkedIn scraping](/guides/how-to-scrape-linkedin). ## Why the notice doesn't tell you what you did For invitation restrictions, the silence is deliberate and documented. LinkedIn's [invitation restrictions page](https://www.linkedin.com/help/linkedin/answer/a551012) states plainly: "For your privacy, LinkedIn Support cannot disclose any additional information regarding the type or reason for the invitation restriction on your account." Contacting Support will not produce the missing detail, because withholding it is the policy. Outside that specific case, LinkedIn has not published a rule about disclosure. What readers actually encounter – and this is an observation about the notices, not a documented LinkedIn position – is that a restriction notice names the policy area it falls under rather than the action that crossed a line. So a member sees "Professional Community Policies" or "automated activity" and is left to work backwards. This is why "restricted for no reason" is such a common search: there usually *was* a reason, it simply is not printed on the screen. The practical response is to work from the type in the table above and the documented triggers in the previous section, rather than from a support reply that will not come. ## How to get your account back ### If it's an invitation restriction Do nothing, and do not open a support case. Per LinkedIn: "Most restrictions will automatically be removed within one week. LinkedIn won't be able to remove invitation restrictions upon request." Use the time on something LinkedIn does name: invitations "ignored, left pending, or marked as spam by the recipients" are one of its listed triggers, so withdrawing invitations that have gone stale addresses a documented signal directly. Note the cost before you start: "After you withdraw an invitation, you won't be able to resend an invite to the same recipient for up to three weeks." Our guide to [pending invitations](/guides/linkedin-pending-invitations) covers where both queues live and how withdrawal actually behaves. ### If it's an automated-activity suspension [LinkedIn's instruction](https://www.linkedin.com/help/linkedin/answer/a1340567) is one step: "Review and disable the software or extension that automates activities. Your account will then automatically be re-enabled at the time specified on the suspension notification." Two things follow from that. First, the clock is already set – there is no faster route. Second, repetition escalates: "Repeated suspensions may result in permanent restriction of your LinkedIn account." LinkedIn also offers a second-look contact form for members who want to explain their use, but be aware of its status in LinkedIn's own words: "This feature is being gradually rolled out and is currently available only to some LinkedIn members." If you do not see it, it is not available to you yet. ### If it's a content or policy restriction Where LinkedIn offers [another chance to regain access](https://www.linkedin.com/help/linkedin/answer/a1507211), the path is a wait followed by an agreement: "you'll need to wait 48 hours and then agree to comply with our Professional Community Policies." Three details save a lot of confusion here: 1. **The clock starts when you log in.** "The restriction period begins when you log in, even if the amount of time shown on the screen doesn't change." A frozen-looking counter is not a bug. 2. **Your profile stays visible.** "Your account will still be active to others during this restriction period, meaning other members will be able to view your account and message you." 3. **One appeal per decision.** If you believe the content complies, you can appeal – but "You may appeal each decision only once." Spend it on your strongest case. ### If you're asked to verify your identity LinkedIn's verification partner for account recovery is **Persona** – not CLEAR, which belongs to a separate verified-badge feature and is widely misreported as part of this flow. Per the [identity verification page](https://www.linkedin.com/help/linkedin/answer/a1342692), you supply a photo of a valid government-issued ID card, driver's licence or passport, and may be asked for a selfie to match it. Library and school ID cards are not accepted. Only on your consent does Persona pass LinkedIn your verification result, full name, year of birth, city/state/province/country, ID type and issuer, and a redacted copy of the ID "with only the full name and face portrait being visible". LinkedIn states it does not receive your biometric data, or the numbers, expiry dates or issue dates on the ID. The submitted data is "generally permanently deleted within 14 days of submission". On desktop you scan a QR code to continue on your phone. If you would rather not submit an ID or a photo of yourself and you are "located in Canada, the EU or the UK", LinkedIn offers an alternative: print the Affidavit of Identity, sign it before a Notary Public, then scan and attach it to your support case. ### If someone else got into your account This is a different route from an identity violation, with its own form. Per [LinkedIn's page on unauthorized account access](https://www.linkedin.com/help/linkedin/answer/a1340402), submit the **Report Unauthorized Account Access or Changes** form immediately, and include your profile URL if you have it. If you can still log in, LinkedIn asks you to do all of the following straight away: change your password to one not used anywhere else, turn on two-factor authentication, review your active sessions and sign out of any you do not recognise (individually or everywhere at once), check that the email addresses and phone numbers on the account are current, and secure the personal email accounts tied to it. ## How long does a LinkedIn restriction last? LinkedIn publishes a duration for three situations and no others: | Situation | Published duration | | --- | --- | | Invitation restriction | "Most restrictions will automatically be removed within one week" | | Automated-activity suspension | "the time specified on the suspension notification" | | The policy second-chance path | 48 hours | For profile violations, identity violations and account reviews, **no duration is published**, and for invitation restrictions Support will not discuss the case. If a page gives you "7 to 30 days" or "24 to 48 hours" for those, it is not quoting LinkedIn. Two corrections to the common mental model: **A permanent restriction does not always arrive last.** It is easy to assume permanence is the end of an escalation ladder, but LinkedIn documents three separate paths to it. Some content violations "may result in permanent account restriction after a single violation", and for egregious categories – LinkedIn names child sexual abuse material, terrorism, extremely violent content and egregious sexual harassment – a single violation may end the account. Identity restrictions may be "either temporarily or indefinitely" from the outset. And repeated automated-activity suspensions "may result in permanent restriction". Only the third is an escalation. **Whether people can still see you depends on the type.** On the [48-hour policy path](https://www.linkedin.com/help/linkedin/answer/a1507211), "other members will be able to view your account and message you". Under a [full restriction](https://www.linkedin.com/help/linkedin/answer/a1447823), the opposite is documented: "Other people will no longer be able to find your LinkedIn profile or message you." If your profile has gone from search entirely, you are not on the 48-hour path. ## How to resume LinkedIn activity without repeating this This section assumes you already have your access back and are deciding how to work from here. **Ramp back rather than resuming where you stopped.** LinkedIn publishes no ramp schedule, so treat this as a precaution rather than a documented rule: return well below your previous volume and rebuild gradually. What *is* documented is what to prioritise – two of LinkedIn's listed invitation triggers are things you control directly: sending "many invitations within a short amount of time", and invitations that end up "ignored, left pending, or marked as spam by the recipients". So send fewer in any short window, be more selective about who you send to, and clear the stale pending queue before adding to it. Our [connection request guide](/guides/how-to-automate-linkedin-connection-requests) covers the mechanics, and [the limits guide](/guides/understanding-linkedin-limits) collects the working numbers. **Be clear about where automation stands.** Nothing in this section changes section 8.2 of the User Agreement, quoted above. LinkedIn's position is that third-party software which scrapes or automates activity is not permitted, and that "Automated inauthentic activity violates the LinkedIn User Agreement and can result in temporary or permanent restriction of your account." No product choice makes automation an approved activity, and no configuration removes that risk. What a tool can change is one class of trigger: how much you do and how fast. That is the narrow claim [Linked API](/) makes. Each account runs in its own dedicated cloud browser on LinkedIn's real pages, at human pace with natural pauses – you sign in yourself, and Linked API never sees or stores your password. You then [set per-action limits](/docs/admin-limits) once, by category and by day, week or month, and the platform enforces them: an action that would cross a limit returns a `limitExceeded` error instead of executing, so a script cannot run past a cap you set. You can drive it from your own backend through the REST API, the [Node and Python SDKs](/sdks/installation), or the shell CLI – or skip the wiring entirely and let an AI agent run it through the [MCP server](/mcp/overview), the [agent-friendly CLI](/cli/getting-started) in Claude Code, Cursor or Codex, or a ready-made [skill](/skills). **The honest scope of that.** Enforced caps govern only the volume and pace you configured. They do nothing about content, profile, identity, compromise or report-driven restrictions, which is most of the table in the first section. Linked API still carries restriction risk. Treat configurable limits as one control over one trigger class, not as protection. ## Frequently Asked Questions (FAQ) #### How do I appeal a LinkedIn account restriction? There is no single universal appeal form. The route is the on-screen prompt on your own notice: LinkedIn asks you to "login and follow the onscreen prompts to ask us to revisit our decision." You may appeal each decision only once, so make the strongest version of your case the first time. The separate second-look contact form for automated-activity suspensions exists, but LinkedIn describes it as gradually rolling out and currently available only to some members. #### Can a permanently restricted LinkedIn account be recovered? LinkedIn does not document a recovery path for a permanent restriction, and no honest answer promises one. What it does document is how permanence arises: a single violation in an egregious content category, an identity restriction imposed indefinitely, or repeated automated-activity suspensions. If you believe the decision was an error, the on-screen appeal is the only route LinkedIn publishes, and it can be used once. #### Will people still see my profile while my account is restricted? It depends on the type. On the 48-hour policy path, LinkedIn states that "other members will be able to view your account and message you" even though you cannot get in. Under a full account restriction, LinkedIn states the opposite: "Other people will no longer be able to find your LinkedIn profile or message you." So a profile that has vanished from search indicates a full restriction rather than the temporary policy path. #### Can LinkedIn Support lift an invitation restriction if I ask? No. LinkedIn states that "LinkedIn won't be able to remove invitation restrictions upon request", and that most lift automatically within a week. It also will not tell you why: "For your privacy, LinkedIn Support cannot disclose any additional information regarding the type or reason for the invitation restriction on your account." #### Does LinkedIn restrict accounts for using automation tools? Yes, and it says so directly: "we don't allow the use of third-party software or browser extensions that scrape, modify the appearance of, or automate activity on LinkedIn's website," and such activity "can result in temporary or permanent restriction of your account." Section 8.2 of the User Agreement bars both scraping tooling and bots used to send messages, add contacts or drive engagement. This applies to every tool in the category, without exception. #### What should I do if my account was restricted after someone else accessed it? Use the compromise route, not the identity-verification one. Submit the Report Unauthorized Account Access or Changes form with your profile URL. If you can still log in, change your password to one you use nowhere else, turn on two-factor authentication, review your active sessions and sign out of anything unfamiliar, confirm the email addresses and phone numbers on the account, and secure the personal email accounts linked to it. --- If you are rebuilding an outreach or data workflow after a restriction, our [account safety model](/safety) sets out how Linked API runs actions, and [pricing](/pricing) is flat per seat. *Facts verified 2026-07-27 against LinkedIn Help and the LinkedIn User Agreement.* ## LinkedIn InMail: What It Is, How Credits Work, and When to Use It LinkedIn InMail is the feature everyone has received and few people fully understand: the paid message that lands in your inbox from someone you never connected with. The mechanics behind it – who can send it, what a credit actually costs, when you get one back – are documented by LinkedIn across half a dozen help pages that almost nobody reads in full. This guide pulls the whole picture together, verified against those pages, and adds the part LinkedIn doesn't cover: when spending an InMail credit is actually the right move. > **The short version.** InMail is LinkedIn's paid direct message to people you are **not** connected to – free accounts can't send it. Each paid plan gets monthly credits: Premium Career **5**, Premium Business **15**, Sales Navigator Core **50**, Recruiter Lite **30**, accumulating up to plan-specific caps (15 / 45 / 150 / 120). A credit comes **back** if the recipient accepts, declines, or replies within 90 days – as a rule, only a message that stays unanswered costs you. Subjects run up to 200 characters, the body up to 2,000. There is no unlimited-InMail plan, and outside Recruiter there is no way to buy extra credits – but anyone, including free members, can message **Open Profile** Premium members at no cost. And before spending a credit: a connection request costs nothing and is usually the better first move. ## What is InMail on LinkedIn? Per LinkedIn's [InMail Messages help page](https://www.linkedin.com/help/linkedin/answer/a543895), "InMail messages is a premium feature, and it allows you to directly message another LinkedIn member that you're not connected to." That one sentence carries the two defining constraints: it is **paid**, and it is for **strangers** – people outside your first-degree network whom you could otherwise only reach after they accept a connection request. The free-account boundary is explicit on the same page: "If you have a Basic (free) account, then you can only directly message LinkedIn members that you're connected to." Free members can still *reply* to any InMail they receive, and can message [Open Profile members](#free-inmail-the-open-profile-exception) without paying anything. Two more facts from the same page that most guides skip: - **Credit pools are siloed.** "Premium InMail message credits can't be used to send InMail messages on Sales Navigator or LinkedIn Recruiter" – each product has its own bucket. - **Some people are unreachable by design.** Members who have turned off InMail in their message preferences can't be InMailed at all, at any price. ## How many InMail credits do you get per plan? Credits are the currency of InMail. The Premium and Sales Navigator allowances are documented on LinkedIn's [credits and renewal page](https://www.linkedin.com/help/linkedin/answer/a543695), and the Recruiter figures on its Recruiter help pages: | Plan | Credits per month | Max accumulation | Can you buy extra? | | --- | --- | --- | --- | | Premium Career | 5 | 15 | No | | Premium Business | 15 | 45 | No | | Sales Navigator (all tiers) | 50 | 150 | No | | Recruiter Lite | 30 | 120 | [Packs of 10, max 70 per seat](https://www.linkedin.com/help/recruiter/answer/a414231) | | Recruiter | [150 per license](https://www.linkedin.com/help/recruiter/answer/a745199) | – | Yes | Unused credits roll forward until you hit your plan's accumulation cap – note the caps are plan-specific, not one universal multiplier. New credits arrive "every month, on the first day of your billing cycle." For Sales Navigator there is one wrinkle worth knowing: LinkedIn's own pages currently describe the renewal day differently – the [Sales Navigator credits page](https://www.linkedin.com/help/sales-navigator/answer/a101030) says credits arrive "the first day of each month (in UTC time)," while the general credits page says the last day of the month. Treat the Sales Navigator page as the authority for Sales Navigator. To see your balance: **Me** icon → **Premium features** → **See all features** → expand the **InMail** section – "Credits available" is shown there, per the [view-credits help page](https://www.linkedin.com/help/linkedin/answer/a543685). ## The credit-back rule (and the mechanics that surprise people) The most misunderstood part of InMail is that sending one doesn't necessarily spend a credit for good. LinkedIn's rule, quoted from the [credits page](https://www.linkedin.com/help/linkedin/answer/a543695): "Every InMail message that is accepted/declined or responded to directly within 90 days of it being sent is credited back." Quick Replies count as a response, and on Sales Navigator even "Not interested" and auto-replies qualify. Meanwhile, "a pending InMail message isn't counted as either accepted or declined" – it sits in limbo. So as a rule, only a message that stays unanswered for 90 days costs you – with one documented exception: on Sales Navigator, [deleting an InMail that never got a response does not refund the credit](https://www.linkedin.com/help/sales-navigator/answer/a101030). Well-targeted InMail is close to self-funding; ignored InMail quietly drains the balance. ![Diagram: the life of an InMail credit over the 90 days after sending, per LinkedIn's help pages. Sending an InMail spends one credit from the monthly allowance, then one of three things happens. If the recipient responds within 90 days – accepting, declining, or replying, with Quick Replies counting too – the credit is returned. While the message is still pending it is counted as neither accepted nor declined and the 90-day clock keeps running. If there is silence for 90 days, the credit is gone. A footer notes that on Sales Navigator deleting an unanswered InMail does not refund the credit, and unused credits accumulate up to plan caps of 15, 45, 150, or 120.](/images/guides/linkedin-inmail-credit-lifecycle.webp) #### Can you send a second InMail before they reply? This is where the products genuinely differ, and flattening them into one rule (as most guides do) gets it wrong: - **Premium:** no – per the [credits page](https://www.linkedin.com/help/linkedin/answer/a543695), you can't send another InMail to the same person until they respond to the first. - **Sales Navigator:** yes – a second InMail before any response is allowed, and [it costs another credit](https://www.linkedin.com/help/sales-navigator/answer/a101030). - **Recruiter:** a built-in cooldown – [24 hours before re-messaging the same candidate](https://www.linkedin.com/help/recruiter/answer/a745199), unless they reply sooner. Three more mechanics worth knowing before you budget around credits: Sales Navigator rollover credits "must be used within 90 days"; credits are forfeited if you cancel or switch plans ("your InMail credits won't be transferred"); and Recruiter Lite's purchase option has a skimming gotcha – the balance is hard-capped at 120 per seat at billing renewal, so per LinkedIn's [own example](https://www.linkedin.com/help/recruiter/answer/a414231), a recruiter holding 115 credits receives only 5 of their 30 monthly credits, and the remaining 25 are "skipped and be non-recoverable." ## How much does InMail cost? There is **no standalone per-InMail price** – InMail only comes bundled with a paid plan. For Premium, LinkedIn states "it's not possible to purchase additional InMail message credits outside of the monthly allotment" ([send-InMail page](https://www.linkedin.com/help/linkedin/answer/a546814)), and for Sales Navigator, "at this time, you can't purchase additional InMail credits" ([credits page](https://www.linkedin.com/help/sales-navigator/answer/a101030)). Only the Recruiter products sell extra credits, in packs of 10 for Recruiter Lite, with the price shown only at checkout. So the real question is which plan's monthly grant you are paying for – 5 on Premium Career, 15 on Premium Business, 50 on Sales Navigator, 30 on Recruiter Lite. LinkedIn's plan prices vary by billing period, region, and signup cohort (a consumer price change has been rolling out through 2026), and are displayed at LinkedIn's own checkout rather than on a public price list – so check there. Two things soften the arithmetic regardless of the sticker: credits roll over within your cap, and answered InMail comes back free. ## InMail character limits (and how to send one) Per the current [Send an InMail help page](https://www.linkedin.com/help/linkedin/answer/a546814): the subject line takes "up to 200 characters" and the body "up to 2000 characters." (Many guides still cite a 1,900-character body – the current help page says 2,000.) The subject is optional. Sending one is unglamorous: open the person's profile → **More** → **Message [Name]** → add a subject if you want one → write → **Send**. On mobile, the **Message** button on their profile does the same. If the compose box opens without any credit warning, either you are connected (a regular free message), or the recipient has Open Profile enabled – see below. ## InMail vs connection request vs email: when to spend a credit InMail's pitch is response rates. LinkedIn's own sales-solutions page [claims](https://business.linkedin.com/sales-solutions/b2b-sales-strategy-guides/improve-inmail-response-rates-on-linkedin) InMail gets "a 10-25% hit rate when it comes to soliciting a response from potential clients -- 300% higher than emails with the exact same content" (LinkedIn's number, from an undated page live as of 2026). Its [Talent Blog study](https://www.linkedin.com/business/talent/blog/talent-strategy/these-inmails-get-best-response-rates) of tens of millions of recruiter InMails (May 2022) is more actionable: | Finding | Effect on response rate | | --- | --- | | Keep it under 400 characters | +22% vs the average InMail | | Personalize – don't bulk-send | +15% vs bulk InMails | | Don't send on Friday or Saturday | −4% / −8% vs other days | The honest decision framework, though, starts before the credit. A **connection request** is free, and once accepted, messaging is free forever – for most steady prospecting it is the better first move, and [automating that flow](/guides/how-to-automate-linkedin-connection-requests) scales it. **Email** wins when you already have the address: no credit, no character ceiling, your own tooling. **InMail** earns its credit in two situations: you can't wait for an acceptance (a closing candidate, a time-boxed deal), or connecting is unrealistic (a far-out-of-network executive). What to actually write – openers, follow-ups, cadence – is craft, not mechanics: the [cold outreach guide](/guides/linkedin-cold-outreach-strategies-2026) covers it. ## Free InMail: the Open Profile exception One carve-out makes "InMail is paid-only" slightly false in practice. Premium members can enable **Open Profile**, which per [LinkedIn's help page](https://www.linkedin.com/help/linkedin/answer/a545663) allows "anyone on LinkedIn to contact a Premium member for free, even if they're not in the sender's network" – no credit consumed, and the sender doesn't even need a paid plan. If a Premium member's profile shows the **Message** button open to you without a credit prompt, that is usually why. The volume is not unlimited: LinkedIn [limits the number of Open Profile messages](https://www.linkedin.com/help/linkedin/answer/a544787) you can send in a given period, without publishing a number. For outbound prospecting, Open Profile members are the cheapest people on LinkedIn to reach – worth identifying deliberately. ## Sending InMail from your own system (in brief) Everything above is manual. If InMail is part of an outreach flow you run in code – a recruiting pipeline, a sales sequence in your own CRM – [Linked API](/), a flexible LinkedIn automation API you can embed in your own product, backend, or workflow, sends InMail programmatically on your own account through a human-paced cloud browser. One scope note, stated precisely: Linked API automates **Sales Navigator** InMail only, via the [`nvSendMessage`](/docs/action-nv-send-message) action, and it requires a Sales Navigator seat on the connected account – it does not send Premium or Recruiter InMail. The API contract is also stricter than the LinkedIn compose box: subject up to **80** characters and text up to **1,900** via the API, versus 200 and 2,000 in the UI – budget copy for the tighter limits when the same message runs both ways. The full flow – the connect-or-InMail branching loop, reading replies, monitoring the inbox, with working TypeScript and Python – lives in [How to Automate LinkedIn Messages](/guides/how-to-automate-linkedin-messages), and the [`nvSendMessage` reference](/docs/action-nv-send-message?surface=sdk) has the exact contract. The action is available across all Linked API surfaces, enumerated below. ## Frequently Asked Questions (FAQ) #### What is the difference between InMail and a regular message on LinkedIn? A regular message is free but only works between 1st-degree connections. InMail is a paid-plan feature that reaches members you are not connected to, spends a credit per send (refunded if they respond within 90 days), and supports a subject line. If you are already connected to someone, LinkedIn simply sends a regular message – no credit involved. Which of the two applies depends on the [degree of connection](/guides/linkedin-connection-degrees) between you. #### How many InMail credits do you get per month? Premium Career gets 5, Premium Business 15, Sales Navigator 50, and Recruiter Lite 30 per month, per LinkedIn's help pages. Unused credits accumulate up to plan-specific caps: 15, 45, 150, and 120 respectively. There is no plan with unlimited InMail. #### Do InMail credits roll over – and do they expire? They roll over up to your plan's accumulation cap (15/45/150/120). On Sales Navigator, rolled-over credits must be used within 90 days. Credits are also forfeited if you cancel or change your subscription type – LinkedIn states they won't be transferred. #### How do I get an InMail credit back? The recipient has to act on your message within 90 days: accepting, declining, or replying all return the credit – Quick Replies count, and on Sales Navigator even "Not interested" qualifies. A message that stays pending returns nothing, and on Sales Navigator deleting an unanswered InMail forfeits the credit. #### Can free LinkedIn members send InMail? No – sending InMail requires a paid plan. Free members can, however, reply to any InMail they receive, and can message Premium members who have Open Profile enabled at no cost, since Open Profile lets anyone on LinkedIn contact that member for free. #### Is there a daily limit on InMail? On the Recruiter products, yes: up to 1,000 InMails per day per seat, with a 200-InMail cap in a new seat's first week, per [LinkedIn's Recruiter help](https://www.linkedin.com/help/recruiter/answer/a745199). Premium and Sales Navigator have no published daily InMail cap – the monthly credit allowance is the effective limit. #### Is InMail spam? The channel itself is not – credits, opt-outs, and the credit-back-on-response mechanic are all designed to make irrelevant InMail expensive and relevant InMail sustainable. Any individual InMail can still be unwanted, which is exactly why recipients can decline it or switch off InMail entirely. Judge the sender and the message, not the mechanism. --- Running outreach on your own account, at any scale? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually – with Sales Navigator InMail and every other action across the full surface set: the [REST API and Node/Python SDKs](/sdks/installation), the shell [CLI](/cli/getting-started), the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`) – on your own account and at a human pace. ## LinkedIn Pending Invitations: When They Expire and What Withdrawing Does Every LinkedIn account quietly accumulates two queues: connection requests you sent that nobody answered, and invitations other people sent you. LinkedIn does not surface either one prominently, so most people never learn how to see pending connections on LinkedIn at all – and the stale pile grows. This guide covers where both queues live, how to withdraw what has gone stale, and the facts LinkedIn actually documents about what happens next: who gets notified, when invitations expire, and how soon you can try again. > **The short version.** Your pending **sent** invitations live under My Network → Manage invitations → the **Sent** tab (on mobile: My Network → Invitations → Sent); **received** invitations are reached through the Notifications icon – and also from My Network. From Sent, click Withdraw and confirm – and per LinkedIn Help: the recipient is **not notified**, reminder emails stop, the withdrawal can't be undone, and you **can't re-invite the same person for up to three weeks**. Unanswered invitations **expire after six months** (LinkedIn sends up to two reminders first, and once expired you can send a new one), and there is **no native bulk withdraw** – it's one at a time. ## What counts as a pending invitation on LinkedIn? A pending invitation is any invitation that has not been acted on yet, and it exists on both sides: requests **you sent** that the other person has not accepted or ignored, and invitations **you received** and have not answered. Connection requests are the most common kind, but LinkedIn's invitation list also carries other categories – its filters cover [People, Events, Pages, and Newsletters](https://www.linkedin.com/help/linkedin/answer/a540852), so an invitation can also be someone inviting you to an event or to follow a page or newsletter. One side-effect worth knowing, in its exact form: on profiles where **Follow** is the primary button, choosing Connect from the overflow menu means [you automatically follow the person](https://www.linkedin.com/help/linkedin/answer/a702683) while your invitation is pending – and if they decline, you keep following them unless you manually unfollow. ## How to see the connection requests you've sent On desktop, per LinkedIn's [withdraw help page](https://www.linkedin.com/help/linkedin/answer/a568295): 1. Click the **My Network** icon at the top of your LinkedIn homepage. 2. In the Invitations section, click **Show all** – or, if nothing is pending on the received side, click **Manage** next to "No pending invitations". 3. Click the **Sent** tab under **Manage invitations**. On mobile: tap the **My Network** tab → **Invitations** → the **Sent** tab. The Sent tab is the full list of everything still outstanding. The direct address most people end up bookmarking is `linkedin.com/mynetwork/invitation-manager/sent/` – LinkedIn's help pages describe the menu route above, but the URL takes you straight there. ## How to see invitations you've received LinkedIn's current [help page for received invitations](https://www.linkedin.com/help/linkedin/answer/a540852) routes through notifications: on desktop, click the **Notifications** icon at the top of your homepage; on iOS and Android, tap the **Notifications** tab in the navigation bar. Invitations also appear in the **My Network** section on every platform, so either entry works. For each invitation you have two buttons, in LinkedIn's words: "Click Accept to add the person as one of your 1st-degree connections," or "Click Ignore to remove the invitation without accepting it." An invitation waiting here is why a profile can still show "2nd" while the right move is to accept rather than invite – see [degrees of connection](/guides/linkedin-connection-degrees) for how that state differs from the badge. Two details people always ask about: - **Ignoring is silent.** Per the same help page: "The sender won't be notified that you've ignored their invitation, so they may try to connect with you again." - **"I don't know this person"** – the option LinkedIn offers after you ignore – goes further: it prevents that member from sending you further invitations. ## How to withdraw a LinkedIn invitation (and what happens next) From the **Sent** tab (previous section), click **Withdraw** next to the invitation, then confirm in the pop-up. That is the whole mechanism – and LinkedIn's [help page](https://www.linkedin.com/help/linkedin/answer/a568295) documents exactly what it triggers: - **The other person is not told.** "The recipient will not be notified when you withdraw the invitation." - **Reminders stop.** Withdrawing an invitation stops LinkedIn's reminder emails to the recipient. - **It's permanent.** "Withdrawn invitations can't be restored." - **A cooldown starts.** "You won't be able to send a new invitation to the same member for up to three weeks." If you followed the person as part of inviting them (the Follow-primary case from the first section), check your follow state after withdrawing – following is managed separately from the invitation itself. One edge case: if the Withdraw button is missing, the invitation was most likely already accepted – there is nothing to withdraw anymore, but you can still remove them as a connection. ## Do LinkedIn invitations expire? Yes. Per LinkedIn's [connections and invitations FAQ](https://www.linkedin.com/help/linkedin/answer/a554142): "Invitations expire after six months. Before they expire, LinkedIn sends up to two reminders to the recipient." So an ignored request does not sit in your Sent tab forever – LinkedIn retires it on its own schedule. What happens after expiry is the part the FAQ answers directly: "Expired invitations no longer appear as pending, and you can send a new one." Note the asymmetry with withdrawal – the up-to-three-weeks block applies only to invitations **you withdrew**; an invitation that expired on its own can be re-sent right away. ![Diagram: the life of a LinkedIn invitation. A sent invitation ends in one of four states. Accepted – you become 1st-degree connections. Ignored – the sender is not notified and the invitation quietly disappears. Withdrawn – the recipient is not notified, reminder emails stop, and you cannot re-invite the same person for up to three weeks. Expired – after six months and up to two reminders, it no longer appears as pending and you can send a new one. A footer notes sent invitations are managed under My Network, Manage invitations, Sent tab.](/images/guides/linkedin-invitation-lifecycle.webp) ## Why stale pending invitations are worth cleaning up A long Sent list is not just clutter – it is a signal LinkedIn reads. Its [page on invitation restrictions](https://www.linkedin.com/help/linkedin/answer/a551012) lists, among the triggers: "Many of your invitations have been ignored, left pending, or marked as spam by the recipients." Accounts that trip these signals get temporarily blocked from sending invitations; the same page notes that having too many pending invitations out can mean waiting up to a month before sending another, and that most restrictions lift within a week. If that has already happened to you, our guide to a [restricted LinkedIn account](/guides/linkedin-account-restricted) covers how an invitation restriction differs from the other documented types and what the route back is for each. The practical habit: open the Sent tab monthly and withdraw anything older than three or four weeks. If someone has not accepted in a month, the request is functionally dead – withdrawing it keeps your outstanding queue small and your acceptance rate honest. As for how many pending invitations is "too many": LinkedIn does not publicly state a numeric pending-invitation maximum – how invitation restrictions and sending limits work in practice is covered in the [connection limit guide](/guides/linkedin-connection-limit-2026). ## When there are hundreds: managing invitations programmatically Everything above is one invitation at a time – and LinkedIn's help page is explicit that this is the only native way: "You can't bulk withdraw invitations." If you prospect at real volume, the queue becomes a job for code rather than clicks. [Linked API](/) – a flexible LinkedIn automation API you can embed in your own product, backend, CRM, or workflow, running on your own account through a human-paced cloud browser – exposes the whole queue as composable primitives (its withdraw action also unfollows by default, with an opt-out): | Job | API action | CLI | | --- | --- | --- | | List pending sent requests | [`retrievePendingRequests`](/docs/action-st-retrieve-pending-requests) | `linkedin connection pending --json` | | Withdraw a stale request | [`withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request) | `linkedin connection withdraw ` | | List received invitations | [`retrieveInvitations`](/docs/action-st-retrieve-invitations) | `linkedin connection invitations --json` | | Accept or ignore by rule | [`acceptInvitation`](/docs/working-with-invitations) / [`ignoreInvitation`](/docs/working-with-invitations) | `linkedin connection accept ` / `linkedin connection ignore ` | Hosted tools automate this queue too, at the layer of pre-built campaign agents; the primitives above are for wiring it into your own system. The full lifecycle with working code – sending, status checks, pruning stale invites on a schedule, handling incoming – lives in [How to Automate LinkedIn Connection Requests](/guides/how-to-automate-linkedin-connection-requests) and the [working-with-invitations docs](/docs/working-with-invitations), and the same actions are available to AI agents through the [MCP server](/mcp/overview), the AI-agent-friendly [CLI](/cli/getting-started), and ready-made [skills](/skills). ## Frequently Asked Questions (FAQ) #### Can someone see if you withdraw a LinkedIn request? No. LinkedIn's help page states the recipient "will not be notified when you withdraw the invitation" – the request simply disappears from their list, and its reminder emails stop. Keep in mind the withdrawal itself is permanent, and you won't be able to re-invite that person for up to three weeks. #### How long do LinkedIn invitations stay pending? Until someone acts on them – or for six months, whichever comes first. Per LinkedIn's connections and invitations FAQ, invitations expire after six months, with up to two reminders sent to the recipient before that happens. #### Why can't I withdraw a LinkedIn invitation? The usual reason is that it was already accepted – once someone accepts, there is no pending invitation left to withdraw, and your option becomes removing the connection instead. Already-withdrawn invitations also can't be touched again: LinkedIn states withdrawn invitations can't be restored. #### Can you bulk-withdraw LinkedIn invitations? Not natively – LinkedIn's help page says plainly, "You can't bulk withdraw invitations," so the built-in flow is one at a time from the Sent tab. At real volume, the programmatic route in this guide's last section does the same job through an API on your own account. #### What happens if someone ignores my invitation? Nothing visible to you. You are not notified when someone ignores your request – the invitation quietly leaves their list. Ignoring is not a block: LinkedIn notes the sender "may try to connect with you again." #### How many pending invitations can you have on LinkedIn? LinkedIn does not publicly state a numeric pending-invitation maximum. What it does document is the consequence: many invitations left ignored or pending is a listed trigger for invitation restrictions, and too many outstanding invitations can mean waiting up to a month before sending more. How invitation restrictions and sending limits work in practice is covered in the connection limit guide. --- Keeping the queue clean on your own account, at any scale? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually – with every invitation primitive across the full surface set: the [REST API and Node/Python SDKs](/sdks/installation), the shell [CLI](/cli/getting-started), the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`) – on your own account and at a human pace. ## LinkedIn Posts API and Post Automation (2026): Native Scheduler, Tools, and API Publishing consistently on LinkedIn is one of those jobs that rewards a system and punishes willpower. Writing a good post is real work; pasting it into the composer at the right time on the right account is not – that part is exactly what you should automate. This guide covers how to automate LinkedIn posts at every level: what LinkedIn's built-in scheduler actually does, when a scheduling tool earns its subscription, and how to publish to your profile or company page from your own pipeline in code. > **The short version.** You can automate LinkedIn posts three ways: LinkedIn's **native scheduler** (free, up to 3 months ahead, but manual and one post at a time), a **scheduling tool** (content calendar, approvals, multi-network publishing), or an **API that posts on your own account**. With Linked API's `createPost` you publish to your personal profile or a company page from your own pipeline – a CMS, an RSS feed, a backend job, or an AI agent – with images, video, or a document attached. The rest of this guide is the honest decision path, plus working code. ## Is there a LinkedIn posts API? Yes – for publishing. LinkedIn's official API creates posts, and for some projects it is the right choice; it is not a way to read or collect other people's posts at scale, which is the [post scraper guide](/guides/linkedin-post-scraper)'s territory. The current route is the versioned [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) (`POST https://api.linkedin.com/rest/posts`, with required `Linkedin-Version` headers), which replaced the legacy ugcPosts/Share endpoints. Access comes in two distinct paths, and it is worth being precise about them. Posting **as a member** (`w_member_social`) is available through the self-serve [Share on LinkedIn](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin) product on a developer app – no review gate for that scope. Posting **as an organization** (`w_organization_social`) and broader content management run through the Community Management API, whose Standard-tier access requires an application form, a screen recording of your app, and test credentials, per [Microsoft's quick-start](https://learn.microsoft.com/en-us/linkedin/marketing/quick-start) (docs as of May 2026). Notably, the Posts API exposes no scheduling parameter – posts are created as published (or draft), so timing logic lives in the caller there too. Build on the official API when you are shipping an approved integration – a social suite, a marketing platform, a product feature for LinkedIn's own ecosystem. For automating your own account's publishing inside your own stack, the app-review path is usually more machinery than the job needs – which is exactly the gap the own-account API route above fills. ## The three ways to automate LinkedIn posts | Route | Cost | Where it runs | Personal / Page | Media | Best for | | --- | --- | --- | --- | --- | --- | | Native scheduler | Free | LinkedIn's composer | Both | What the composer supports (minus the excluded types below) | One person queueing posts ahead | | Scheduling tool | Subscription | The vendor's app | Both | Vendor-dependent | Teams: calendars, approvals, multi-network | | Your own pipeline via [Linked API](/) | Flat [per-seat plan](/pricing) | Your code, on your own account | Both (`companyUrl` for Pages) | 9 images, or 1 video, or 1 PDF | Posting as a feature of your product, backend, or agent | There is also a fourth, narrower route – building an approved developer app on LinkedIn's official Posts API – covered [further down](#the-official-linkedin-posts-api-when-its-the-right-route), because for most people it is an integration project, not an automation setup. ## Can you schedule posts on LinkedIn natively? Yes. LinkedIn's built-in scheduler is free and covers more than most people assume, so start here before adding any tool. For **personal posts**, per LinkedIn's [Schedule posts](https://www.linkedin.com/help/linkedin/answer/a1347212) help page, you can schedule from the clock icon in the composer for any time "within 10 minutes to 3 months from the current time", on both desktop and the mobile app. Scheduled posts are not fire-and-forget drafts: from **View all scheduled posts** you can preview, edit the content, reschedule the time, or delete. Three post types are excluded – LinkedIn's list is "Events, Jobs, Services" – and scheduled times are standardized in UTC based on your device's time-zone settings, which is the usual explanation when a personal post publishes at an unexpected hour. For **company pages**, the [Schedule a LinkedIn Page post](https://www.linkedin.com/help/linkedin/answer/a1419179) help page allows scheduling "anywhere between an hour from the current time to three months in advance" – note the 1-hour minimum lead time, versus 10 minutes for personal posts. The exclusion list is longer: "You can't schedule events, multiple photos, reshares, polls, jobs, and service posts for your Page." Scheduled Page posts are managed from the Page admin view, and one operational gotcha: a scheduled Page post cannot be promoted in Campaign Manager until it has actually published. Where the native scheduler stops is structural, not cosmetic: there is no recurring queue, the horizon is capped at 3 months, every post goes through the composer one at a time, and there are no approval workflows or multi-network calendars. If your bottleneck is any of those, you have outgrown it. ## How to automate LinkedIn posts with an API The programmatic route treats posting as what it is in a real content system: one step in a pipeline you already own. [Linked API](/) is a LinkedIn automation API that runs actions on your own account through a human-paced cloud browser, and its [`createPost`](/docs/action-st-create-post) action is the publishing step: - **Post as yourself or as a company page** – pass `companyUrl` to publish on behalf of a Page you have content-admin access to. - **Text up to 3,000 characters**, matching the composer. - **Attachments:** up to 9 images (JPEG, PNG, GIF, WebP, max 8 MB each), or 1 video (MP4, MOV, WebM, max 200 MB), or 1 PDF document (max 100 MB). Attachment types cannot be mixed in one post. - **Returns the created post's URL**, so your system can store, track, or announce it. One honest mechanic before the code: Linked API has **no scheduled-post queue**. `createPost` publishes when you call it – your cron job, queue worker, or workflow engine owns the timing. That is by design: the point of this route is that scheduling logic lives in *your* system, next to your content. Install the SDK (`npm install -S @linkedapi/node` or `pip install linkedapi`) and publish through `customWorkflow` – posting is a raw-definition action, so there is no dedicated SDK method: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const workflow = await linkedapi.customWorkflow.execute({ actionType: 'st.createPost', text: 'We just shipped scheduled exports – here is what changed and why it matters.', attachments: [{ url: 'https://example.com/release-chart.png', type: 'image' }], }); const { data: completion } = await linkedapi.customWorkflow.result(workflow.workflowId); console.log('Published:', completion.data.postUrl); ``` ```python import os from linkedapi import LinkedApi linkedapi = LinkedApi( linked_api_token=os.environ["LINKED_API_TOKEN"], identification_token=os.environ["IDENTIFICATION_TOKEN"], ) workflow = linkedapi.custom_workflow.execute( { "actionType": "st.createPost", "text": "We just shipped scheduled exports – here is what changed and why it matters.", "attachments": [{"url": "https://example.com/release-chart.png", "type": "image"}], } ) completion = linkedapi.custom_workflow.result(workflow.workflow_id).data print("Published:", completion["data"]["postUrl"]) ``` To publish as your company page instead, add `companyUrl: "https://www.linkedin.com/company/your-company"` to the same definition. The same action is one line in the shell [CLI](/cli/getting-started): ```bash linkedin post create "We just shipped scheduled exports – here is what changed" \ --attachments "https://example.com/release-chart.png:image" # Post as your company page linkedin post create "Release 2.4 is live" \ --company-url https://www.linkedin.com/company/your-company ``` ## Build the pipeline end to end: an approved-posts queue A publish call is not yet automation. The smallest pipeline that behaves like a real content system has four parts: a **queue** of posts, an optional **drafting** step, a human **approval gate**, and a **cron tick** that publishes whatever is due. The queue is just data – a JSON file or a database table where each item carries `text`, optional `attachments`, optional `companyUrl`, a `publishAt` time, and an `approved` flag. Drafts can come from anywhere: your team, a blog-to-post script, or an AI agent writing *into the queue* through the [MCP server](/mcp/overview), the AI-agent-friendly [CLI](/cli/getting-started), or the ready-made [skills](/skills) (`npx @linkedapi/skills`) – the agent drafts, a human flips `approved`, and nothing publishes unreviewed. Then a cron job (every 15 minutes is plenty) publishes what is due: ```typescript import LinkedApi from '@linkedapi/node'; import { readFileSync, writeFileSync } from 'fs'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const queue = JSON.parse(readFileSync('queue.json', 'utf8')); const due = queue.filter( (post) => post.approved && !post.postUrl && new Date(post.publishAt) <= new Date(), ); for (const post of due) { const workflow = await linkedapi.customWorkflow.execute({ actionType: 'st.createPost', text: post.text, ...(post.attachments && { attachments: post.attachments }), ...(post.companyUrl && { companyUrl: post.companyUrl }), }); const { data: completion } = await linkedapi.customWorkflow.result(workflow.workflowId); if (completion.success) { post.postUrl = completion.data.postUrl; } else { console.error(`Failed "${post.text.slice(0, 40)}…": ${completion.error.type}`); } } writeFileSync('queue.json', JSON.stringify(queue, null, 2)); ``` ```python import json import os from datetime import datetime, timezone from linkedapi import LinkedApi linkedapi = LinkedApi( linked_api_token=os.environ["LINKED_API_TOKEN"], identification_token=os.environ["IDENTIFICATION_TOKEN"], ) queue = json.load(open("queue.json")) now = datetime.now(timezone.utc) due = [ post for post in queue if post["approved"] and not post.get("postUrl") and datetime.fromisoformat(post["publishAt"]) <= now ] for post in due: definition = {"actionType": "st.createPost", "text": post["text"]} if post.get("attachments"): definition["attachments"] = post["attachments"] if post.get("companyUrl"): definition["companyUrl"] = post["companyUrl"] workflow = linkedapi.custom_workflow.execute(definition) completion = linkedapi.custom_workflow.result(workflow.workflow_id).data if completion["success"]: post["postUrl"] = completion["data"]["postUrl"] else: print(f"Failed {post['text'][:40]!r}: {completion['error']['type']}") json.dump(queue, open("queue.json", "w"), indent=2) ``` Published items keep their `postUrl` (your audit trail), failures stay in the queue with the error surfaced for retry, and the review gate means automation never outruns editorial judgment. Reading posts back – tracking who reacted and commented on what you published, or watching anyone else's feed – is the reverse direction, covered in the [LinkedIn post scraper guide](/guides/linkedin-post-scraper). ![Diagram: a LinkedIn post automation pipeline built on an approved-posts queue. Content sources on the left – an RSS feed, a CMS webhook, and an AI agent drafting via MCP, CLI, or skills – feed post drafts into an approval queue where a human reviews and approves each item. A cron tick on your schedule picks up due approved posts and calls the createPost action, which publishes to a personal profile or a company page and returns a postUrl that is stored back on the queue item. A footer notes the pipeline runs on your own account, at a human pace.](/images/guides/linkedin-post-automation-pipeline.webp) Three variations on the same skeleton: - **Blog/RSS → personal profile.** A script watches your feed; each new article becomes a queued summary post with the article link and cover image. - **CMS webhook → company page.** Your CMS fires a webhook on release-notes publish; the handler queues a Page post with `companyUrl`, and the cron picks it up in the next tick. - **n8n / Make.** In [n8n](/integrations/n8n/overview), use the Linked API node's **Execute custom workflow** action with the same raw `st.createPost` definition; a plain HTTP Request to the REST API works as a fallback, and Make wires up the same way. (n8n also ships its own built-in LinkedIn node – that one posts through LinkedIn's official API with OAuth, a separate route with its own constraints.) ## When native scheduling or a scheduler tool is all you need Honest routing, in both directions. If you are one person queueing next week's posts, the **native scheduler** already does it: free, official, editable, three months out. Nothing in this guide beats it for that job. If a team runs your content, a **scheduler tool** earns its keep with the things native lacks: a shared calendar, approval flows, cross-network publishing, and analytics. Buffer's free plan, for example, covers 3 connected channels with 10 scheduled posts per channel, with paid plans from around $5 per channel per month, billed annually (per Buffer's pricing, 2026). That is a fair price for coordination features you would otherwise build yourself. The API route earns its keep at a specific threshold: when posting must live **inside your product, backend, or agent** – triggered by your data, gated by your review process, publishing to profiles and Pages your system manages. Below that threshold, use the simpler tool; past it, the tool becomes the bottleneck. ## Keeping automated posting safe (and worth reading) Automated posting is the calmest kind of LinkedIn automation – you are publishing to your own feed, not messaging strangers. Outbound is a different discipline with its own pacing rules: [automating messages](/guides/how-to-automate-linkedin-messages) and [automating connection requests](/guides/how-to-automate-linkedin-connection-requests) each have their own guide. For posting, two habits keep it calm. First, run it like a human: Linked API executes every action on **your own account** through a cloud browser at a human pace, and a steady weekly rhythm serves you better than bursts of machine-gun publishing. Quality compounds; volume alone does not. Second, automate the *publishing*, not the *engagement*: auto-posting your content is a workflow, while automating fake engagement on it – pods, mass reactions, comment rings – is the pattern LinkedIn's [Professional Community Policies](https://www.linkedin.com/legal/professional-community-policies) actually target. For pacing questions across all your LinkedIn activity, the [limits guide](/guides/understanding-linkedin-limits) has the current picture. ## Frequently Asked Questions (FAQ) #### Is there a LinkedIn posts API? Yes, for publishing. The versioned Posts API creates posts as a member (the self-serve `w_member_social` scope via Share on LinkedIn) or as an organization (through the review-gated Community Management API). It does not give you a way to read or collect other people's posts at scale, and for automating your own account's publishing inside your own stack the app-review path is usually more machinery than the job needs – which is what an account-based route such as Linked API's `createPost` covers. #### Can you automate posts on LinkedIn? Yes, at three levels: LinkedIn's native scheduler queues individual posts up to 3 months ahead for free; scheduling tools add calendars, approvals, and multi-network publishing; and an API like Linked API's `createPost` publishes to your profile or company page directly from your own pipeline – CMS, RSS, backend job, or AI agent. #### Can you schedule posts on LinkedIn without a third-party tool? Yes. The built-in scheduler covers personal posts from 10 minutes to 3 months ahead (desktop and mobile) and company-page posts from 1 hour to 3 months ahead, per LinkedIn's help pages. Scheduled posts can be previewed, edited, rescheduled, or deleted before they publish. #### How many posts can you schedule on LinkedIn? LinkedIn's help pages document no numeric cap on how many posts you can schedule. The practical constraints are different: the 3-month scheduling horizon, and the excluded post types – events, jobs, and services for personal posts, with polls, reshares, and multi-photo posts additionally excluded for Pages. #### Why is LinkedIn not publishing my scheduled post? Check the post type first: scheduling unsupported types (events, jobs, services – and for Pages also polls, reshares, multi-photo posts) fails with an error. For personal posts publishing at the wrong hour, scheduled times are standardized in UTC based on your device's time-zone settings. If a third-party tool did the scheduling, the failure may be caused by an expired account connection or a revoked Page permission – reconnect and check admin access. #### Can you post to a LinkedIn company page via API? Yes, two ways. With Linked API, pass `companyUrl` to `createPost` and the post publishes on behalf of a Page you have content-admin access to – same action, same pipeline as personal posts. On LinkedIn's official Posts API, organization posting uses the `w_organization_social` scope, which sits behind Community Management API access review. #### Is automating LinkedIn posts allowed? Automation on LinkedIn always deserves care, but publishing your own content to your own feed is its low-risk end. Keep it on your own account at a human pace – which is how Linked API runs every action – keep editorial review in the loop, and put your automation effort into content quality rather than engagement tricks like pods or mass reactions. --- Ready to make posting a feature of your own system? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually – and get `createPost` with every other action across the full surface set: the [REST API and Node/Python SDKs](/sdks/installation), the shell [CLI](/cli/getting-started), the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`) – on your own account and at a human pace. ## LinkedIn Social Selling Index (SSI): What It Is, How to Check It, and Whether It Still Matters The Social Selling Index is the one score LinkedIn keeps about how you use LinkedIn – and most people have never looked at it, while the people who have tend to overrate what it means. Both are mistakes worth fixing: SSI is free to check, genuinely informative about your activity, and – per LinkedIn's own current messaging – not the sales KPI it was once marketed as. This guide covers what the SSI measures, how to check it, what a good score actually is, and how to track it over time instead of glancing at it once. > **The short version.** The Social Selling Index is LinkedIn's 0–100 score of your social-selling activity, built from four components worth 25 points each: your professional brand, finding the right people, engaging with insights, and building relationships. Check it free at [linkedin.com/sales/ssi](https://www.linkedin.com/sales/ssi) (you must be logged in); it updates daily. There is no current official target: LinkedIn's dated guidance from 2017 called anything above 70 high and below 30 weak, but today's dashboard labels no threshold – your industry and network percentile ranks, and your trend, are the more useful signal. LinkedIn itself now downplays SSI's link to sales outcomes, so treat it as a directional activity metric: worth tracking as a trend (which you can automate), not worshipping as a KPI. ## What is the LinkedIn Social Selling Index? The Social Selling Index (SSI) is a 0–100 score that LinkedIn computes daily from your activity, as a measure of how well you practice what it calls social selling. Per LinkedIn's official [tip sheet](https://business.linkedin.com/sales-solutions/learning-center/resources/tip-sheets/ts022), the score has four components: 1. **Create a Professional Brand** – how complete and content-rich your presence is. 2. **Find the right people** – how deliberately you search for and view relevant prospects. 3. **Engage with Insights** – how much you share, react, comment, and join conversations. 4. **Build Strong Relationships** – how consistently you connect, especially with decision-makers. The scoring is officially documented on LinkedIn's [social selling page](https://business.linkedin.com/sales-solutions/social-selling): "Each of the above elements is assigned a value between one and 25, and the sum of all four elements results in a total SSI score between 0–100." The same tip sheet notes the index "measures the four social selling activities as they take place on Sales Navigator and LinkedIn.com" – so Sales Navigator actions like advanced searches and saved leads feed the score alongside ordinary LinkedIn activity. ![Diagram: the four components of the LinkedIn Social Selling Index, each worth up to 25 points. Create a Professional Brand covers profile completeness and published content; Find the right people covers deliberate searches and viewing relevant prospects; Engage with Insights covers reactions, comments, shares, and joining conversations; Build Strong Relationships covers connecting steadily, especially with decision-makers. Added together they make the 0-100 SSI, updated daily and free to check while logged in at linkedin.com/sales/ssi.](/images/guides/linkedin-ssi-four-components.webp) SSI started as a Sales Navigator metric; LinkedIn [opened it to every member for free](https://www.linkedin.com/business/sales/blog/modern-selling/get-your-score-linkedin-makes-the-social-selling-index-available-for-everyone) on August 3, 2015, and its learning-center materials describe [daily SSI updates](https://business.linkedin.com/sales-solutions/learning-center/resources/guides/g001/en-en/ts007), which matches the dashboard's own wording. ## How to check your SSI score Go to **[linkedin.com/sales/ssi](https://www.linkedin.com/sales/ssi)** while logged in to LinkedIn – the page is free for every member but shows a login wall if you are signed out, which is why the URL sometimes looks "broken" when shared. The dashboard shows more than the headline number, and the extras are the interesting part: - Your **total SSI score** (0–100) and the **four component sub-scores** (up to 25 each), so you can see which pillar is dragging. - Your **Industry SSI rank** – the percentile you occupy among people in your industry. - Your **Network SSI rank** – the same percentile within your own network. - Your movement over the last week. The percentile ranks matter more than the raw score, because they answer the question a raw number cannot: high or low *compared to whom*? ## What is a good SSI score? The honest answer has two layers, and most guides get one of them wrong. **LinkedIn's dated official guidance did set thresholds.** In a January 18, 2017 [Sales Blog post](https://www.linkedin.com/business/sales/blog/management/3-leading-indicators-of-social-selling-success), LinkedIn wrote that "a high SSI is typically anything above 70" and that "a less-than-stellar SSI would be anything below 30." Its official [Achieving Social Selling Success infographic](https://www.linkedin.com/business/sales/blog/b2b-sales/achieving-social-selling-success) draws the same lines: SSI leaders above 70, laggards below 30. This is where the "70+" benchmark you see everywhere comes from – it is genuinely LinkedIn's number, just an old one. **Today, there is no current official target.** The modern SSI dashboard labels no threshold, and LinkedIn's current pages set none. Third-party guides embellish beyond the official numbers – higher "thought leader" bars, claimed platform-wide averages – but none of that comes from LinkedIn. So the useful present-day yardstick is **relative and temporal** rather than absolute: where you sit in your industry and network percentile ranks, and whether your score is trending up as you invest in LinkedIn. A 55 in the top 5% of your industry is a better signal than a 72 in the bottom half of it. ## Does SSI still matter in 2026? This is the question the classic SSI guides have not caught up with, because the primary source changed underneath them. LinkedIn's main SSI page is now titled ["From Social Selling Index (SSI) to AI"](https://business.linkedin.com/sell/resources/SSI), and it argues for moving past the score – "less scoring, more selling" – conceding outright that "a high SSI score doesn't always represent the efficacy of a sales person or correlate with measurable sales outcomes." That is LinkedIn, on its own flagship SSI page, telling you not to treat SSI as a performance metric. At the same time, LinkedIn still publishes the classic numbers on its [social selling page](https://business.linkedin.com/sales-solutions/social-selling), from internal research dating back to roughly 2014–2016: "Social selling leaders create 45% more opportunities than peers with lower SSI," "social selling leaders are 51% more likely to reach quota," and "78% of social sellers outsell peers who don't use social media." Read those for what they are – LinkedIn's own marketing research about correlation between activity and outcomes, not proof that raising the score raises revenue. The fair verdict: SSI is a **directional activity metric**. It tells you whether you (or a team) are consistently present on LinkedIn – completing profiles, searching deliberately, engaging, connecting. That makes it useful as a trend line and a team-hygiene signal, and useless as a quota predictor – by LinkedIn's own admission. If consistent LinkedIn presence is part of how you sell, SSI is a cheap proxy for whether the presence is actually happening. ## How to improve your LinkedIn SSI score Each component responds to a specific kind of activity. The table covers what feeds each pillar and the highest-leverage moves – and where a pillar is automatable, the linked guide goes deeper: | Component (official name) | What feeds it | What moves it | | --- | --- | --- | | Create a Professional Brand | Profile completeness, content you publish | Complete every profile section; post consistently – [automating your posting](/guides/how-to-automate-linkedin-posts) keeps the cadence without the willpower | | Find the right people | Deliberate searches and prospect views | Search with intent instead of browsing – [Boolean search](/guides/linkedin-boolean-search) makes each search count; Sales Navigator searches feed this too | | Engage with Insights | Reactions, comments, shares, group activity | Comment substantively on prospects' posts; share with a point of view; [messaging that gets replies](/guides/how-to-automate-linkedin-messages) beats broadcasting | | Build Strong Relationships | Connections made, especially decision-makers | Connect steadily with relevant people, not in bursts – the [connection-request guide](/guides/how-to-automate-linkedin-connection-requests) covers doing it at a human pace | Two honest notes. First, because Sales Navigator-only actions (advanced searches, saved leads, prospecting views) officially count toward the score, Navigator users tend to score higher by construction – LinkedIn publishes no boost percentage, and any specific figure you see circulating is a third-party estimate. Second, the fastest way to a higher SSI is simply doing the four activities regularly for a few weeks; the score updates daily, so effort shows up quickly. ## Who can see your SSI score? By default, only you. Your SSI is not displayed on your profile, not visible to your connections, and LinkedIn documents no direct effect of your SSI on how your profile or posts rank in feeds or search. There is one documented exception: Sales Navigator team contracts. LinkedIn's [data-privacy help page for Sales Navigator](https://www.linkedin.com/help/linkedin/answer/a103026) states: "Your Social Selling Index (SSI) score will be made available to your admin and other members of your team." If your company runs Sales Navigator with team seats, your admin and teammates can see your score – worth knowing before you assume it is fully private. ## Track your SSI over time (the part worth automating) A score you glance at once is trivia. The signal is the **trend** – is your score climbing while you invest in LinkedIn, and are your industry and network percentiles moving? The ranking guides reviewed in July 2026 do not show how to do that programmatically, and it is the one part that genuinely benefits from automation. [Linked API](/) – a flexible LinkedIn automation API you can embed in your own product, backend, CRM, or workflow, running every action on your own account through a human-paced cloud browser – has two dedicated actions for exactly this, documented under [retrieving SSI and performance](/docs/retrieving-ssi-and-performance). [`retrieveSSI`](/docs/action-st-retrieve-ssi) returns your score **plus both percentile ranks** (`ssi`, `industryTop`, `networkTop`), and [`retrievePerformance`](/docs/action-st-retrieve-performance) adds your dashboard analytics: `followersCount`, `postViewsLast7Days`, `profileViewsLast90Days`, `searchAppearancesPreviousWeek`. A weekly cron that appends one CSV row gives you a chart LinkedIn itself never shows – your SSI and reach over months, next to your actual outreach activity: ```typescript import LinkedApi from '@linkedapi/node'; import { appendFileSync } from 'fs'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const ssiWorkflow = await linkedapi.retrieveSSI.execute(); const { data: ssi } = await linkedapi.retrieveSSI.result(ssiWorkflow.workflowId); const perfWorkflow = await linkedapi.retrievePerformance.execute(); const { data: perf } = await linkedapi.retrievePerformance.result(perfWorkflow.workflowId); appendFileSync( 'ssi-history.csv', [ new Date().toISOString().slice(0, 10), ssi.ssi, ssi.industryTop, ssi.networkTop, perf.followersCount, perf.postViewsLast7Days, perf.profileViewsLast90Days, perf.searchAppearancesPreviousWeek, ].join(',') + '\n', ); ``` ```python import os from datetime import date from linkedapi import LinkedApi linkedapi = LinkedApi( linked_api_token=os.environ["LINKED_API_TOKEN"], identification_token=os.environ["IDENTIFICATION_TOKEN"], ) ssi_workflow = linkedapi.retrieve_ssi.execute() ssi = linkedapi.retrieve_ssi.result(ssi_workflow.workflow_id).data perf_workflow = linkedapi.retrieve_performance.execute() perf = linkedapi.retrieve_performance.result(perf_workflow.workflow_id).data row = [ date.today().isoformat(), ssi.ssi, ssi.industry_top, ssi.network_top, perf.followers_count, perf.post_views_last_7_days, perf.profile_views_last_90_days, perf.search_appearances_previous_week, ] with open("ssi-history.csv", "a") as f: f.write(",".join(map(str, row)) + "\n") ``` The same numbers are one command in the shell [CLI](/cli/getting-started) – easy to pipe into whatever you already use: ```bash linkedin stats ssi --json linkedin stats performance --json ``` One constraint to be clear about: both actions return data **for the connected account only** – there is no org-wide endpoint that reads a whole team's scores. A team SSI dashboard means each member's own LinkedIn account is connected, one seat per connected account on the flat [per-seat plan](/pricing), with the tracker running the same pull per account. And for AI-first workflows the whole loop works out of the box: an agent wired up through the [MCP server](/mcp/overview), the AI-agent-friendly [CLI](/cli/getting-started), or the ready-made [skills](/skills) can check the weekly numbers and report the movement in plain language – nothing to build or maintain. ## Frequently Asked Questions (FAQ) #### What is a good SSI score on LinkedIn? LinkedIn's dated guidance from 2017 called anything above 70 a high SSI and anything below 30 weak, and that is where the common "70+" benchmark comes from. Today's dashboard labels no threshold, and LinkedIn's current pages set no target – so use the relative signals instead: your industry and network percentile ranks, and whether your score is trending up. #### How often does the SSI score update? Daily. LinkedIn's learning-center materials reference daily SSI updates, and the dashboard carries the same wording – so changes in your activity show up within a day or so, which is what makes the score practical to track as a trend. #### Is my SSI score visible to others? By default, no – it is not on your profile and your connections cannot see it. The documented exception is Sales Navigator team contracts: LinkedIn's data-privacy help page states your SSI score "will be made available to your admin and other members of your team." #### Does Sales Navigator increase your SSI? Sales Navigator activity officially counts: LinkedIn's tip sheet says the SSI measures the four activities "as they take place on Sales Navigator and LinkedIn.com," including Navigator-only signals like advanced searches and saved leads. LinkedIn publishes no boost percentage, though – any specific figure circulating in third-party guides is an estimate, not LinkedIn's number. #### Is checking your SSI score free? Yes. SSI has been free for every LinkedIn member since August 3, 2015, when LinkedIn opened what was previously a Sales Navigator metric to everyone. You just need to be logged in when you open linkedin.com/sales/ssi. #### Does a high SSI make LinkedIn show my content to more people? LinkedIn documents no direct ranking effect from SSI on your feed reach or search visibility. The overlap is indirect: the activities that raise SSI – posting, engaging, connecting – are the same ones that grow reach on their own. Treat SSI as a reflection of that activity, not a lever on distribution. --- Want the trend, not a one-off glance? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually – and pull `retrieveSSI` and `retrievePerformance` on a schedule through the full surface set: the [REST API and Node/Python SDKs](/sdks/installation), the shell [CLI](/cli/getting-started), the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`) – on your own account and at a human pace. ## How to Automate LinkedIn Connection Requests in 2026: Send, Track, and Manage Invitations Sending LinkedIn connection requests by hand is exactly the work a computer should do: open a profile, click Connect, sometimes add a note, then keep track of who you already invited, who accepted, and which requests went stale enough to withdraw. At a handful a day it is a chore; at the scale of real prospecting it becomes a queue you can never keep clean. So the practical question is how to automate LinkedIn connection requests – both sending them and managing the pending and incoming queue – without babysitting a browser tab. > **The short version.** You can automate LinkedIn connection requests three ways: a **Chrome extension** that clicks inside your logged-in browser, a **hosted campaign tool** you configure in a UI, or an **API that runs on your own account**. The API approach composes the whole lifecycle from primitives – send a request, check the connection status, list and withdraw stale pending invites, accept or ignore incoming invitations, and detect when someone accepts so you can send a welcome. With Linked API each step runs on your own account through a human-paced cloud browser, and you can embed it in your own product, backend, or CRM. ## Three ways to automate LinkedIn connection requests LinkedIn does not offer a public connect API you can call directly. Its official invitation endpoints are [restricted to approved partners](https://learn.microsoft.com/en-us/linkedin/shared/integrations/communications/invitations) and are not generally available, so every automation option acts on your own logged-in account instead. Three shapes are practical: | Approach | Runs on | Where the logic lives | Lifecycle coverage | Embeddable in your stack | Best for | |---|---|---|---|---|---| | Chrome extension | Your browser, while it is open | Inside the extension | Sending, with some tracking | No | One person, light volume, zero setup | | Hosted campaign tool | The vendor's cloud | A campaign UI you configure | Full, inside the vendor's UI | Limited (some expose an API over pre-built agents) | Marketers who want a packaged UI | | API on your own account (Linked API) | A human-paced cloud browser on your account | Your own code | Full, as composable primitives | Yes – REST API, Node/Python SDKs, shell CLI, MCP server, AI-agent CLI, and skills | Teams embedding it in a product, backend, or CRM | Hosted tools are genuinely capable. PhantomBuster's Outreach Flow tracks a request from sent to accepted to follow-up, while its separate Auto Invitation Withdrawer prunes pending requests, and Dripify supports automatic withdrawal and acceptance-based branches (per each vendor's own docs, as of 2026). Some even expose an API – PhantomBuster's, for example, launches, configures, and chains its pre-built "Phantoms." The real difference is the **layer you program at**: those APIs orchestrate the vendor's packaged agents inside a campaign model, while Linked API exposes the underlying LinkedIn actions themselves as primitives you compose and embed. If you just want a campaign to run, a hosted tool is fine. If you want the connection lifecycle as building blocks inside your own system, read on. ## Is there a LinkedIn invitation API? Not a public one. LinkedIn's official [invitation endpoints](https://learn.microsoft.com/en-us/linkedin/shared/integrations/communications/invitations) are restricted to approved partners, so there is no self-serve API that sends a connection request on your behalf. The working equivalent is account-based: Linked API's [`sendConnectionRequest`](/docs/action-st-send-connection-request?surface=sdk) sends the invitation, with an optional note, through a dedicated cloud browser on your own account, and the matching actions [retrieve](/docs/action-st-retrieve-invitations), [withdraw](/docs/action-st-withdraw-connection-request), [accept](/docs/action-st-accept-invitation) and [ignore](/docs/action-st-ignore-invitation) invitations cover the rest of the lifecycle described next. ## The connection-request lifecycle, as API primitives Automating connection requests well is not one call – it is a small lifecycle. Linked API exposes each step as its own action so you assemble exactly the flow you need: - [`sendConnectionRequest`](/docs/action-st-send-connection-request?surface=sdk) – send a request, optionally with a note. - [`checkConnectionStatus`](/docs/action-st-check-connection-status?surface=sdk) – returns `connected`, `notConnected`, `pending`, or `incoming`, so you never invite the same person twice. - [`retrievePendingRequests`](/docs/action-st-retrieve-pending-requests?surface=sdk) – lists the outgoing requests you are still waiting on. - [`withdrawConnectionRequest`](/docs/action-st-withdraw-connection-request?surface=sdk) – cancels a request that has gone stale. - [`retrieveInvitations`](/docs/action-st-retrieve-invitations?surface=sdk) plus [`acceptInvitation`](/docs/action-st-accept-invitation?surface=sdk) / [`ignoreInvitation`](/docs/action-st-ignore-invitation?surface=sdk) – triage the invitations coming *in*. - [`syncNetwork`](/docs/action-st-sync-network?surface=sdk) and [`pollNetwork`](/docs/monitoring-network?surface=sdk) – retrieve acceptance events after Linked API observes them (and other network changes) without diffing lists yourself. ![Diagram: the LinkedIn connection-request lifecycle as Linked API primitives. An outbound lane runs sendConnectionRequest, then checkConnectionStatus returns pending, then retrievePendingRequests lists the queue, then withdrawConnectionRequest prunes stale invites. A parallel inbound lane runs retrieveInvitations, then acceptInvitation or ignoreInvitation. A monitoring lane runs syncNetwork once, then pollNetwork surfaces a connectionAccepted event with a detectedAt timestamp, which hands off to sendMessage for a welcome. A footer notes it runs on your own account, at a human pace.](/images/guides/linkedin-connection-lifecycle.webp) The rest of this guide walks each lane, in code. All of it runs on the [Node and Python SDKs](/sdks) and the [shell CLI](/cli/commands#connections); the same actions are available over the [REST API](/sdks), the [MCP server](/mcp), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and the ready-made [agent skills](/skills). If you want an agent to run this rather than writing the code yourself, start with [how to give an AI agent access to LinkedIn](/guides/linkedin-ai-agent). ## How to automate sending connection requests (in code) The one rule that separates a clean automation from a messy one is the **duplicate gate**: check where you stand before you send. `checkConnectionStatus` tells you the [connection status](/docs/checking-connection-status) – whether you are already `connected`, already have a `pending` request out, have an `incoming` request to accept, or are genuinely `notConnected` and clear to invite. Three of those four wear the same profile badge, which is why the [degree of connection](/guides/linkedin-connection-degrees) you can see is not the state you need. Send only in the last case, and handle the one common edge – some people can only be added with a verified email, which `sendConnectionRequest` reports as `emailRequired`. ```typescript import { LinkedApi } from "@linkedapi/node"; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); async function connectIfNew( personUrl: string, note: string, knownEmail?: string, ): Promise { const check = await linkedapi.checkConnectionStatus.execute({ personUrl }); const { data } = await linkedapi.checkConnectionStatus.result(check.workflowId); if (data?.connectionStatus !== "notConnected") { return data?.connectionStatus ?? "unknown"; // connected, pending, or incoming – skip } const req = await linkedapi.sendConnectionRequest.execute({ personUrl, note }); const { errors } = await linkedapi.sendConnectionRequest.result(req.workflowId); // Edge: this person requires a verified email. Retry with one if you have it. if (errors?.some((e) => e.type === "emailRequired") && knownEmail) { const retry = await linkedapi.sendConnectionRequest.execute({ personUrl, note, email: knownEmail, }); await linkedapi.sendConnectionRequest.result(retry.workflowId); return "sent"; } return errors?.length ? errors[0].type : "sent"; } ``` ```python import os from linkedapi import ( LinkedApi, LinkedApiConfig, CheckConnectionStatusParams, SendConnectionRequestParams, WithdrawConnectionRequestParams, AcceptInvitationParams, IgnoreInvitationParams, NetworkPollRequest, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token=os.environ["LINKED_API_TOKEN"], identification_token=os.environ["IDENTIFICATION_TOKEN"], ) ) def connect_if_new(person_url: str, note: str, known_email: str | None = None) -> str: check = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url=person_url) ) data = linkedapi.check_connection_status.result(check.workflow_id).data if data.connection_status != "notConnected": return data.connection_status # connected, pending, or incoming – skip req = linkedapi.send_connection_request.execute( SendConnectionRequestParams(person_url=person_url, note=note) ) errors = linkedapi.send_connection_request.result(req.workflow_id).errors # Edge: this person requires a verified email. Retry with one if you have it. if any(e.type == "emailRequired" for e in errors) and known_email: retry = linkedapi.send_connection_request.execute( SendConnectionRequestParams( person_url=person_url, note=note, email=known_email ) ) linkedapi.send_connection_request.result(retry.workflow_id) return "sent" return errors[0].type if errors else "sent" ``` From the shell, the same two steps are one command each: ```bash linkedin connection status https://www.linkedin.com/in/john-doe linkedin connection send https://www.linkedin.com/in/john-doe --note "Great to connect." ``` The note is a single field, not a sequence; what it should *say* is outreach copy, covered in the [cold outreach guide](/guides/linkedin-cold-outreach-strategies-2026). If the note is too long or your free account has used up its personalized invitations, `sendConnectionRequest` returns `noteTooLong` or `noteLimitExceeded` so you can drop the note and send anyway. > **This runs on your own account.** Every request goes through a human-paced cloud browser on your own LinkedIn account – no cookie handoff to a third-party cloud, and no blast queue. Linked API surfaces LinkedIn's own signals, such as `requestNotAllowed` when you are pushing too hard, so your code can back off automatically. For how many requests are actually safe per day and per week, see the [LinkedIn connection limit guide](/guides/linkedin-connection-limit-2026). ## Keep your pending queue clean: list and withdraw Requests that sit unanswered for weeks are dead weight: they count against the pending invitations LinkedIn lets you hold, and a pile of ignored invites is a signal you do not want to send – LinkedIn names invitations "ignored, left pending, or marked as spam" among its documented triggers for a [restricted LinkedIn account](/guides/linkedin-account-restricted). Withdrawing the stale ones keeps the queue healthy, and [managing your connections and requests](/docs/managing-existing-connections) is a first-class part of the API. `retrievePendingRequests` returns everything still outstanding – each with a `name`, `publicUrl`, `headline`, and a relative `sentTime` like `"1 month ago"` – and `withdrawConnectionRequest` cancels one. ```typescript const wf = await linkedapi.retrievePendingRequests.execute(); const { data } = await linkedapi.retrievePendingRequests.result(wf.workflowId); for (const request of data ?? []) { console.log(`${request.name} – sent ${request.sentTime}`); // Withdraw the ones that have been out too long (unfollow defaults to true). if (isStale(request.sentTime)) { await linkedapi.withdrawConnectionRequest.execute({ personUrl: request.publicUrl }); } } ``` ```python wf = linkedapi.retrieve_pending_requests.execute() data = linkedapi.retrieve_pending_requests.result(wf.workflow_id).data for request in data or []: print(f"{request.name} – sent {request.sent_time}") # Withdraw the ones that have been out too long (unfollow defaults to true). if is_stale(request.sent_time): linkedapi.withdraw_connection_request.execute( WithdrawConnectionRequestParams(person_url=request.public_url) ) ``` ```bash linkedin connection pending --json linkedin connection withdraw https://www.linkedin.com/in/john-doe ``` By default, withdrawing also unfollows the person; pass `unfollow: false` (or `--no-unfollow` on the CLI) to keep following them. The manual side of this queue – where the Sent tab lives, what the recipient sees on withdrawal, expiry and cooldown rules – is covered in the [pending invitations guide](/guides/linkedin-pending-invitations). How stale is "too stale," and how many pending invites are safe to hold, are pacing questions – the [connection limit guide](/guides/linkedin-connection-limit-2026) has the current numbers. ## Handle incoming invitations automatically The inbound side is its own queue – the docs cover it under [working with invitations](/docs/working-with-invitations). `retrieveInvitations` lists every incoming invitation, and each carries an `invitationType` of `connect`, `companyFollow`, or `newsletterSubscribe`. For `connect` invitations you also get the sender's `headline` and `note` (both nullable), which is enough to auto-accept the ones that fit your ICP and ignore the rest with `acceptInvitation` / `ignoreInvitation`. ```typescript const wf = await linkedapi.retrieveInvitations.execute(); const { data } = await linkedapi.retrieveInvitations.result(wf.workflowId); for (const invite of data ?? []) { if (invite.invitationType !== "connect") continue; const relevant = /founder|head of|vp|director/i.test(invite.headline ?? ""); if (relevant) { await linkedapi.acceptInvitation.execute({ invitationType: "connect", personUrl: invite.publicUrl, }); } else { await linkedapi.ignoreInvitation.execute({ invitationType: "connect", personUrl: invite.publicUrl, }); } } ``` ```python wf = linkedapi.retrieve_invitations.execute() data = linkedapi.retrieve_invitations.result(wf.workflow_id).data for invite in data or []: if invite.invitation_type != "connect": continue headline = invite.headline or "" if any(k in headline.lower() for k in ("founder", "head of", "vp", "director")): linkedapi.accept_invitation.execute( AcceptInvitationParams(invitation_type="connect", person_url=invite.public_url) ) else: linkedapi.ignore_invitation.execute( IgnoreInvitationParams(invitation_type="connect", person_url=invite.public_url) ) ``` ```bash linkedin connection invitations --json linkedin connection accept connect https://www.linkedin.com/in/john-doe linkedin connection ignore connect https://www.linkedin.com/in/someone-else ``` Company-follow and newsletter invitations come through the same method with their own `companyUrl` / `newsletterUrl` fields, so you can route those too. ## Detect when someone accepts (and welcome them) The payoff of automating requests is acting on each acceptance without watching for it by hand. Rather than re-list your connections and diff them, enable [network monitoring](/docs/monitoring-network) once with `syncNetwork`, then read connection events with `pollNetwork`. Each event carries a `type` (`connectionRequestReceived`, `connectionAccepted`, or `connectionAdded`), the other person's `personUrl`, and a `detectedAt` timestamp for when Linked API observed it. The events come back newest-first, so filter for `connectionAccepted`, hand each new connection to your own welcome step, and advance your cursor to the newest event you saw. ```typescript // Enable network monitoring once per account. const sync = await linkedapi.syncNetwork.execute(); await linkedapi.syncNetwork.result(sync.workflowId); // Later, poll for acceptances. `events` comes back newest-first. const { data } = await linkedapi.pollNetwork({ type: "connectionAccepted", since: lastSeen }); const events = data?.events ?? []; for (const event of events) { await sendWelcome(event.personUrl); // your own follow-up } if (events.length) { lastSeen = events[0].detectedAt; // advance the cursor to the newest event } ``` ```python # Enable network monitoring once per account. sync = linkedapi.sync_network.execute() linkedapi.sync_network.result(sync.workflow_id) # Later, poll for acceptances. `events` comes back newest-first. result = linkedapi.poll_network( NetworkPollRequest(type="connectionAccepted", since=last_seen) ) events = result.data or [] # Python returns the list of events itself for event in events: send_welcome(event.person_url) # your own follow-up if events: last_seen = events[0].detected_at # advance the cursor to the newest event ``` ```bash linkedin network sync linkedin network events --type connectionAccepted --json ``` `pollNetwork` is a polling call, so `detectedAt` is when Linked API observed the acceptance, not a live push; events are retained for 90 days, so poll often enough to consume them. If you prefer push, subscribe to the `network.connectionAccepted` [webhook](/docs/webhooks) and skip polling entirely. What the welcome should say, and when to follow up, is outreach rather than mechanics: see [how to automate LinkedIn messages](/guides/how-to-automate-linkedin-messages) for the send-and-read flow and the [cold outreach guide](/guides/linkedin-cold-outreach-strategies-2026) for copy and cadence. ## When an extension or hosted tool is the better choice An API is not always the right tool. If you are one person sending a handful of invites a week, a Chrome extension is the fastest path – nothing to code, nothing to host. If you want a packaged campaign UI with built-in dashboards and templates and you do not need the flow embedded anywhere, a hosted tool will run the lifecycle for you. Linked API earns its place when you are building connection automation into your own product, backend, or CRM, want the individual LinkedIn actions as programmable primitives, and want it running on your account at a human pace instead of in a browser tab you keep open. Finding *who* to connect with is a separate job – for that, see the [Boolean search guide](/guides/linkedin-boolean-search) and the [Sales Navigator scraper guide](/guides/linkedin-sales-navigator-scraper) – and so is what your new connections see in their feed: automating your own publishing is covered in [How to Automate LinkedIn Posts](/guides/how-to-automate-linkedin-posts). ## Frequently Asked Questions (FAQ) #### How do I avoid sending a duplicate connection request? Call `checkConnectionStatus` before you send and skip anyone who is already `connected` or `pending`. As a backstop, `sendConnectionRequest` also reports `alreadyPending` or `alreadyConnected` if a request or connection already exists, so a duplicate is a caught error rather than a second invite. #### What happens if a profile requires an email to connect? Some people only accept connection requests that include a verified email. When that is the case, `sendConnectionRequest` returns `emailRequired`. Pass the person's `email` as the optional input and retry. Email is an input for that case only – Linked API never returns email addresses as data. #### Can I see and cancel the connection requests I already sent? Yes. `retrievePendingRequests` lists every outgoing request you are still waiting on, with the person's name, URL, headline, and a relative sent time. `withdrawConnectionRequest` cancels one; withdrawing unfollows the person by default, which you can turn off with `unfollow: false`. #### Can I automatically accept or ignore incoming invitations? Yes. `retrieveInvitations` returns your incoming invitations, and `acceptInvitation` / `ignoreInvitation` act on each by type and URL. For `connect` invitations you get the sender's headline and note, so you can auto-accept the ones that match your criteria and ignore the rest. #### How do I know when someone accepts my request? Enable monitoring once with `syncNetwork`, then poll `pollNetwork` for `connectionAccepted` events – each carries the person's URL and a `detectedAt` timestamp. If you would rather not poll, subscribe to the `network.connectionAccepted` webhook and Linked API pushes each event to your endpoint. #### Do I need Sales Navigator to automate connection requests? No. `sendConnectionRequest` and the rest of the connection lifecycle work on a standard LinkedIn account. Sales Navigator only matters for its larger search, covered in the [Sales Navigator API guide](/guides/linkedin-sales-navigator-api), and for [InMail to non-connections](/guides/linkedin-inmail). #### Does this run on my own LinkedIn account? Yes. Requests run through a human-paced cloud browser on your own account, with no cookie handoff to a third-party cloud. Linked API surfaces LinkedIn's own limit signals, such as `requestNotAllowed`, so your code can slow down. For safe pacing, see the [connection limit guide](/guides/linkedin-connection-limit-2026). #### How much does it cost? Linked API starts at $49/mo on the Core plan, billed annually, flat per seat – there is no per-request or per-lead metering. See [pricing](/pricing) for the current plans. ## Build it on your own account You can build the whole connection lifecycle today. Linked API gives you sending, status checks, pending-queue management, incoming-invitation handling, and acceptance detection as composable primitives across the full surface set: the [REST API and Node/Python SDKs](/sdks), the shell [CLI](/cli), the [MCP server](/mcp), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`) – all running on your own account at a human pace. Start on the [Core plan at $49/mo, billed annually](/pricing) and wire it into your product, backend, or CRM. ## LinkedIn Company Scraper and Company API: Extract Company Profiles, Employees, and Decision-Makers (2026) A LinkedIn company page holds a small database: the firmographics, the people who work there, the decision-makers, and everything the company posts. Turning that into structured data you can actually use – at scale, and while managing the risk to your account – is what a company scraper is for. > **The short version.** A LinkedIn company scraper turns a company's LinkedIn URL into structured data. You have three methods: a **cookieless dataset** (runs via proxies with no login, but typically returns company fields, and sells people or posts as separate products), a **session-borrowing browser tool** (you hand your logged-in LinkedIn cookie to a third-party cloud, which is the real ban vector), or an **API on your own account**. Linked API's `fetchCompany` returns the company profile *plus* its employees, posts, and decision-makers in one workflow, and `searchCompanies` finds companies by size, industry, and location – on your own account at a human pace, returning company and people data, **not emails**. ## What you can pull from a LinkedIn company One `fetchCompany` workflow returns the firmographic profile, and you can opt in to the employees, decision-makers, and posts alongside it. | Data | What you get | How | | --- | --- | --- | | Company profile | name, industry, size (`employeesCount`), HQ location, specialties, website, year founded, open-jobs count, logo | `fetchCompany` | | Employees | up to **500** people (name, headline, location, profile URL), filterable by role, location, school, or past company | `retrieveEmployees` | | Decision-makers | up to **20** key people (name, headline, location, country code (`countryCode`), profile URL) | `retrieveDMs` | | Company posts | up to **20** recent posts (text, media, reaction and comment counts) | `retrievePosts` | | **Not returned** | emails, phone numbers, follower counts, any funding, venture-financing, or investor detail | – | That last row is the honest boundary: Linked API returns company and people data from LinkedIn, not contact details or a firmographic database's funding history. ## Is there a LinkedIn company API? Only a narrow one. LinkedIn's official [Organization Lookup API](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/organizations/organization-lookup-api) lives inside the Community Management API and is built for Pages you administer: the full record requires the `rw_organization_admin` permission and an `ADMINISTRATOR` role on that organization, the lookup for organizations you do not administer returns a handful of fields – id, name, vanity name, website, logo, locations and type – and the documentation states that wildcards and boolean search are not supported. Nothing in it returns employees, decision-makers or posts for an arbitrary company, so every "LinkedIn company API" or "company data API" on the market is an account-based or scraping product. Linked API's route is account-based: [`fetchCompany`](/docs/action-st-open-company-page?surface=sdk) opens any public company page by URL through a dedicated cloud browser on your own account and returns the data in the table above, and [`searchCompanies`](/docs/action-st-search-companies?surface=sdk) finds the companies to fetch. ## Three ways to scrape LinkedIn company data The methods differ mostly in *where the browser runs* and *whose account it uses* – which is also what decides your ban risk. | Method | How it runs | What you get | Account-safety trade-off | | --- | --- | --- | --- | | Cookieless dataset / scraper | On a vendor's cloud via rotating proxies, no login | Company profile fields; some are pre-collected datasets, others are live URL scrapers, and people or posts tend to be separate products (per Bright Data's collection options, 2026) | Doesn't touch your account, but you consume a product rather than composing your own authenticated query | | Session-borrowing browser tool | On a vendor's cloud, driven by *your* logged-in LinkedIn cookie | Company data, often with a chained employee export | You hand your session to a third party – the real ban vector, and often day-capped (around 80 companies/day, per PhantomBuster's page, 2026) | | API on your own account | On your own authenticated account through a human-paced cloud browser | Profile + employees + posts + decision-makers in one workflow | Runs as you, at a human pace, with no cookie handoff; account-paced rather than a bulk overnight dump | A common question here: **does LinkedIn have an official way to do this?** LinkedIn's own APIs (Marketing, Talent) are partner-gated and need [program approval](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access) – they are not a general-purpose way to pull an arbitrary company from its URL. So in practice you either scrape the public page or use an API that acts on your own account. The rest of this guide covers the API route, where [Linked API](/) retrieves [structured company records](/docs/retrieving-company-data) on your behalf. ## How to scrape one company from its URL Install the SDK (`npm install -S @linkedapi/node` or `pip install linkedapi`), initialise the client with your tokens, and call `fetchCompany` with the company URL. Toggle `retrieveEmployees`, `retrieveDMs`, and `retrievePosts` to pull those alongside the profile; each has a config for its limit (and the employees list takes a filter). ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const company = await linkedapi.fetchCompany.execute({ companyUrl: 'https://www.linkedin.com/company/microsoft', retrieveEmployees: true, retrieveDMs: true, retrievePosts: true, employeesRetrievalConfig: { limit: 100, // up to 500 filter: { position: 'engineer', locations: ['United States'] }, }, postsRetrievalConfig: { limit: 20 }, // up to 20 dmsRetrievalConfig: { limit: 20 }, // up to 20 }); const { data } = await linkedapi.fetchCompany.result(company.workflowId); if (data) { console.log(data.name, data.industry, data.employeesCount); console.log(data.employees?.length, 'employees,', data.dms?.length, 'decision-makers'); } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, FetchCompanyParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) company = linkedapi.fetch_company.execute( FetchCompanyParams( company_url="https://www.linkedin.com/company/microsoft", retrieve_employees=True, retrieve_dms=True, retrieve_posts=True, employees_retrieval_config={ "limit": 100, # up to 500 "filter": {"position": "engineer", "locations": ["United States"]}, }, posts_retrieval_config={"limit": 20}, # up to 20 dms_retrieval_config={"limit": 20}, # up to 20 ) ) data = linkedapi.fetch_company.result(company.workflow_id).data if data: print(data.name, data.industry, data.employees_count) print(len(data.employees or []), "employees,", len(data.dms or []), "decision-makers") ``` On the shell, the [company CLI](/cli/commands#companies) does the same in one line, and the AI-agent surfaces – the [MCP server](/mcp/overview) and ready-made [skills](/skills) – expose it to Claude Code, Cursor, and Codex. ```bash linkedin company fetch https://www.linkedin.com/company/microsoft --employees --dms --json ``` The employees filter is what turns "a company" into "the right people at a company": pass a `position`, `locations`, `schools`, `currentCompanies`, or `previousCompanies` and you get a targeted slice instead of the whole roster. Full field reference is in the [`fetchCompany` docs](/docs/action-st-open-company-page?surface=sdk) and the [company data guide](/docs/retrieving-company-data). ![Diagram: one fetchCompany workflow returns four kinds of data. A company URL feeds fetchCompany, with retrieveEmployees, retrievePosts, and retrieveDMs toggles, fanning out to four output cards: Company profile (industry, size, HQ, specialties, website, founded); Employees array (name, headline, location – up to 500); Posts array (up to 20); and Decision-makers array (name, headline, location, countryCode – up to 20), highlighted as the differentiator. A footer notes it runs on your own account, at a human pace, returning company and people data, not emails.](/images/guides/linkedin-company-scraper-bundle.webp) ## Find the companies to scrape If you do not already have the URLs, `searchCompanies` builds the list. Filter by `sizes`, `locations`, and `industries`, or hand it a LinkedIn search results URL as `customSearchUrl`, then loop each result's `publicUrl` into `fetchCompany`. ```typescript const search = await linkedapi.searchCompanies.execute({ term: 'fintech', limit: 50, // up to 1000 filter: { sizes: ['51-200', '201-500'], locations: ['United States'], industries: ['Financial Services'], }, }); const { data: companies } = await linkedapi.searchCompanies.result(search.workflowId); for (const c of companies ?? []) { console.log(c.name, c.industry, c.publicUrl); // feed publicUrl back into fetchCompany } ``` ```python from linkedapi import SearchCompaniesParams search = linkedapi.search_companies.execute( SearchCompaniesParams( term="fintech", limit=50, # up to 1000 filter={ "sizes": ["51-200", "201-500"], "locations": ["United States"], "industries": ["Financial Services"], }, ) ) companies = linkedapi.search_companies.result(search.workflow_id).data for c in companies or []: print(c.name, c.industry, c.public_url) # feed public_url back into fetch_company ``` ```bash linkedin company search --term "fintech" --sizes "51-200,201-500" --industries "Financial Services" --json ``` Company sizes use LinkedIn's own bands (`1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+`). Each result returns `name`, `publicUrl`, `industry`, `location`, and `logoUrl`; pass the `publicUrl` straight into `fetchCompany` to go deep. If you work in Sales Navigator, the [`nvSearchCompanies`](/docs/action-nv-search-companies?surface=sdk) and [`nvFetchCompany`](/docs/action-nv-open-company-page?surface=sdk) variants add filters like revenue (they need a Sales Navigator seat); see the [Sales Navigator API guide](/guides/linkedin-sales-navigator-api). See also the [search reference](/docs/action-st-search-companies?surface=sdk) and [how to find companies with filters](/docs/searching-for-companies). ## Staying safe: your own account, human pace, no cookie handoff The account risk in company scraping is not the data – public company pages are meant to be read. It is *how* a tool gets it. Browser tools that run on a vendor's cloud ask for your logged-in LinkedIn cookie, and handing your session to a third party is what actually gets accounts flagged – the tools' own pages flag it too. Linked API takes a different path: every workflow runs on **your own account through a human-paced cloud browser**, so you never export a cookie to anyone. It also applies its own caps by design – one `fetchCompany` returns up to 500 employees, 20 posts, and 20 decision-makers, and `searchCompanies` up to 1,000 results, with the workflow pacing itself between actions. Separately, it surfaces LinkedIn's own limit signals: if LinkedIn restricts a retrieval you get a clear `retrievingNotAllowed` or `searchingNotAllowed` to back off on, rather than a silent overrun. Our [account limits guide](/guides/understanding-linkedin-limits) and [safety model](/safety) go deeper on pacing your volumes. ## When a cookieless scraper or data provider fits better Linked API is not always the right tool. If you need a one-off dump of tens of thousands of company records and you do not have (or do not want to use) a LinkedIn account, a **cookieless dataset provider** is the better fit – it trades composability for raw scale. And if your job is really about **emails, direct dials, or funding-round histories**, that is a firmographic or enrichment database, not a LinkedIn scraper. Linked API is the right fit when you want the profile, the people, and the decision-makers *together*, on your own account, wired into your own product or workflow. For the broader picture, see the [LinkedIn scraping pillar](/guides/how-to-scrape-linkedin) and the [scraper API overview](/guides/linkedin-scraper-api); for other entities, the [profile scraper](/guides/linkedin-profile-scraper), [jobs scraper](/guides/linkedin-jobs-scraper), and [post scraper](/guides/linkedin-post-scraper). ## Frequently Asked Questions (FAQ) #### Can you scrape LinkedIn company data? Yes. A company scraper turns a LinkedIn company URL into structured JSON you can store or feed into a workflow. The available fields and account-safety trade-offs depend on the method you pick, covered above. #### How do I get a list of all employees at a company on LinkedIn? Call `fetchCompany` with `retrieveEmployees` on. You get up to 500 employees per company (name, headline, location, profile URL), and you can filter the list by role, location, school, or current/previous company to pull just the people you care about. #### Can you scrape company size, industry, and headcount from LinkedIn? Yes. `fetchCompany` returns the firmographic profile – industry, exact headcount (`employeesCount`), HQ location, specialties, website, year founded, and open-jobs count – from the company URL, with no extra configuration. #### How do I scrape LinkedIn without getting my account blocked? The main ban vector is handing your logged-in cookie to a third-party cloud tool. Avoid that: use a method that runs on your own account at a human pace and stays within LinkedIn's limits. Linked API does this by design and surfaces LinkedIn's own limit signals so you can slow down before hitting a wall. #### Can you scrape a LinkedIn company page without logging in? Cookieless dataset providers scrape public pages via proxies with no login, but they typically return company fields only. To also get the employee roster, posts, and decision-makers, you use an authenticated method – Linked API runs on your own account rather than borrowing a session. #### Can you get a company's decision-makers? Yes. Turn on `retrieveDMs` and `fetchCompany` returns up to 20 decision-makers (name, headline, location, country code (`countryCode`), profile URL) in the same result as the profile and employees – so you know who to contact, not just where. #### How many employees can you pull per company? Up to 500 per company with `fetchCompany`, controlled by `employeesRetrievalConfig.limit`. To go wider than one company, use `searchCompanies` (up to 1,000 companies) and fetch each in turn. #### What is the best LinkedIn company scraper? It depends on the job. For a raw bulk dump with no account, a cookieless dataset provider wins. For the profile, employees, posts, and decision-makers together, embedded in your own stack on your own account, an API like Linked API is the better fit. --- Want the company profile, its people, and its decision-makers in one workflow, on your own account? [Start with Linked API](/pricing) – pull company data through the [API and SDKs](/sdks/installation), [CLI](/cli/getting-started), [MCP server](/mcp/overview), and [skills](/skills), and wire it straight into your own stack. ## How to Automate LinkedIn Messages (2026): The LinkedIn Messaging API, What's Safe, and the Flow in Code If you send the same LinkedIn connection requests and follow-up messages by hand every week, you have already hit the ceiling: it does not scale, and copy-pasting into the message box is exactly the work a computer should do. This guide covers what you can automate on LinkedIn, the safe ways to do it, and – for teams who want it wired into their own product or CRM – the exact connect-to-reply flow in code. > **The short version.** Standard LinkedIn messaging has no native way to schedule or sequence outbound DMs (LinkedIn documents scheduled outreach only for its paid recruiting tools – Recruiter and Recruiter Lite can schedule initial InMail, and Recruiter alone can schedule one automated follow-up; Premium's away message is just an auto-reply), so automating everyday outreach means a tool or an API acting on your own account. The real automation is a branching loop: if you are already connected, send a message; if not, either send a connection request → wait until it is accepted → message, or use Sales Navigator InMail to reach a non-connection directly. Then read the reply, or monitor your whole inbox for incoming messages at scale. Keep it on your own account, at a human pace, within LinkedIn's limits. ## Is there a LinkedIn messaging API? Yes, but it is not a general developer product. LinkedIn documents two official messaging surfaces, both gated behind partner approval, and neither one lets you schedule or sequence outbound DMs. | Official surface | What it does | Who can use it | | --- | --- | --- | | [Messages API](https://learn.microsoft.com/en-us/linkedin/shared/integrations/communications/messages) | Creates a message to one or more first-degree connections, or replies in an existing thread | Approved partners only, under an API agreement | | [Pages messaging integration](https://www.linkedin.com/help/linkedin/answer/a6246714) | Sends and receives messages in a **Company Page's** inbox – not your personal DMs | Page admins, through six approved platforms: Hootsuite, Sprinklr, Oktopost, Brandwatch, Zoho Recruit, Bird CRM | The Messages API documentation opens on the access rule: *"Usage of this API is restricted to approved partners, subject to limitations via API agreement."* Approval on its own would still not buy you automation, because the same page rules it out in the requirements: *"A message must be associated with a specific member action. Member actions do not include an automated or scheduled event."* LinkedIn further requires that the member can edit any pre-prepared draft, takes an affirmative action to send it, and that the message posts at or around the time of that action. That leaves the account-level route for everyone else, and it is what the rest of this guide covers: Linked API's messaging actions – [`sendMessage`](/docs/action-st-send-message) and [`nvSendMessage`](/docs/action-nv-send-message), with [inbox monitoring](/docs/monitoring-inbox) for reading replies and [conversation history](/docs/working-with-conversations) for earlier messages – run on your own account through a human-paced cloud browser rather than under a partner agreement. ## Can you automate LinkedIn messages? Yes – but for regular direct messages, not with a native LinkedIn button. LinkedIn's own [Message content automation](https://www.linkedin.com/help/linkedin/answer/a1336512) page is about *inbound* processing – how it renders and suggests replies to messages you receive – not about scheduling messages you send. The one place LinkedIn documents scheduled outbound is its paid recruiting product: per LinkedIn's [help center](https://www.linkedin.com/help/recruiter/answer/a1457303), you can schedule initial outreach InMail in **Recruiter and Recruiter Lite**, and schedule one automated follow-up InMail in **Recruiter** only. Everyday Messaging has no equivalent, and the Premium [away message](https://www.linkedin.com/help/linkedin/answer/a550614) is an automatic *reply* shown to people who message you – not a way to send scheduled outreach. So for standard outreach, "automating LinkedIn messages" means a tool or an API that performs the clicks and keystrokes on your own account. The rest of this guide is how to do that well. ## The three ways to automate LinkedIn messages There are three approaches, and the right one depends on how much control you need and whether you write code. | Approach | How it runs | Best for | Honest trade-off | | --- | --- | --- | --- | | Browser (Chrome) extension | On your own computer, in your logged-in browser | Quick, occasional personal use | Only works while your machine and browser are on; more exposed than a cloud setup | | Cloud no-code tool | On the vendor's cloud, on a schedule, through a visual campaign builder | Non-technical users running standard campaigns | A closed UI – you get the vendor's features, not your own logic; you live inside their product | | API / code | You call an API from your own backend, on your own account | Teams embedding messaging in a product, CRM, or agent | You write a little code – in exchange you own the whole flow | The first two are covered well elsewhere (see our [roundup of LinkedIn automation tools](/blog/top-10-linkedin-automation-tools) for the "which tool" question). This guide focuses on the third, because it is the one no listicle actually shows you: the real flow, in code, on your own account. ## What you can automate: messages, InMail, and connection notes Before the code, get the routing right – *who* you can message depends on your relationship to them. | Message type | Who it reaches | Method | Notes | | --- | --- | --- | --- | | Standard message | People you are already connected to (1st-degree) | `sendMessage` | Text up to 1,900 characters | | Sales Navigator InMail | People you are **not** connected to | `nvSendMessage` | Needs a Sales Navigator seat and a subject line – credits and consumer mechanics in the [InMail guide](/guides/linkedin-inmail) | | Connection request + note | Someone you want to connect with | `sendConnectionRequest` | A single note field, not a sequence – automating the request itself is its own [connection-requests guide](/guides/how-to-automate-linkedin-connection-requests) | Two honest boundaries that pages blurring "reach anyone instantly" skip: a standard message only works once someone is a 1st-degree connection, and reaching a non-connection means either connecting first or spending a Sales Navigator InMail. There is no button that DMs a stranger for free. If the 1st/2nd/3rd labels themselves are the part you want pinned down, [degrees of connection](/guides/linkedin-connection-degrees) covers what each one allows – and why the badge alone does not tell you what to do next. ## How to automate the full flow (in code) Here is the branching loop with [Linked API](/), the [LinkedIn automation API](/) that runs these actions on your own account. Install the SDK (`npm install -S @linkedapi/node` or `pip install linkedapi`) and initialise the client with your tokens. The core decision is the **acceptance gate** – the step generic messaging relays skip. You check where you stand, and only message a first-degree connection; if you are not connected, you get connected first and message *after* the request is accepted. Automating the connection request itself – sending it, handling the `emailRequired` edge, withdrawing stale invites, and detecting acceptance – is its own lifecycle, covered in [How to Automate LinkedIn Connection Requests](/guides/how-to-automate-linkedin-connection-requests); here it is only the gate before a message. ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const NOTE = 'Hi! I work on LinkedIn tooling and would love to connect.'; // Once per account, before any outreach: enable inbox monitoring so replies are // captured (add nvSyncInbox for InMail replies). It only catches what arrives after. const sync = await linkedapi.syncInbox.execute({}); await linkedapi.syncInbox.result(sync.workflowId); async function reach(personUrl: string, text: string): Promise { // 1. Where do we stand? -> connected | pending | notConnected const check = await linkedapi.checkConnectionStatus.execute({ personUrl }); const { data } = await linkedapi.checkConnectionStatus.result(check.workflowId); // 2. Already connected -> message them directly. if (data?.connectionStatus === 'connected') { const sent = await linkedapi.sendMessage.execute({ personUrl, text }); await linkedapi.sendMessage.result(sent.workflowId); return; } // 3. Not connected -> get connected first, then message once accepted. // Sending the request, the emailRequired edge, and withdrawing stale invites // are their own lifecycle: /guides/how-to-automate-linkedin-connection-requests if (data?.connectionStatus === 'notConnected') { await linkedapi.sendConnectionRequest.execute({ personUrl, note: NOTE }); // Re-run checkConnectionStatus on a schedule (cron or webhook); // when it returns 'connected', call sendMessage. Do not busy-wait. } } ``` ```python from linkedapi import ( LinkedApi, LinkedApiConfig, CheckConnectionStatusParams, SendMessageParams, SendConnectionRequestParams, NvSendMessageParams, InboxPersonPollRequest, InboxPollRequest, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) NOTE = "Hi! I work on LinkedIn tooling and would love to connect." # Once per account, before any outreach: enable inbox monitoring so replies are # captured (add nv_sync_inbox for InMail replies). It only catches what arrives after. sync = linkedapi.sync_inbox.execute() linkedapi.sync_inbox.result(sync.workflow_id) def reach(person_url: str, text: str) -> None: # 1. Where do we stand? -> connected | pending | notConnected check = linkedapi.check_connection_status.execute( CheckConnectionStatusParams(person_url=person_url) ) data = linkedapi.check_connection_status.result(check.workflow_id).data # 2. Already connected -> message them directly. if data and data.connection_status == "connected": sent = linkedapi.send_message.execute( SendMessageParams(person_url=person_url, text=text) ) linkedapi.send_message.result(sent.workflow_id) return # 3. Not connected -> get connected first, then message once accepted. # Sending the request, the emailRequired edge, and withdrawing stale invites # are their own lifecycle: /guides/how-to-automate-linkedin-connection-requests if data and data.connection_status == "notConnected": linkedapi.send_connection_request.execute( SendConnectionRequestParams(person_url=person_url, note=NOTE) ) # Re-run check_connection_status on a schedule (cron or webhook); # when it returns "connected", call send_message. Do not busy-wait. ``` To reach someone you are **not** connected to without waiting, send a Sales Navigator InMail instead. This route has no acceptance gate, but it needs a Sales Navigator seat and a subject line – how InMail credits, refunds, and limits work is covered in the [LinkedIn InMail guide](/guides/linkedin-inmail). ```typescript const inmail = await linkedapi.nvSendMessage.execute({ personUrl, subject: 'Quick question about your team', text: 'Hi Dana, saw your team is hiring SDRs. Mind if I share how we help?', }); await linkedapi.nvSendMessage.result(inmail.workflowId); ``` ```python inmail = linkedapi.nv_send_message.execute( NvSendMessageParams( person_url=person_url, subject="Quick question about your team", text="Hi Dana, saw your team is hiring SDRs. Mind if I share how we help?", ) ) linkedapi.nv_send_message.result(inmail.workflow_id) ``` Finally, read the reply. Inbox monitoring, switched on with `syncInbox` at the top of the code before anything is sent, captures every new message from then on – including replies to InMail when you also run `nvSyncInbox`. Enable it first: a reply that arrives before monitoring is on is not captured. Then `pollInbox` with the person's `personUrl` returns the whole conversation with them, newest first, each message tagged with a `sender` of `us` or `them`, so you can detect a real response. Message times are whole minutes, so set `since` a few minutes before you sent your message, take the earliest `us` message in the result – the one you sent, not a later follow-up – and count a `them` message as the reply only if it is not earlier than that one. A standard message and a Sales Navigator InMail live in different threads, and `type` (`st` or `nv`) tells them apart. If you also need what was said before monitoring was enabled, load it once with `syncConversation` (or `nvSyncConversation`) – it then comes back in the same result. ```typescript // Later, on your schedule: everything with this person around and after your message. // since is a few minutes before you sent it - message times are whole minutes. const { data } = await linkedapi.pollInbox({ personUrl, type: 'st', since }); const messages = [...(data?.messages ?? [])].reverse(); // oldest first const sent = messages.find((m) => m.sender === 'us'); // the message you sent const reply = sent ? messages.find((m) => m.sender === 'them' && new Date(m.time) >= new Date(sent.time)) : undefined; // no `sent` yet: not known yet console.log(reply?.text); // InMail reply (Sales Navigator route): the same check with type 'nv'. const inmail = await linkedapi.pollInbox({ personUrl, type: 'nv', since }); ``` ```python # Later, on your schedule: everything with this person around and after your message. # since is a few minutes before you sent it - message times are whole minutes. result = linkedapi.poll_inbox( InboxPersonPollRequest(person_url=person_url, type="st", since=since) ) messages = list(reversed(result.data.messages)) if result.data else [] # oldest first sent = next((m for m in messages if m.sender == "us"), None) # the message you sent reply = ( next((m for m in messages if m.sender == "them" and m.time >= sent.time), None) if sent else None # no sent yet: not known yet ) if reply: print(reply.text) # InMail reply (Sales Navigator route): the same check with type "nv". inmail = linkedapi.poll_inbox( InboxPersonPollRequest(person_url=person_url, type="nv", since=since) ) ``` Prefer the shell? The [CLI](/cli/getting-started) runs the same flow, and the AI-agent surfaces – the [MCP server](/mcp/overview) and ready-made [skills](/skills) – expose it to Claude Code, Cursor, and Codex. If you are setting an agent up for the first time, [how to give an AI agent access to LinkedIn](/guides/linkedin-ai-agent) covers the connection choices and the first-run sequence. ```bash linkedin inbox sync # once per account, before any outreach linkedin connection status https://www.linkedin.com/in/john-doe # connected | pending | notConnected linkedin connection send https://www.linkedin.com/in/john-doe --note "Hi! Would love to connect." linkedin message send https://www.linkedin.com/in/john-doe "Thanks for connecting - quick question..." linkedin navigator message send https://www.linkedin.com/in/john-doe "Hi..." --subject "Quick question" linkedin inbox get --person https://www.linkedin.com/in/john-doe --json # the whole conversation, replies included ``` Where this flow ends is deliberate: it gets a message sent and the reply back into your system. *What* the follow-up should say, and *when* to send it, is outreach strategy – see our guide to [LinkedIn cold outreach](/guides/linkedin-cold-outreach-strategies-2026) for the copy, timing, and sequence design. For method-by-method reference, see the docs for [sending messages](/docs/sending-message), [connection requests and invitations](/docs/working-with-invitations), [checking connection status](/docs/checking-connection-status), and [monitoring the inbox](/docs/monitoring-inbox), or the SDK pages for [`sendMessage`](/docs/action-st-send-message?surface=sdk), [`nvSendMessage`](/docs/action-nv-send-message?surface=sdk), [`sendConnectionRequest`](/docs/action-st-send-connection-request?surface=sdk), [`checkConnectionStatus`](/docs/action-st-check-connection-status?surface=sdk), [`syncInbox`](/docs/action-st-sync-inbox?surface=sdk), [`pollInbox`](/docs/monitoring-inbox?surface=sdk), and [`syncConversation`](/docs/action-st-sync-conversation?surface=sdk). ![Diagram: the branching LinkedIn message automation flow. Entry asks who you are messaging. If already connected, send a message with sendMessage. If not connected, either send a connection request with sendConnectionRequest, poll checkConnectionStatus until it returns connected, then sendMessage – or send a Sales Navigator InMail with nvSendMessage, which has no acceptance gate. Both paths converge on reading the reply: for a single thread, syncInbox (or nvSyncInbox for a Sales Navigator thread) then pollInbox with the person's personUrl; for the whole inbox, syncInbox then pollInbox. Follow-up copy and cadence are handed off to the cold-outreach guide. A footer notes it runs on your own account, at a human pace, returning profile data, not emails.](/images/guides/linkedin-message-automation-flow.webp) ## Automate the whole inbox (monitor every reply) The loop above reads a reply when you already know the person. To automate messaging at the **inbox** level – catch every incoming message across all your conversations, then route, tag, or reply to it – poll the whole inbox rather than one person at a time. Enable it once per account with [`syncInbox`](/docs/action-st-sync-inbox?surface=sdk) (or [`nvSyncInbox`](/docs/action-nv-sync-inbox?surface=sdk) for the Sales Navigator inbox), then [`pollInbox`](/docs/monitoring-inbox?surface=sdk) returns every new message across every thread, each with a `sender` of `us` or `them` and two URLs for the other person: the hashed `personHashedUrl`, always present and what you reply to, and `personPublicUrl`, the public profile URL, which is `null` until that person has been resolved. `syncInbox` only switches monitoring on and returns; the messages are picked up afterwards by a sync that runs periodically, so expect the first ones on the next sync rather than the moment the call resolves. One disclosure worth knowing: when that sync finds new messages in a thread – including messages you send yourself – it opens the profile page of the other person in that thread to resolve their public URL – but only when Linked API does not already know it, so a person is visited once rather than on every message. Those visits are made from your own account and are visible to the people whose profiles are opened. A person's `personPublicUrl` stays `null` until their thread produces a message. ```typescript // Enable whole-inbox monitoring once per account. const sync = await linkedapi.syncInbox.execute({}); await linkedapi.syncInbox.result(sync.workflowId); // Poll only what arrived since your last run, using a cursor you persist. // Without `since`, pollInbox returns everything captured - so you would // reprocess (and re-reply to) old messages on every run. // `since` is inclusive and times are whole minutes, so the next run sees the newest // minute again: keep the ids already handled at the cursor and skip them. const { since, handledIds } = await loadCursor(); // your storage; { since: undefined, handledIds: [] } on first run const { data } = await linkedapi.pollInbox({ type: 'st', since }); const messages = data?.messages ?? []; for (const m of messages) { if (m.sender !== 'them' || handledIds.includes(m.id)) continue; // new inbound only const reply = await linkedapi.sendMessage.execute({ personUrl: m.personHashedUrl, text: 'Thanks for the reply!' }); await linkedapi.sendMessage.result(reply.workflowId); } // Advance the cursor to the newest message (pollInbox returns newest-first) and // remember the ids at that time - the next run gets all of them again. if (messages.length) { const newest = messages[0].time; const atNewest = messages.filter((m) => m.time === newest).map((m) => m.id); await saveCursor({ since: newest, handledIds: atNewest }); } ``` ```python # Enable whole-inbox monitoring once per account. sync = linkedapi.sync_inbox.execute() linkedapi.sync_inbox.result(sync.workflow_id) # Poll only what arrived since your last run, using a cursor you persist. # Without `since`, poll_inbox returns everything captured - so you would # reprocess (and re-reply to) old messages on every run. # `since` is inclusive and times are whole minutes, so the next run sees the newest # minute again: keep the ids already handled at the cursor and skip them. since, handled_ids = load_cursor() # your storage; (None, set()) on first run result = linkedapi.poll_inbox(InboxPollRequest(type="st", since=since)) messages = result.data or [] for m in messages: if m.sender != "them" or m.id in handled_ids: # new inbound only continue reply = linkedapi.send_message.execute( SendMessageParams(person_url=m.person_hashed_url, text="Thanks for the reply!") ) linkedapi.send_message.result(reply.workflow_id) # Advance the cursor to the newest message (poll_inbox returns newest-first) and # remember the ids at that time - the next run gets all of them again. if messages: newest = messages[0].time at_newest = {m.id for m in messages if m.time == newest} save_cursor(newest, at_newest) ``` On the shell it is `linkedin inbox sync` once (add `--nv` for the Sales Navigator inbox), then `linkedin inbox get --since `. Poll with an advancing `since`, or skip polling entirely and subscribe to [inbox webhook events](/docs/webhooks?surface=sdk#receiving-events) for push delivery. This stays the mechanics layer – detecting and retrieving the message; what an auto-reply should actually say still belongs to your [outreach strategy](/guides/linkedin-cold-outreach-strategies-2026). ## LinkedIn message and connection limits (staying safe) LinkedIn does not publish exact message or connection-request limits, so treat the numbers you see online as guardrails, not gospel, and expect them to move with your account's age and standing. Third-party trackers land in a similar ballpark. For **connection requests**, ZELIQ's 2026 automation guide suggests roughly 20-40 a day (100-200 a week), while PhantomBuster, as of its July 2026 update, puts free and Premium accounts nearer 100 a week. For **direct messages**, ZELIQ's 2026 guide suggests about 30-60 a day, and PhantomBuster, as of July 2026, cites roughly 100 a week on a free account and 150 on Premium – all unofficial observations, not published caps. Our own breakdowns of the [connection request limit](/guides/linkedin-connection-limit-2026) and [LinkedIn's limits](/guides/understanding-linkedin-limits) go deeper. The safer way to automate is to stay well inside those bounds on your own account. Linked API is built for exactly that: every action runs on **your own account through a human-paced cloud browser**, not a blast queue, and it surfaces LinkedIn's own limit signals instead of steamrolling them. Your code receives `messagingNotAllowed` when you are not connected or messaging is restricted, `noteLimitExceeded` when a free account runs out of personalized invitation notes, and `requestNotAllowed` when you approach LinkedIn's request limits – so you can back off automatically. It works within [our safety model](/safety), and it returns profile, company, and post data – **not** emails or phone numbers. ## When a no-code tool is the better choice Code is not always the answer. If you want a point-and-click campaign builder, a visual sequence editor, and a shared team inbox – and you never want to touch an SDK – a no-code tool (the Dripify / HeyReach / Waalaxy class) will get you live faster. Those products are a good fit when the standard "connect, then follow up on a schedule" campaign is all you need and a closed UI is acceptable. Linked API is the better choice when you want messaging as a primitive inside your own stack: triggered by events in your CRM, branched on your own logic, wrapped in your product, or driven by an AI agent. You trade a visual builder for full control and embeddability. Start from the [installation guide](/sdks/installation) and the [CLI quickstart](/cli/getting-started). And messaging is only one lane of automation – publishing content on a schedule is its own, covered in [How to Automate LinkedIn Posts](/guides/how-to-automate-linkedin-posts). ## Frequently Asked Questions (FAQ) #### Can you automate LinkedIn messages? Yes, for standard direct messages – but not with a native LinkedIn feature. LinkedIn only offers scheduled outbound in its paid Recruiter products; everyday Messaging has none. Automating standard outreach means using a tool or an API, like [Linked API](/), that performs the actions on your own account. #### Does LinkedIn have a messaging API? Yes, but not one most developers can reach. LinkedIn's official [Messages API](https://learn.microsoft.com/en-us/linkedin/shared/integrations/communications/messages) creates messages to first-degree connections or replies in an existing thread, and its documentation restricts usage "to approved partners, subject to limitations via API agreement". Approval alone would not buy automation either: LinkedIn requires each message to follow "a specific member action" and states that those "do not include an automated or scheduled event". Pages messaging is a separate integration again, covering a Company Page's inbox rather than your personal DMs. Sending and reading messages programmatically on your own account means an account-level API such as [Linked API](/). #### Is automating LinkedIn messages safe – will I get my account restricted? Any automation carries some risk, and how you use it decides how much. Aggressive volume and speed are what push accounts toward restriction, so the safer approach is to stay well within LinkedIn's limits and keep a human pace. Linked API runs each action on your own account through a human-paced cloud browser and surfaces LinkedIn's own limit signals – `messagingNotAllowed`, `noteLimitExceeded`, `requestNotAllowed` – so your code can back off before it hits a wall. #### Can you schedule LinkedIn messages? Not for standard direct messages – LinkedIn has no native scheduler for them. LinkedIn Recruiter and Recruiter Lite can schedule initial InMail, and Recruiter can schedule one automated follow-up, but that is a paid recruiting feature. For everything else you schedule the send in your own code or in a third-party tool. #### How do I send bulk or mass messages on LinkedIn? There is no safe "blast to thousands" button, and trying to fake one is how accounts get restricted. The scalable-but-safe approach is to loop over your list programmatically and send each message on your own account at a human pace, respecting LinkedIn's limits – which is exactly what the [flow above](/guides/how-to-automate-linkedin-messages) does. #### Can you message someone you're not connected to? Yes, two ways. Send a connection request and message them once they accept (a standard 1st-degree message), or send a **Sales Navigator InMail** with `nvSendMessage`, which reaches a non-connection directly but needs a Sales Navigator seat and a subject line. #### How many messages and connection requests can you send per day? LinkedIn publishes no exact cap. Third-party trackers put safe connection requests around 20-40 a day, and direct messages around 30-60 a day, per ZELIQ's 2026 guide; PhantomBuster, as of July 2026, cites nearer 100 messages a week on a free account. They are unofficial and move with your account standing, so treat them as guardrails and stay comfortably under them. See our [connection limit guide](/guides/linkedin-connection-limit-2026) for detail. #### Can you automate LinkedIn messages for free? You can send messages and connection requests on a free LinkedIn account, but free accounts hit tighter limits – for example, a capped number of personalized invitation notes (Linked API returns `noteLimitExceeded` when you reach it). Automation tools and APIs are paid products; Linked API starts at $49/mo (Core, billed annually), flat per seat. #### Can you automate your LinkedIn inbox and replies? Yes. Turn on whole-inbox monitoring once with `syncInbox` (or `nvSyncInbox` for the Sales Navigator inbox), then `pollInbox` returns every new message across all your conversations, each tagged with the hashed `personHashedUrl` to reply to, a `personPublicUrl` that carries the public profile URL once that person has been resolved, and a `sender` of `us` or `them` – so you can route, tag, or reply to incoming messages in code. Enabling only switches monitoring on; the messages are picked up afterwards by a sync that runs periodically. Prefer push over polling? Subscribe to inbox webhook events. On the CLI it is `linkedin inbox sync` once, then `linkedin inbox get`. #### Chrome extension vs cloud-based automation – which is safer? A cloud setup is generally the safer of the two: it runs independently of your own machine and browser session, at a controlled pace. A Chrome extension only runs while your computer and browser are on, and ties the automation directly to your everyday browsing session. An API on a cloud browser, like Linked API, gives you the cloud's consistency plus full control over pacing. --- Want to run the connect-to-reply flow on your own account, inside your own stack? [Start with Linked API](/pricing) – the same actions across the [API and SDKs](/sdks/installation), [CLI](/cli/getting-started), [MCP server](/mcp/overview), and ready-made [skills](/skills), on your own account and at a human pace. ## LinkedIn Boolean Search: Operators, Examples, and How to Run Searches at Scale (2026) LinkedIn boolean search combines keywords with a handful of operators – quotation marks for exact phrases, and **AND, OR, and NOT (typed in uppercase)** plus parentheses for grouping – to build precise searches for people and companies. It works in the standard search bar, in LinkedIn Recruiter, and in Sales Navigator, though each puts boolean in slightly different fields. This guide covers the operators with copy-paste examples, the differences between the three surfaces, the mistakes and limits that trip people up, and the part most boolean guides skip: how to run the same search programmatically at scale. > **The short version.** Five operators do the work: `"exact phrase"`, and **AND / OR / NOT in uppercase** plus **parentheses** to group. They go inside the field you're searching – the standard search box, or the Keywords/Title/Company fields in Recruiter and Sales Navigator – not as `title:`-style commands (those don't work). To run a search at scale, you build it in LinkedIn's interface, copy the results URL, and hand that URL to a search API. It runs the search you built – it is not a raw boolean engine, and it returns profile data, not emails. ## The LinkedIn boolean operators There are five, and they are the same across LinkedIn's search surfaces. (Reference: LinkedIn's own [Use Boolean search on LinkedIn](https://www.linkedin.com/help/linkedin/answer/a524335).) | Operator | What it does | Example | Rule | | --- | --- | --- | --- | | Quotation marks | Matches an exact phrase | `"product manager"` | LinkedIn ignores common words (and, or, the, by, in, with); quote a phrase to force them in | | AND | Requires all terms (narrows) | `finance AND CPA` | Type it in UPPERCASE | | OR | Matches any term (broadens) | `sales OR marketing` | Type it in UPPERCASE | | NOT | Excludes a term (narrows) | `developer NOT manager` | UPPERCASE, immediately before the term | | Parentheses | Groups logic, evaluated first | `VP NOT (assistant OR SVP)` | The only grouping symbol LinkedIn recognizes | Two rules save most of the headaches. First, **AND, OR, and NOT must be uppercase** – written in lowercase, LinkedIn reads them as ordinary keywords. Second, when you mix operators, LinkedIn evaluates them in a fixed order: **quotes, then parentheses, then NOT, then AND, then OR**. When in doubt, add parentheses to make your intent explicit. LinkedIn does **not** support wildcards (`*`), curly, square, or angle brackets, or the `+`/`-` operators – use AND and NOT instead. ## Where boolean search works: standard search, Recruiter, and Sales Navigator Boolean behaves the same, but *where* you type it differs by product. | Surface | Where boolean goes | Structured filters | | --- | --- | --- | | Standard search | The single search box at the top | Minimal; most advanced filters are gated behind Premium | | Recruiter / Recruiter Lite | The Keywords field, plus filter fields like Job titles, Location, Companies, Schools, Skills, Industries, and Spoken languages | 20+ filters | | Sales Navigator | The Keywords, Title, and Company fields | 30+ Lead and Account filters | One correction, because a lot of older guides get it wrong: **colon field-commands like `title:`, `company:`, or `school:` are not documented by LinkedIn and do not work** in standard search. Boolean today goes *inside* the named field you want to search – the Keywords box, or the dedicated Title, Company, and School fields in [Recruiter](https://www.linkedin.com/help/recruiter/answer/a415295) and Sales Navigator – not as a `field:value` prefix. LinkedIn's [Sales Navigator help](https://www.linkedin.com/help/linkedin/answer/a168061) confirms boolean in its Company, Title, and Keyword fields (that page is a few years old, so treat the finer field details as a guide rather than gospel). The practical takeaway: in the standard search bar you lean on boolean; in Recruiter and Sales Navigator you keep the boolean simple and let the structured filters do the heavy lifting. ## Boolean search examples you can copy Here are six strings that follow the rules above. Adapt the roles to your target; keep the syntax. - **Backend engineers, excluding recruiters** (tech recruiting): `"software engineer" AND (Java OR Kotlin OR Scala) AND (backend OR "back end") NOT recruiter` - **Sales VPs at software companies, minus junior roles** (SaaS prospecting): `("VP of Sales" OR "Vice President of Sales" OR "VP Sales") AND (SaaS OR software) NOT (assistant OR intern)` - **Individual-contributor developers only** (IC sourcing): `developer NOT (manager OR director OR contractor)` - **Advanced-degree data/ML people who use Python or R** (DS/ML recruiting): `("data scientist" OR "machine learning engineer" OR "ML engineer") AND (Python OR R) AND (PhD OR "Master's")` - **Marketing leaders in fintech, excluding freelancers** (exec prospecting): `(CMO OR "Chief Marketing Officer" OR "VP Marketing") AND (fintech OR "financial services") NOT freelance` - **ICU nurses, excluding students and retirees** (healthcare recruiting): `("registered nurse" OR RN) AND (ICU OR "intensive care") NOT (student OR retired)` Notice the pattern: quotes lock multi-word titles together, parentheses hold a group of synonyms joined by OR, and NOT trims out the roles you never want to see. ## Common boolean mistakes (and LinkedIn's limits) - **Lowercase operators.** `java and python` searches for the word "and." Capitalize AND, OR, NOT. - **Forgetting quotes on phrases.** `product manager` can match "product" and "manager" separately, and LinkedIn drops the stop words in between; `"product manager"` keeps it exact. - **Expecting field-commands to work.** `title:CMO` does nothing useful in standard search. Put the term in the Keywords box, or use the Title field in Recruiter or Sales Navigator. Colon commands like `intitle:` and `inurl:` belong to Google, not LinkedIn – searching LinkedIn profiles through Google is a separate technique, covered in the [X-ray search guide](/guides/linkedin-xray-search). - **Hitting the Commercial Use Limit.** On a free account, LinkedIn caps how much you can search each month once your activity looks like hiring or prospecting. Per LinkedIn's [help page](https://www.linkedin.com/help/linkedin/answer/a564226), it does not display the exact number of searches you have left, and the limit resets at the start of each calendar month. Recruiter and Sales Navigator raise or remove that ceiling. - **Expecting boolean in every filter.** Boolean only works in the fields that accept free text (Keywords, Title, Company). The dropdown and checkbox filters use their own logic. ## From boolean to scale: run your search via API A boolean search is a one-off in the browser. When you want to run it repeatedly, feed the results into a workflow, or search on behalf of a product, you move it to an API – and the clean way to do that is to **build the search in LinkedIn, then hand over its URL**. LinkedIn encodes your whole query (keywords, boolean, and filters) into the search results URL; [Linked API](/guides/linkedin-scraper-api) takes that URL as `customSearchUrl` and runs the same search on your own authenticated account. ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); // Build the boolean search in LinkedIn, copy the results URL, and run it at scale. // customSearchUrl runs exactly that search (it overrides term and filter). const search = await linkedapi.searchPeople.execute({ customSearchUrl: 'https://www.linkedin.com/search/results/people/?keywords=%22software%20engineer%22%20AND%20(Java%20OR%20Kotlin)', limit: 25, }); const { data: people } = await linkedapi.searchPeople.result(search.workflowId); for (const person of people ?? []) { console.log(person.name, person.headline, person.publicUrl); } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, SearchPeopleParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) search = linkedapi.search_people.execute( SearchPeopleParams( custom_search_url="https://www.linkedin.com/search/results/people/?keywords=%22software%20engineer%22%20AND%20(Java%20OR%20Kotlin)", limit=25, ) ) people = linkedapi.search_people.result(search.workflow_id).data for person in people or []: print(person.name, person.headline, person.public_url) ``` If you would rather describe the search in code than build it in the UI, use structured filters instead. One rule to know: a request needs **either a `term` or a `customSearchUrl`** – a `filter`-only call is rejected – so pass a keyword `term` alongside your `filter`: ```typescript const search = await linkedapi.searchPeople.execute({ term: 'engineer', filter: { position: 'Software Engineer', locations: ['United States'], industries: ['Software Development'], }, limit: 25, }); ``` ```python search = linkedapi.search_people.execute( SearchPeopleParams( term="engineer", filter={ "position": "Software Engineer", "locations": ["United States"], "industries": ["Software Development"], }, limit=25, ) ) ``` The [people search](/docs/searching-for-people) and [company search](/docs/searching-for-companies) endpoints work the same way, and the [Sales Navigator variants](/guides/linkedin-sales-navigator-api) (`nvSearchPeople`, `nvSearchCompanies`) add filters like years of experience and company revenue. Two honest limits: `term` is plain keyword text, not a boolean parser – for real boolean, use `customSearchUrl`; and results come back at your account's safe pace with profile URL, name, headline, and location, **not emails**. From a returned profile you can go deeper with the [profile scraper](/guides/linkedin-profile-scraper) or turn matches into [outreach](/guides/linkedin-cold-outreach-strategies-2026), always [within your account's limits](/safety). ![Diagram: build a boolean or filtered search in LinkedIn's standard search, Recruiter, or Sales Navigator, then copy the search results URL, pass it to the API as customSearchUrl, and receive structured JSON results (name, profile URL, headline, location) at your account's pace. A note reads: the API runs the search you built, not a raw boolean-string engine, and returns profiles, not emails.](/images/guides/linkedin-boolean-search-to-api.webp) ## Frequently Asked Questions (FAQ) #### What is boolean search on LinkedIn? It is a way to combine keywords with operators – quotation marks for exact phrases, and AND, OR, NOT, and parentheses – to build a precise search for people or companies instead of a single loose keyword. #### What boolean operators does LinkedIn support? Quotation marks for exact phrases, and AND, OR, and NOT (typed in uppercase) plus parentheses for grouping. LinkedIn does not support wildcards (`*`), brackets of any kind, or the `+`/`-` operators. #### Do field commands like `title:` still work on LinkedIn? No. LinkedIn does not document colon field-commands, and they do not work in standard search. Type your boolean inside the named field instead – the Keywords box in standard search, or the dedicated Title, Company, and School fields in Recruiter and Sales Navigator. Commands like `intitle:` work in Google, where they are used for [X-ray search](/guides/linkedin-xray-search) against public profiles. #### Does boolean search work in Sales Navigator and Recruiter? Yes. Recruiter accepts boolean in its Keywords field and several filter fields; Sales Navigator accepts boolean in its Keywords, Title, and Company fields, layered on top of its structured filters. In both, keep the boolean simple and let the filters narrow the rest. #### Why did my LinkedIn search stop showing results? Free accounts hit a monthly Commercial Use Limit once your searching looks like hiring or prospecting. LinkedIn does not state the exact number and it resets at the start of each calendar month. Recruiter and Sales Navigator raise or remove that limit. #### Can I run a LinkedIn boolean search programmatically? Yes. Build the search in LinkedIn's interface and pass its results URL to a search API – Linked API's `searchPeople` and `searchCompanies` accept it as `customSearchUrl` – or describe the search with structured filters plus a keyword term. It runs the search you built on your own account and returns profile data, not emails, and it is not a raw boolean-string engine. #### Is boolean search free on LinkedIn? In the standard search bar, yes, subject to the monthly commercial use limit. Recruiter and Sales Navigator add more boolean-capable fields and structured filters on their paid plans. --- Built the perfect boolean search and want to run it every day, at scale, from your own account? [Start with Linked API](/pricing) – paste your LinkedIn search URL, get clean JSON for every match, and wire it into your stack through the [API and SDKs](/sdks/installation), [CLI](/cli/getting-started), [MCP](/mcp/overview), and [skills](/skills). ## LinkedIn Post Scraper: How to Extract Posts, Reactions, and Comments via API (2026) A LinkedIn post scraper pulls the content and engagement of a post – its text, media, and reaction, comment, and repost counts – and, most usefully, the list of people who reacted and commented, each with their name, headline, and profile URL. There is no official LinkedIn API to read arbitrary post data, so every option is a scraper, and they differ a lot in reliability and account risk. This guide shows exactly what fields you can get, a real sample response, how to search posts by keyword, scrape one post, and pull a person's or company's recent posts or your own feed in code (Node and Python), and the part most guides skip: turning post engagers into a warm-lead list. > **The short version.** A post's content and its engager list – who reacted and who commented, with name, headline, and profile URL – are both extractable. Personal emails are not. Linked API finds posts by keyword through LinkedIn's own content search, reads known post URLs, a person's or company's recent posts, and your own home feed – all from your account, all bounded by a limit – and on big posts it samples engagement rather than dumping it whole. Chrome extensions and scripts run on *your own* account at machine speed and get it restricted; an API paces like a human and returns clean JSON. ## What data is in a LinkedIn post A post is really three records: the post itself, the reactions on it, and the comments on it. Here is what you get from each. | Group | Fields | | --- | --- | | Post | `url`, `time`, `type` (`original` / `repost`), `repostText`, `text` | | Media | `images`, `hasVideo`, `hasPoll` | | Counts | `reactionsCount`, `commentsCount`, `repostsCount` | | Reaction (per engager) | `actor` (`type`, `name`, `profileUrl` or `companyUrl`, `headline`, your `connectionDegree`), `type` (`like` / `celebrate` / `support` / `love` / `insightful` / `funny`) | | Comment (per commenter) | `author` (`type`, `name`, `profileUrl` or `companyUrl`, `headline`, your `connectionDegree`), `text`, `time`, `isReply`, `reactionsCount`, `repliesCount` | The reaction and comment records are the valuable part: each one carries the engager's **profile URL** – a durable key you can enrich or act on later. Note what is *not* here: no email, no phone. Post data is public-facing profile data, not contact data. ## A real scraped post The reactions and comments sections are opt-in – you ask for them, and you get typed JSON back instead of HTML to parse. ```json { "url": "https://www.linkedin.com/posts/jane-doe_growth-activity-7440011668937568257", "time": "2026-07-08T14:20:00Z", "type": "original", "repostText": null, "text": "We cut our onboarding drop-off by 40% this quarter. Three things that worked...", "images": ["https://media.linkedin.com/…/chart.png"], "hasVideo": false, "hasPoll": false, "reactionsCount": 214, "commentsCount": 37, "repostsCount": 9, "reactions": [ { "actor": { "type": "person", "name": "Mark Lee", "urn": null, "profileUrl": "https://www.linkedin.com/in/mark-lee", "headline": "VP Sales at Northwind", "viewerState": { "connectionDegree": "2nd" } }, "type": "insightful" }, { "actor": { "type": "person", "name": "Sara Kim", "urn": null, "profileUrl": "https://www.linkedin.com/in/sara-kim", "headline": "Head of RevOps at Acme", "viewerState": { "connectionDegree": "3rd+" } }, "type": "celebrate" } ], "comments": [ { "author": { "type": "person", "name": "David Ross", "profileUrl": "https://www.linkedin.com/in/david-ross", "headline": "Growth Lead at Beacon", "viewerState": { "connectionDegree": "2nd" } }, "text": "Which onboarding step moved the needle most?", "time": "2026-07-08T15:02:00Z", "isReply": false, "reactionsCount": 4, "repliesCount": 1 } ] } ``` ## Four ways to scrape LinkedIn posts, compared There is no official post API, so the real choice is between four methods that trade off scale, output, and the risk to your account. | Method | Scale | Output | Maintenance | Account-ban risk | | --- | --- | --- | --- | --- | | Manual copy-paste | A few a day | You retype it | None | None | | Chrome extension | Tens/day | A spreadsheet | Breaks on layout changes | High – acts on your account at machine speed | | DIY Python scraper | High, until blocked | Raw HTML you parse | You own selectors and proxies | High – headless on your account | | API on your own account | Steady, paced | Structured JSON (post + engagers) | Handled for you | Lower – paced like a human | One scope note before the code: this account-based approach reaches posts four ways – a **keyword search**, a **specific post URL**, a **person's or company's timeline**, and **your own home feed** – and every one of them runs through your own account at a human pace. That makes it the right tool for live, targeted reads, and the wrong one for a sweeping historical archive of everything ever posted about a topic across many keywords: a dataset or a large-scale scraper is built for that volume, with the usual scraping-risk and stale-versus-live trade-offs. For the account-based model across all entities, see the [LinkedIn scraper API guide](/guides/linkedin-scraper-api); for vacancies rather than content posts, the [LinkedIn jobs scraper](/guides/linkedin-jobs-scraper). ## Search LinkedIn posts by keyword Install the SDK: ```bash # Node.js npm install -S @linkedapi/node # Python pip install linkedapi ``` Then [search posts](/docs/searching-for-posts) the way you would in LinkedIn's content search – a keyword plus the same filters the search page offers: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const workflow = await linkedapi.searchPosts.execute({ term: 'onboarding drop-off', limit: 20, filter: { sort: 'latest', datePosted: 'pastWeek' }, }); const { data } = await linkedapi.searchPosts.result(workflow.workflowId); for (const post of data ?? []) { console.log(post.time, post.author?.name, post.reactionsCount, post.url); } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, SearchPostsParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.search_posts.execute( SearchPostsParams( term="onboarding drop-off", limit=20, filter={"sort": "latest", "date_posted": "pastWeek"}, ) ) for post in linkedapi.search_posts.result(workflow.workflow_id).data or []: print(post.time, post.author.name if post.author else None, post.reactions_count, post.url) ``` Each result carries the post's URL, author (name, headline and profile URL for a person), text, hashtags, mentions and reaction, comment and repost counts. Beyond `sort` and `datePosted`, the [`searchPosts` filters](/docs/action-st-search-posts?surface=sdk) cover content type, who posted it (you, your connections, people you follow), specific authors and companies, mentions, and the author's company or industry – or pass a `customSearchUrl` copied from a LinkedIn search you configured by hand. Two honest limits. LinkedIn widens a narrow query on its own and can return loosely related posts, so check results against your own criteria when an exact match matters. And a search returns at most 100 posts per call, from your own account's search. Run the same search daily with `datePosted: 'past24Hours'` and you are watching a keyword for people raising their hand – a buying-signal feed rather than complete social listening, because LinkedIn decides what its search returns. To see who engaged with a result, pass its `url` to the next step. ## Scrape a single post and its engagers [Fetch a post](/docs/retrieving-post-data) by URL – from a search result or anywhere else – asking for its reactions and comments: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const workflow = await linkedapi.fetchPost.execute({ postUrl: 'https://www.linkedin.com/posts/jane-doe_growth-activity-7440011668937568257', retrieveReactions: true, retrieveComments: true, reactionsRetrievalConfig: { limit: 50 }, commentsRetrievalConfig: { limit: 50, sort: 'mostRecent' }, }); const { data } = await linkedapi.fetchPost.result(workflow.workflowId); console.log(data?.text, data?.reactionsCount, data?.reactions?.length); // each reaction: { actor: { type, name, urn, profileUrl or companyUrl, headline }, type } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, FetchPostParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.fetch_post.execute( FetchPostParams( post_url="https://www.linkedin.com/posts/jane-doe_growth-activity-7440011668937568257", retrieve_reactions=True, retrieve_comments=True, reactions_retrieval_config={"limit": 50}, comments_retrieval_config={"limit": 50, "sort": "mostRecent"}, ) ) data = linkedapi.fetch_post.result(workflow.workflow_id).data print(data.text, data.reactions_count, len(data.reactions or [])) ``` The `limit` in each config is what keeps this honest on a viral post: you pull a bounded, recent slice of the engagement, not a guaranteed copy of every one of thousands of reactions. ## Scrape a person's or company's recent posts To watch what someone publishes, [fetch the person](/docs/retrieving-person-data) (or [company](/docs/retrieving-company-data)) with `retrievePosts` and a `postsRetrievalConfig`. The `since` date is the key to monitoring – pass your last run's timestamp and you get only what is new: ```typescript const workflow = await linkedapi.fetchPerson.execute({ personUrl: 'https://www.linkedin.com/in/jane-doe', retrievePosts: true, postsRetrievalConfig: { limit: 20, since: '2026-06-01T00:00:00Z' }, }); const { data } = await linkedapi.fetchPerson.result(workflow.workflowId); for (const post of data?.posts ?? []) { console.log(post.time, post.reactionsCount, post.url); } // swap fetchPerson for fetchCompany with a companyUrl to track a company's posts ``` ```python from linkedapi import FetchPersonParams workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/jane-doe", retrieve_posts=True, posts_retrieval_config={"limit": 20, "since": "2026-06-01T00:00:00Z"}, ) ) data = linkedapi.fetch_person.result(workflow.workflow_id).data for post in data.posts or []: print(post.time, post.reactions_count, post.url) ``` This returns a **recent, bounded set** of posts, not a full archive. Run it on a schedule (a nightly cron with `since` set to yesterday) and you have a monitor for a person's or company's new activity – the foundation for the use case below. ## Is there a LinkedIn feed API? Not an official one for reading a member's home feed: LinkedIn's [Activity Feed API is deprecated](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/activity-feed?view=li-lms-2024-05) and no longer grants access, and its current APIs cover publishing and managing your own organization's content rather than reading what your network posts. Two different jobs get called a "LinkedIn feed API", so here are both answers. **Showing your company's posts on a website** does not need an API at all: LinkedIn's "Embed this post" option gives you a snippet per post, and website-widget tools handle a live wall of them. **Reading your own home feed into code or an AI agent** – what your network is posting, as LinkedIn serves it to you – is what [`retrieveFeed`](/docs/action-st-retrieve-feed?surface=sdk) does: ```typescript const workflow = await linkedapi.retrieveFeed.execute({ limit: 20 }); const { data } = await linkedapi.retrieveFeed.result(workflow.workflowId); for (const post of data ?? []) { console.log(post.author?.name, post.feedContext, post.url); } ``` ```python from linkedapi import RetrieveFeedParams workflow = linkedapi.retrieve_feed.execute(RetrieveFeedParams(limit=20)) for post in linkedapi.retrieve_feed.result(workflow.workflow_id).data or []: print(post.author.name if post.author else None, post.feed_context, post.url) ``` Each post comes back in the standard post shape plus `feedContext` – LinkedIn's own line explaining why the post is in your feed, such as a connection reacting to it. The feed is algorithmic rather than chronological, so there is no `since`: each call returns what LinkedIn serves the account now, up to 100 posts. From the shell, `linkedin feed retrieve --limit 50 --json` does the same through the [CLI](/cli/commands#your-account). ## Turn post engagers into warm leads Here is the part worth building for. A person who reacted to or commented on a relevant post has raised their hand. You already have their profile URL from the post – so the pipeline is short: read the post's engagers, optionally enrich each live, and act. ```typescript // You already fetched the post with retrieveReactions + retrieveComments above. const leads = [ ...(data?.reactions ?? []).map(({ actor, type }) => ({ url: actor.type === 'person' ? actor.profileUrl : actor.companyUrl, name: actor.name, headline: actor.type === 'person' ? actor.headline : null, signal: type, })), ...(data?.comments ?? []).map(({ author }) => ({ url: author.type === 'person' ? author.profileUrl : author.companyUrl, name: author.name, headline: author.type === 'person' ? author.headline : null, signal: 'commented', })), ]; // From here: enrich one live with fetchPerson(url), or act with // sendConnectionRequest / sendMessage - all on your own account. ``` ![Pipeline diagram: a monitored LinkedIn post feeds into pulling its reactions and comments, which give each engager's profile URL, name, and headline; those flow into optional live enrichment via fetchPerson and then into outreach with a connection request or message. A note marks that you get profile URLs, not emails.](/images/guides/linkedin-post-engagers-pipeline.webp) Two honest limits keep this grounded. You get profile URLs and public headlines, **not emails** – so this builds a targeted list and a warm-intent signal, not a contact database. And it runs on your own account at a human pace, so it is built for steady, relevant lists, not for scraping every reactor on a 10,000-like post. For what to do with the list next, see our [cold outreach guide](/guides/linkedin-cold-outreach-strategies-2026); if your source list lives in Sales Navigator, see the [Sales Navigator guide](/guides/linkedin-sales-navigator-scraper). ## Can you get an engager's email? No, and it is the same story as any [profile scraper](/guides/linkedin-profile-scraper): emails and phone numbers are not shown on posts or profiles, so a scraper reading LinkedIn cannot return them. What you get is the engager's profile URL and headline. To attach a verified email, match that person against a separate, consented B2B enrichment provider – a different product from post scraping, and the data does not come off LinkedIn. ## Is scraping LinkedIn posts legal, and will my account get banned? Post content and public engagement are largely visible data, and courts have treated scraping genuinely public data more leniently than accessing private data – but public is not a free pass. Personal data still falls under GDPR and CCPA, and LinkedIn enforces against extraction operations directly. We break down the full legal picture in our [guide to scraping LinkedIn](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers). The ban risk is highest exactly where most people start: a Chrome extension or headless script driving your own account too fast. Linked API takes the account-based path – it runs on your own authenticated account through a human-paced cloud browser, within built-in workflow limits – and returns maintained, structured JSON, so you are not rewriting selectors every time the feed changes. Pace like a human, stop on any warning, and keep contact data to consented sources. Read our [safety model](/safety) before you scale. ## Frequently Asked Questions (FAQ) #### What data can you scrape from a LinkedIn post? The post's content (text, media flags), its reaction, comment, and repost counts, and the list of people who reacted and commented – each with name, headline, and profile URL, plus the reaction type or comment text. Personal emails and phone numbers are not available. #### Can I see who liked or commented on a LinkedIn post? Yes. Requesting reactions returns each reactor's profile URL, name, headline, and reaction type (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`); requesting comments returns each commenter's URL, name, headline, and comment text. This is what makes a post a lead source. #### Can I get the email of someone who engaged with a post? No. LinkedIn does not expose personal emails on posts or profiles, so you get profile URLs, not contacts. Match those profiles against a consented enrichment provider if you need verified emails. #### Can I scrape a person's recent LinkedIn posts? Yes – fetch the person with `retrievePosts` and a `postsRetrievalConfig` (`limit`, optional `since`). It returns a bounded recent set of their posts, not a guaranteed full archive; the same works for a company via `fetchCompany`. #### Can I monitor a person's or company's posts for new activity? Yes. Pass a `since` date in `postsRetrievalConfig` to pull only posts published after your last run, and schedule the call (for example, a nightly cron job). That turns the fetch into a monitor for new posts. The publishing direction – scheduling and automating your own posts – is covered in [How to Automate LinkedIn Posts](/guides/how-to-automate-linkedin-posts). #### Can Linked API search LinkedIn posts by keyword? Yes – since September 2026. `searchPosts` runs LinkedIn's content search on your own account with a keyword and the same filters the search page offers (sort, date posted, content type, author, mentions), and returns up to 100 posts per call as structured JSON. LinkedIn can widen narrow queries, so check results against your own criteria when an exact match matters. #### Is there an official LinkedIn API for reading post data? No. LinkedIn's official APIs cover your own content and approved partner use cases; there is no public API to read arbitrary posts and their engagement. Every "post scraper" you see is a scraping or account-based product. #### Is scraping LinkedIn posts legal? Reading public post data is treated more leniently than accessing private data, but personal data still falls under GDPR and CCPA. See our [legal breakdown](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers). #### How many posts can I scrape per day? There is no universal number – it depends on your account's age and history and on staying within a safe daily pace. An account-based API paces the work like a human rather than bursting; treat volume as bounded, not a firehose. --- Need LinkedIn post data and the people behind the engagement, without running an extension or a fragile script? [Start with Linked API](/pricing) – search posts by keyword, read posts, reactions, and comments, and pull your own feed – from your own account, as structured JSON, through the [API and SDKs](/sdks/installation), [CLI](/cli/getting-started), [MCP](/mcp/overview), and [skills](/skills). ## LinkedIn Connections API and Export: Get Your Connections Manually or via API (2026) Exporting your LinkedIn connections gets your network out of LinkedIn and into a spreadsheet, a CRM, or your own product. There are two honest ways to do it, and they answer different needs. The manual way – LinkedIn's own data export – hands you a one-time CSV in a few clicks, and it is the right answer if you just want the file. If you need your connections kept up to date in a CRM or product, there is no one-click tool for that; you pull them as JSON through an API on your own schedule. This guide covers both: the exact current steps to download the file, what fields you actually get (email is included only if the connection shared it), and how to build a continuous sync when a one-time export is not enough. > **The short version.** The manual way is LinkedIn's **Data Export**: Me → Settings & Privacy → Data privacy → Get a copy of your data → "Download larger data archive, including connections…" → Request archive, then download the `Connections.csv` from the email. You get a one-time snapshot of your 1st-degree connections, with an email address only for those who chose to share it. That is the right answer for a one-time download. If you need your connections **kept in sync** – upserted into a CRM or product on a schedule – there is no built-in export for that; you pull them as JSON through an account-based API like [Linked API](/pricing) and update your system yourself. ## How to export your LinkedIn connections (step by step) Here is the route [LinkedIn currently documents](https://www.linkedin.com/help/linkedin/answer/a566336/export-connections-from-linkedin): ![The manual LinkedIn connections export flow: Settings and Privacy, then Data privacy, then Get a copy of your data, then the larger data archive option, then Request archive, then download the CSV from the email](/images/guides/export-linkedin-connections-steps.webp) 1. Click **Me** (your photo, top right) → **Settings & Privacy**. 2. Open **Data privacy**. 3. Click **Get a copy of your data**. 4. Choose **"Download larger data archive, including connections, verifications, contacts, account history, and information we infer about you based on your profile and activity."** 5. Click **Request archive** and confirm your password. 6. LinkedIn emails you when the archive is ready. Download it and open **`Connections.csv`**. Two things to know before you start. You can only export your **1st-degree connections** – [LinkedIn notes](https://www.linkedin.com/help/linkedin/answer/a566336/export-connections-from-linkedin) you "currently can't export a list of your contacts that aren't 1st-degree connections." And if your account still shows a granular "Connections" option, that works too; the larger-archive option above is the route LinkedIn currently documents. ## What you get in the export (and what you don't) The `Connections.csv` gives you one row per 1st-degree connection, typically with these columns: - **First name** and **last name** - **LinkedIn profile URL** - **Company** – as of the time you export - **Position** – job title - **Connected on** – the date you connected, in MM/DD/YYYY format - **Email address** – but [only for connections who allowed it](https://www.linkedin.com/help/linkedin/answer/a566336/export-connections-from-linkedin), so many rows have none What you will not get: anyone beyond your 1st degree, a personal email for every connection, or any ongoing updates. The file is a snapshot as of the moment you export it, and the only way to refresh it manually is to export again. If you need it to stay current, use the API below. ## Is there a LinkedIn connections API? Not a self-serve one. LinkedIn's official [Connections API](https://learn.microsoft.com/en-us/linkedin/shared/integrations/people/connections-api) exists, but it is restricted to LinkedIn-approved developers with permissions such as `r_1st_connections`, so for most teams it is simply not available. The route that is open to everyone is account-based: Linked API's [`retrieveConnections`](/docs/action-st-retrieve-connections) returns your 1st-degree connections as structured JSON through a dedicated cloud browser on your own account, which is what the continuous sync below is built on. ## How to export your connections programmatically (build a continuous sync) The manual export is a snapshot. If you want your connections to stay current in a CRM or product, run the export in code on a schedule instead. Linked API's `retrieveConnections` returns your connections as structured JSON through your own account, and its `since` parameter returns only connections you made on or after a timestamp – so each scheduled run picks up the **new connections** since the last one. Reach it through the REST API, the [Node and Python SDKs](/docs/action-st-retrieve-connections?surface=sdk), or the shell [CLI](/cli); the same engine is available to an AI agent through the [MCP server](/mcp), the AI-agent-friendly CLI, or [ready-made skills](/skills). ![A continuous connection-sync pipeline: a cron scheduler triggers retrieveConnections with a since timestamp through the API, SDK, or CLI; Linked API runs it on your own account and returns new connections as JSON, which you upsert into your CRM or product](/images/guides/export-linkedin-connections-pipeline.webp) ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); // Run this on a cron; persist lastRun (ISO date) between runs const lastRun = process.env.LAST_RUN_ISO; const workflow = await linkedapi.retrieveConnections.execute({ since: lastRun, limit: 500 }); const { data: connections } = await linkedapi.retrieveConnections.result(workflow.workflowId); for (const c of connections ?? []) { // Upsert each new connection into your CRM console.log(c.name, '-', c.publicUrl, '-', c.connectedAt); } ``` ```python from linkedapi import LinkedApi, LinkedApiConfig from linkedapi.types import RetrieveConnectionsParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) # Run this on a cron; persist last_run (ISO date) between runs last_run = "2026-06-01T00:00:00Z" workflow = linkedapi.retrieve_connections.execute( RetrieveConnectionsParams(since=last_run, limit=500) ) connections = linkedapi.retrieve_connections.result(workflow.workflow_id).data or [] for c in connections: # Upsert each new connection into your CRM print(c.name, "-", c.public_url, "-", c.connected_at) ``` Run that on a schedule, persist the last run time, and upsert each new connection into your CRM. Two honest limits: `since` returns newly made connections, not a full change-feed – it will not report profile edits or connections that were removed, so treat it as an append-and-update sync – and, like the manual export, the API returns no personal email addresses. It runs on your own account, bounded by your account's [daily limits](/docs/core-concepts#limits-management). Grab your tokens from the [installation guide](/sdks/installation), and see [retrieving connections](/docs/action-st-retrieve-connections) and [managing existing connections](/docs/managing-existing-connections) for the full contract. Today you poll for the workflow result, as shown above. **Webhook delivery is rolling out shortly** – a `workflow.completed` event sent to your own endpoint, so you can receive the result without polling. We will update this guide when it lands; until then, see [executing workflows](/docs/executing-workflows) for the result model. ## Manual export vs API sync: which do you need? | | Manual Data Export | Account-based API (`retrieveConnections`) | | --- | --- | --- | | Output | One-time CSV | Structured JSON on demand | | Freshness | Snapshot when you export | On demand / scheduled polling; `since` returns connections made on or after a timestamp | | Keep a CRM updated | Re-download by hand | Scheduled sync you build | | Emails | Only if the connection shared it | No email field | | Setup | A few clicks | API tokens + code | | Best for | A one-time download | Continuous sync into a product or CRM | If you need the file once, export it. If you need your network to stay current inside a system, sync it with the API. ## Frequently Asked Questions (FAQ) #### How do I export my LinkedIn connections? Use LinkedIn's Data Export: Me → Settings & Privacy → Data privacy → Get a copy of your data → "Download larger data archive, including connections…" → Request archive, then download `Connections.csv` from the email LinkedIn sends. It is a one-time snapshot of your 1st-degree connections. #### Can I export my LinkedIn connections with email addresses? Only partly. LinkedIn includes an email address only for connections who allowed their connections to see or download it, so many rows have none. The API route returns no personal emails at all. If email coverage matters, do not count on having one for your whole list. #### Where is the option to download my connections? It lives under Settings & Privacy → Data privacy → Get a copy of your data, via the "Download larger data archive, including connections…" option. That is LinkedIn's currently documented route; if a granular "Connections" checkbox appears in your account, that works too. #### Can I export contacts that aren't 1st-degree connections? No. LinkedIn states you currently can't export a list of contacts that aren't 1st-degree connections. The export covers your direct connections only. #### How do I export LinkedIn connections to a CSV or Excel? The Data Export archive already contains a `Connections.csv` – open it directly in Excel, Google Sheets, or any tool that reads CSV. There is no separate "export to Excel" step. #### Can I export LinkedIn connections to my CRM automatically? Yes, with an API. Schedule `retrieveConnections({ since })` on a cron via the API, SDKs, or CLI, and upsert new connections into your CRM. You set the cadence, because there is no "new connection" push event – LinkedIn does not notify third parties when your network changes. Webhook delivery of the result is rolling out shortly, and we will update this guide when it is available. #### Is there a LinkedIn connections API? LinkedIn does not offer a self-serve, general-access API for exporting your connections. A [restricted Connections API](https://learn.microsoft.com/en-us/linkedin/shared/integrations/people/connections-api) exists for LinkedIn-approved developers (with permissions like `r_1st_connections`), but most teams cannot use it. For product or CRM sync, an account-based API like Linked API retrieves your connections as JSON through your own account – see the [retrieve-connections docs](/docs/action-st-retrieve-connections). --- Need your connections to stay current in your product, not just a CSV you re-download? [Start with Linked API](/pricing) to pull them as JSON on your own schedule, or see how the account-based model works across LinkedIn data in our [LinkedIn scraper API guide](/guides/linkedin-scraper-api). ## LinkedIn Sales Navigator Scraper: How to Export Leads to CSV (2026) You have built the search, saved the list, and now you want the rows in a spreadsheet. That is the job a LinkedIn Sales Navigator scraper exists to do. Which one fits that job, what does it actually export, and where does each route stop? This guide answers all three, starting with what Sales Navigator itself will and will not give you. > **The short version.** Sales Navigator does not export leads or accounts to CSV or XLS natively – LinkedIn says so in its own help centre – which is why an entire tool category exists. Your realistic options are a browser extension for a quick list from the search you are looking at, a dedicated export tool like Evaboot or Scrupp that runs your saved search through your own session and returns a cleaned CSV, a cloud actor or your own script for custom bulk work you will maintain, or a no-account dataset like Bright Data when you need public data at a scale no single seat should touch. None of them acts on the data, and they refresh differently: an extension or export tool gives you a file you rerun by hand, an actor runs on a schedule you maintain, and a dataset refreshes on its vendor's cycle. If the export is not the end of the job, you want an API instead. ## Can Sales Navigator export leads natively? No. LinkedIn states it plainly in its [Sales Navigator help centre](https://www.linkedin.com/help/sales-navigator/answer/a102031): > "LinkedIn currently doesn't offer the option to export account and lead information from Sales Navigator into a CSV or XLS file." Two things get mistaken for a native export, and neither one is: - **CRM Sync on Advanced Plus.** The same help page notes that Advanced Plus users can sync lead and account information between Sales Navigator and Salesforce. That is a CRM synchronisation, not a file you download, and it requires the top tier plus a Salesforce instance. - **The usage report export.** Sales Navigator admins can export a usage report covering seat activity. That is licence administration data, not your leads. So when a tool advertises "Sales Navigator export", it is not unlocking a hidden feature. It is reading the search on your behalf and building the file itself. ## What a Sales Navigator export actually produces Whatever route you take, the ceiling is what Sales Navigator itself renders. A row typically carries the lead's name, headline or position, company, and location, plus the profile URL that identifies them. Two things a Sales Navigator export does **not** carry, regardless of tool: - **Verified email addresses and phone numbers are not Sales Navigator fields.** Export tools that advertise emails run a separate enrichment step against other data sources after the export, and coverage and accuracy vary by provider. - **Anything behind a filter you did not apply.** The export inherits your search, so the quality of the file is decided before you export anything. Search scope also caps the job: a single Sales Navigator search surfaces a bounded result set, so very large pulls mean splitting the search rather than exporting harder. Our [Boolean search guide](/guides/linkedin-boolean-search) covers building searches that segment cleanly. ## The four extraction routes, compared Capabilities below are as described on each named vendor's live product and help pages. | Route | How it works | Owned seat required | Refresh model | Whose session runs it | Best for | | --- | --- | --- | --- | --- | --- | | **Browser extension** | Reads the Sales Navigator page open in your logged-in browser | Yes | Manual, you rerun it | Yours | A quick list from a search you are already looking at | | **Export tool** (Evaboot, Scrupp) | Runs your saved search through your own account, cleans the rows, returns a file, and offers email enrichment as a separate step | Yes | Manual, one export per run | Yours | A tidy bulk CSV of a saved search | | **Cloud actor or DIY script** (Apify, GitHub projects) | Runs an actor or your own code; cookie-based actors use your session, some run cookie-free against public pages | Only if cookie-based | Scheduled or on demand, maintained by you | Yours if cookie-based, the provider's if not | Custom bulk work you are willing to maintain | | **No-account dataset** (Bright Data) | Serves pre-collected public data at scale | No | Vendor-refreshed, so records can lag | Neither, it never touches your seat | Volume with no owned Sales Navigator seat | The distinction the tool pages blur is **whose session does the work**. A browser extension and an export tool both run on your own logged-in Sales Navigator session, so the activity sits on your account. A cloud actor may use your session cookies, in which case the same applies, or run cookie-free against public pages, in which case it never sees your Sales Navigator searches at all. A dataset never touches your account, and equally cannot see anything your seat can see. Pace matters on every route that uses your own session: a bulk extension pulling a large search as fast as it can render is a very different activity profile from a paced export. We compare the export-tool options in detail in our [Evaboot alternatives guide](/blog/evaboot-alternatives). ## When you need an API instead An export tool is the right answer when the file is the deliverable. It stops being the right answer when the export is step one. If you need to search, pull a field, branch on it, message the lead, and pick the reply back up as a repeatable workflow inside your own product, you want programmatic access to your seat rather than a CSV. That is a different question with a different answer, including what LinkedIn's own partner-gated API does and does not cover. See our [Sales Navigator API guide](/guides/linkedin-sales-navigator-api). ## Frequently Asked Questions (FAQ) #### Can you export leads from Sales Navigator to CSV? Not natively. LinkedIn's help centre states it does not offer export of account and lead information to CSV or XLS. Every CSV you have seen from Sales Navigator was produced by a browser extension, an export tool, or a script reading the results on your behalf. #### What is the best Sales Navigator export tool? It depends on what ends the job. For a clean CSV of a saved search with optional email enrichment, dedicated export tools like Evaboot and Scrupp are built for exactly that. For a quick grab from a search you are viewing, an extension is faster. For volume with no owned seat, a dataset provider fits better. For a repeatable workflow rather than a file, none of them is the answer. #### Do I need a Sales Navigator subscription to export leads? For any route that runs through your own session, yes. Extensions, export tools, and cookie-based actors all read what your seat can see, so the seat is the prerequisite. No-account datasets are the exception, because they never use your account and correspondingly cannot see your searches. What that seat costs, and which tier it needs to be, is covered in our [Sales Navigator cost guide](/guides/linkedin-sales-navigator-cost). #### Does a Sales Navigator export include email addresses? Not from Sales Navigator itself. Email is not a field the product exposes in a lead export. Tools advertising emails run a separate enrichment step against other sources afterwards, and coverage varies by provider. #### How many leads can I export at once? A single Sales Navigator search returns a bounded result set, so the practical limit is the search, not the tool. Large pulls mean splitting one broad search into several narrower ones, which usually improves the list anyway. #### What is the difference between an export tool and an account-based API? An export tool produces a snapshot and the job ends when the file downloads. An account-based API exposes Sales Navigator actions as composable primitives on your own seat, so you can build a workflow that reads and acts repeatedly from inside your own code. ## Get the file, or build the workflow If a CSV is what you need, pick the route above that matches how the job ends. If the export is only the first step and you want search, fetch, and outreach running as one repeatable workflow on your own Sales Navigator seat, that is what [Linked API](/pricing) does – see the [Sales Navigator API guide](/guides/linkedin-sales-navigator-api) for how programmatic access actually works. *Facts verified 29 July 2026 – native export availability checked against LinkedIn's Sales Navigator help centre, and tool capabilities checked against their live pages, on that date.* ## LinkedIn Jobs API: What Exists, and How to Get Job Data via API (2026) There is no official LinkedIn API for searching or reading job postings. LinkedIn's only jobs API is a partner-gated tool for *publishing* jobs, so every "LinkedIn jobs API" you find is really a scraper or a dataset. The upside: job postings are largely public, which makes them one of the more accessible things to pull from LinkedIn. This guide covers what job data you can actually get, a real Python example against LinkedIn's public endpoints (and where it breaks), the search-then-enrich pattern that returns full detail as clean JSON, and how to monitor new postings for hiring signals. > **The short version.** There is no public LinkedIn jobs API. LinkedIn's only jobs API is a partner-gated tool for *posting* jobs, so every "LinkedIn jobs API" on the market is a scraper or a dataset. Job postings are public, which makes them one of the easier things to collect – but hand-rolled scrapers break at scale, and it is still automated access under LinkedIn's terms. The reliable pattern: search jobs through an account you own, open each one for the full record, get structured JSON. ## Does LinkedIn have a jobs API? Not the kind you want. LinkedIn's official [Job Posting API](https://learn.microsoft.com/en-us/linkedin/talent/job-postings/api/overview) exists only for approved partners to *post* jobs through applicant-tracking and job-distribution integrations, and LinkedIn is not accepting new partners for it. There is no public, self-serve API to search or read job listings. So when a tool advertises a "LinkedIn Jobs API," it is a scraping or dataset product wrapping LinkedIn's public pages, not an official endpoint. That is fine – it is how the entire category works – but it changes how you should think about reliability and compliance: you are reading public pages, not calling a sanctioned API. ## What data you can get from a LinkedIn job A single posting carries a fairly complete record. Grouped by what it tells you: ![Anatomy of a scraped LinkedIn job: identity, company, role, compensation, and demand-signal fields](/images/guides/linkedin-job-data-fields.webp) The full per-job object looks like this: ```json { "jobId": "4416248954", "jobUrl": "https://www.linkedin.com/jobs/view/4416248954/", "title": "Senior Product Manager", "companyName": "Example Company", "companyUrl": "https://www.linkedin.com/company/example-company", "location": "San Francisco, CA", "workplaceType": "remote", "employmentType": "Full-time", "postedDate": "1w", "applicantsCount": 84, "salary": { "currency": "usd", "minAmount": 140000, "maxAmount": 180000, "period": "yearly" }, "description": "Example job description text.", "applyUrl": "https://www.linkedin.com/jobs/view/4416248954/apply/", "easyApply": true } ``` Two honest caveats: `salary` only appears when the posting shows pay (driven by pay-transparency laws), and `applicantsCount` is approximate and varies by posting and region. Everything else is reliably present. ## Method 1: DIY with Python LinkedIn serves logged-out job data through public "guest" endpoints, which is why a basic Python scraper works with no account and no cookies: - **Search/list:** `https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=...&start=0` (paginate in steps of 25) - **Per-job detail:** `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/{jobId}` A minimal scraper over the search endpoint: ```python import requests from bs4 import BeautifulSoup # LinkedIn's public, logged-out jobs endpoint - there is no official jobs API url = "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search" params = {"keywords": "product manager", "location": "San Francisco", "start": 0} res = requests.get(url, params=params, headers={"User-Agent": "Mozilla/5.0"}) soup = BeautifulSoup(res.text, "html.parser") for card in soup.select("li"): title = card.select_one("h3.base-search-card__title") company = card.select_one("h4.base-search-card__subtitle") if title and company: print(title.get_text(strip=True), "|", company.get_text(strip=True)) # Paginate by increasing "start" in steps of 25. ``` **Where this breaks.** The guest endpoint is generous until it isn't. You will hit `429 Too Many Requests` after roughly ten pages from one IP, so any real volume needs rotating proxies. Fields silently drop out (salary and applicant counts are inconsistent), the HTML changes and your selectors rot, and the most popular open-source LinkedIn job libraries now note that their anonymous mode is no longer maintained. It is great for a one-off pull, painful as a pipeline. We cover why hand-rolled scrapers decay in the [scraping guide](/guides/how-to-scrape-linkedin#why-diy-headless-scraping-breaks). ## Method 2: Scraping APIs and datasets If you would rather not maintain a scraper, two options handle the proxies for you: **real-time scraping APIs** (Apify job actors, Bright Data) that take a search or a job URL and return JSON, and **job datasets** (Coresignal, TheirStack) for bulk historical postings. The trade-off is the usual one – datasets are cheap at scale but stale, real-time is fresh but priced per request. We compare these models in the [LinkedIn scraper API guide](/guides/linkedin-scraper-api); to read a person's or company's posts and who engaged with them, see the [LinkedIn post scraper guide](/guides/linkedin-post-scraper). ## Method 3: Account-based API (search, then enrich) The pattern most teams actually want is **search-then-enrich**: a search returns a list of thin job cards, and opening each job returns the full posting. With [Linked API](/) you run this through your own account in a cloud browser and get structured JSON back, with no proxies or CAPTCHAs to manage. ![The search-then-enrich pipeline: st.searchJobs to st.doForJobs to st.openJob to structured JSON](/images/guides/linkedin-jobs-scraper-pipeline.webp) As a workflow, you [search](/docs/searching-for-jobs), iterate over each result, and [open every job](/docs/retrieving-job-data) for its details: ```json { "actionType": "st.searchJobs", "term": "product manager", "limit": 25, "location": "San Francisco, California, United States", "filter": { "datePosted": "pastWeek" }, "then": { "actionType": "st.doForJobs", "then": { "actionType": "st.openJob", "basicInfo": true } } } ``` The same workflow through the SDK, via [`customWorkflow`](/sdks/custom-workflow): ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const workflow = await linkedapi.customWorkflow.execute({ actionType: 'st.searchJobs', term: 'product manager', limit: 25, location: 'San Francisco, California, United States', filter: { datePosted: 'pastWeek' }, then: { actionType: 'st.doForJobs', then: { actionType: 'st.openJob', basicInfo: true }, }, }); const { data } = await linkedapi.customWorkflow.result(workflow.workflowId); console.log(data); ``` ```python from linkedapi import LinkedApi, LinkedApiConfig linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.custom_workflow.execute( { "actionType": "st.searchJobs", "term": "product manager", "limit": 25, "location": "San Francisco, California, United States", "filter": {"datePosted": "pastWeek"}, "then": { "actionType": "st.doForJobs", "then": {"actionType": "st.openJob", "basicInfo": True}, }, } ) result = linkedapi.custom_workflow.result(workflow.workflow_id) print(result.data) ``` `st.searchJobs` supports the same filters as the LinkedIn jobs UI – `datePosted`, `experienceLevels`, `employmentTypes`, `workplaceTypes`, `companies`, `industries`, `easyApply`, and more (see [`st.searchJobs`](/docs/action-st-search-jobs)). One thing to know upfront: LinkedIn is rolling out an [AI-powered jobs search](/docs/searching-for-jobs) alongside the classic one, it offers fewer refinements, and which version an account has is LinkedIn's decision – so a few of the filters above are not available on every account. The honest caveat: this runs on your own account, so throughput is bounded by your account's daily limits and it is automated access under LinkedIn's terms. Human-like pacing keeps the account healthy, but it is not a bulk firehose – for millions of historical postings, a dataset is the better tool. ## How to monitor new job postings The highest-value jobs use case is freshness, and it is the one almost no guide covers. LinkedIn's native job alerts are a delayed daily digest; for real-time signals you poll. Filter the search by recency and dedupe by `jobId`: ```json { "actionType": "st.searchJobs", "term": "account executive", "limit": 50, "location": "United States", "filter": { "datePosted": "past24Hours", "companies": ["Example Company"] } } ``` Run that search on a schedule, keep a set of `jobId`s you have already seen, and treat anything new as a fresh posting. A new opening for a role you sell into is a buying signal; a competitor's new hire is a market signal. This is the pattern behind most "hiring intent" data products – and you can run it yourself. ## What you can build - **Recruiting and sourcing** – pull matching roles with company and apply URL, refreshed daily. - **Sales hiring-signals** – new postings mean teams are growing and budgets are moving; route them to your CRM. - **Market and compensation research** – aggregate salary ranges and titles across a sector or competitor set. - **Job-board aggregation** – keep a fresh, deduped feed of relevant roles. - **Monitoring** – alert the moment a target company opens a role. ## Is it legal to scrape LinkedIn jobs? Job postings are largely public – the guest endpoints serve logged-out data – and in the US, the Ninth Circuit affirmed a preliminary injunction in [hiQ Labs v. LinkedIn (9th Cir. 2022)](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/18/17-16783.pdf), finding hiQ had raised serious questions that scraping publicly available data likely does not violate the Computer Fraud and Abuse Act. But public is not a free pass. LinkedIn enforces against extraction operations with blocks and lawsuits, and any personal data you touch falls under GDPR and CCPA. Jobs are lower-risk than personal profiles because the data is corporate and public, but the same rules apply. We break down the full model – computer-access law, privacy, and enforcement – in our [guide to scraping LinkedIn](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers). This is not legal advice. ## Limits and staying unblocked A DIY scraper against the guest API gets rate-limited fast, often by around the tenth page from a single IP, which is why proxies become mandatory at any volume. An account-based run is instead bounded by your own account's [daily limits](/docs/core-concepts#limits-management). Either way, the rule is the same: pace like a human, and stop the moment you see a CAPTCHA or a restriction notice rather than pushing through. ## Frequently Asked Questions (FAQ) #### Does LinkedIn have a jobs API? Only a partner-gated Job Posting API for *publishing* jobs, and LinkedIn is not accepting new partners for it. There is no public, self-serve API to search or read job listings, so every third-party "LinkedIn jobs API" is a scraper or dataset wrapping LinkedIn's public pages. #### Is it legal to scrape LinkedIn jobs? Job postings are public, and in *hiQ v. LinkedIn* the Ninth Circuit affirmed a preliminary injunction finding that scraping public data likely does not violate the CFAA. But LinkedIn's terms still prohibit automated access, and personal data is subject to GDPR and CCPA. Jobs are lower-risk than profiles, not risk-free. See our [legal breakdown](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers). #### How do I scrape LinkedIn jobs with Python? The simplest path is LinkedIn's public guest endpoints (`/jobs-guest/jobs/api/seeMoreJobPostings/search` and `/jobPosting/{id}`) with `requests` and BeautifulSoup, paginating in steps of 25. It works for small pulls but gets rate-limited quickly; for a reliable pipeline, use a scraping API or an account-based API instead. #### Do I need a LinkedIn account or cookies to scrape jobs? Not for the public guest endpoints – they serve logged-out data. You only need an account for higher-volume, structured access through an account-based API, where the requests come from your own authenticated session. #### Can I get salary data from LinkedIn job postings? Yes, when the posting includes it. Pay-transparency laws mean many postings now show a salary range, which comes through as a structured `salary` object. Postings without published pay return no salary. #### How many LinkedIn jobs can I scrape before getting blocked? There is no fixed number. A single IP hitting the guest API is usually throttled after about ten pages, so volume needs rotating proxies. An account-based approach is limited instead by your account's safe daily pace. Behavior matters more than raw count. #### How do I monitor new LinkedIn job postings? Poll a search filtered by `datePosted` (for example `past24Hours`), dedupe results by `jobId`, and treat new IDs as fresh postings. That gives you near-real-time hiring signals, which LinkedIn's once-a-day native alerts do not. #### Is there a free LinkedIn jobs scraper? Open-source Python libraries and the public guest endpoints are free for small, occasional pulls. Most managed tools and APIs offer a small free tier and then charge by volume. #### Can I scrape LinkedIn jobs without Python? Yes. The job data is just JSON, so any language works over plain HTTP. With Linked API you can use the Node.js SDK, the Python SDK, or raw HTTP requests – all hit the same workflow API. --- Need fresh LinkedIn job data without running and unblocking your own scraper? [Start with Linked API](/pricing) – search jobs, open each one for the full record, and get clean JSON straight from your own account. ## LinkedIn Profile Scraper and Profile API: Extract Profile Data by URL or in Bulk (2026) A LinkedIn profile scraper pulls structured data from a person's profile – name, headline, experience, education, skills, activity – from its URL, whether you need one profile or a list of thousands. There is no official LinkedIn API for reading profile data, so every option is a scraper, and they differ a lot in reliability and account risk. This guide shows exactly what fields you can get, a real sample JSON, how to fetch one profile and a whole list in code, an honest take on Chrome extensions versus an API, and what you cannot get (spoiler: emails). > **The short version.** A person's headline, experience, education, skills, and activity are all extractable from their profile. Personal emails and phone numbers are not on the profile and need consented enrichment. Chrome extensions and homemade scripts run on *your own* account at machine speed and get it restricted; an API paces like a human and returns clean JSON. ## What data is on a LinkedIn profile A single profile is a rich record. Grouped by what it tells you: | Group | Fields | | --- | --- | | Identity | `name`, `headline`, `publicUrl`, `hashedUrl` | | Role and company | `position`, `companyName`, `location`, `countryCode` | | About | `about`, `followersCount` | | Experience | `experiences[]` – role, company, dates, duration, employment and location type | | Education | `education[]` – school and details | | Skills and languages | `skills[]` (`{ name }`), `languages[]` (`{ name, proficiency }`) | | Activity | `posts`, `comments`, `reactions` – [scrape posts and engagers](/guides/linkedin-post-scraper) | One detail worth building around: use `hashedUrl` as your stable key, not `publicUrl`. The vanity URL (`/in/jane-doe`) changes when someone renames their profile, but the hashed member URL is durable – it is the difference between a deduped database and silent duplicates. ## Is there a LinkedIn profile API? Not one that reads other people's profiles. LinkedIn's self-serve [Profile API](https://learn.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api) returns the authenticated member's own record – your name, headline and photo after sign-in – and profile lookup by URL or ID exists only inside approved partner integrations, so a standard developer application cannot fetch a profile you point it at. Every "LinkedIn profile API" or "profile lookup API" on the market is therefore an account-based or scraping product. Linked API's route is account-based: [`fetchPerson`](/docs/action-st-open-person-page?surface=sdk) opens any public profile by URL through a dedicated cloud browser on your own account and returns the typed JSON shown below – [one profile at a time](#scrape-one-profile-by-url) or [a list in bulk](#scrape-a-list-of-profiles-in-bulk). ## A real scraped profile Each section is opt-in: you request the parts you need, and you get back typed JSON instead of HTML to parse. ```json { "name": "Jane Doe", "publicUrl": "https://www.linkedin.com/in/jane-doe", "hashedUrl": "https://www.linkedin.com/in/ACoAAB1a2b3c4d", "headline": "Head of Growth at Acme", "location": "San Francisco, California, United States", "countryCode": "US", "position": "Head of Growth", "companyName": "Acme", "about": "Demand generation and lifecycle marketing leader.", "followersCount": 4820, "experiences": [ { "position": "Head of Growth", "companyName": "Acme", "employmentType": "fullTime", "locationType": "hybrid", "duration": 26, "startTime": "2024-01-01T00:00:00Z", "endTime": null, "location": "San Francisco, CA" } ], "education": [ { "schoolName": "Stanford University", "details": "MBA, Marketing" } ], "skills": [ { "name": "Demand Generation" }, { "name": "SEO" } ], "languages": [ { "name": "English", "proficiency": "nativeOrBilingual" }, { "name": "Spanish", "proficiency": "professionalWorking" } ] } ``` ## Four ways to scrape a profile, compared There is no official profile API, so the real choice is between four methods that trade off scale, output, and the risk to your account. | Method | Scale | Output | Maintenance | Account-ban risk | | --- | --- | --- | --- | --- | | Manual copy-paste | A few a day | You retype it | None | None | | Chrome extension | 50–100/day | A spreadsheet | Breaks on layout changes | High – acts on your account at machine speed | | DIY Python | High, until blocked | Raw HTML you parse | You own selectors and proxies | High – headless on your account | | API on your own account | Hundreds to thousands/day | Structured JSON | Handled for you | Lower – paced like a human | The honest distinction is account risk. A Chrome extension or a headless Python script acts through *your own logged-in account* at machine speed, which is exactly the pattern LinkedIn flags – accounts get restricted within days. An account-based API runs the same account but paces actions like a real user and returns maintained, structured JSON, so you are not re-writing selectors every time the page changes. We rank all the methods by risk in the [guide to scraping LinkedIn](/guides/how-to-scrape-linkedin#the-5-ways-to-scrape-linkedin-ranked-by-risk), and compare the API models in the [LinkedIn scraper API guide](/guides/linkedin-scraper-api). ## Scrape one profile by URL Install the SDK: ```bash # Node.js npm install -S @linkedapi/node # Python pip install linkedapi ``` Then [fetch a profile](/docs/retrieving-person-data) by URL, choosing which sections to pull: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const workflow = await linkedapi.fetchPerson.execute({ personUrl: 'https://www.linkedin.com/in/jane-doe', retrieveExperience: true, retrieveEducation: true, retrieveSkills: true, retrieveLanguages: true, }); const { data } = await linkedapi.fetchPerson.result(workflow.workflowId); console.log(data?.name, data?.position, data?.hashedUrl); ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, FetchPersonParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) workflow = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/jane-doe", retrieve_experience=True, retrieve_education=True, retrieve_skills=True, retrieve_languages=True, ) ) data = linkedapi.fetch_person.result(workflow.workflow_id).data print(data.name, data.position, data.hashed_url) ``` If you only have a name rather than a URL, [search for people](/docs/searching-for-people) first and feed the resulting URLs into the fetch. ## Scrape a list of profiles in bulk The common job is enrichment: you have a list of profile URLs – from a CSV, a search export, or your CRM – and you want a structured row for each. Loop over the URLs and collect the results. Linked API runs workflows sequentially on your account, so this paces itself automatically: ```typescript const profileUrls = [ 'https://www.linkedin.com/in/jane-doe', 'https://www.linkedin.com/in/john-smith', // one row per profile ]; const rows = []; for (const personUrl of profileUrls) { const workflow = await linkedapi.fetchPerson.execute({ personUrl, retrieveExperience: true }); const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId); if (data) rows.push(data); else console.warn('Skipped', personUrl, errors); } ``` ```python profile_urls = [ "https://www.linkedin.com/in/jane-doe", "https://www.linkedin.com/in/john-smith", # one row per profile ] rows = [] for person_url in profile_urls: workflow = linkedapi.fetch_person.execute( FetchPersonParams(person_url=person_url, retrieve_experience=True) ) result = linkedapi.fetch_person.result(workflow.workflow_id) if result.data: rows.append(result.data) else: print("Skipped", person_url, result.errors) ``` Two realities to plan for: a share of URLs in any list are stale (people rename or delete profiles), and some profiles are private, so expect to skip a percentage rather than get a clean 100%. Handle the error case, as the loop above does, instead of assuming every fetch succeeds. Bulk volume is also bounded by your account's [daily limits](/docs/core-concepts#limits-management) – this is your real account, not a proxy pool. ## Can you get an email or phone from a profile? Mostly no, and this is where a lot of tools oversell. Personal emails and phone numbers are almost never shown on a LinkedIn profile, so a scraper reading the profile cannot reliably return them. Tools that advertise "verified emails" are matching the person against a separate, ideally consented, B2B enrichment database – that is a different product from profile scraping, and the data does not come off the profile itself. The practical split: scrape the profile for role, company, experience, and activity; get contact details from a consented enrichment provider. We cover why direct contact-harvesting is the riskiest move in the [scraping guide](/guides/how-to-scrape-linkedin). ## Is it legal, and will my account get banned? Profile data is largely public, and in [hiQ Labs v. LinkedIn (9th Cir. 2022)](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/18/17-16783.pdf) the Ninth Circuit affirmed a preliminary injunction, finding hiQ had raised serious questions that scraping publicly available data likely does not violate the Computer Fraud and Abuse Act. But public is not a free pass: LinkedIn enforces against extraction operations with account restrictions and lawsuits – the data API Proxycurl was [sued into shutting down](/guides/proxycurl-alternatives) – and personal data falls under GDPR and CCPA. The ban risk is highest exactly where most people start: Chrome extensions and headless scripts driving your own account too fast. Pace like a human, stop on a CAPTCHA or restriction notice, and keep contact data to consented sources. The full legal model is in our [guide to scraping LinkedIn](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers). This is not legal advice. ## Frequently Asked Questions (FAQ) #### What data can you extract from a LinkedIn profile? Name, headline, location, current role and company, the full experience and education history, skills, languages, follower count, and recent activity (posts, comments, reactions). Personal email and phone are generally not on the profile. #### Does LinkedIn have an official profile API? No. LinkedIn's official APIs are limited to your own data and approved partner use cases; there is no public profile or profile-lookup API for other people's data. Every "profile API" you see is an account-based, scraping or enrichment product. #### Can I scrape LinkedIn profiles in bulk? Yes – pass a list of profile URLs and fetch each one in a loop, collecting structured rows. Plan for a percentage of stale or private URLs, and remember that volume on an account-based approach is bounded by your account's safe daily pace. #### Can I get someone's email from their LinkedIn profile? Not from the profile itself in most cases. Emails and phone numbers are rarely published on profiles, so reliable contact data comes from a separate consented enrichment provider, not from scraping the profile. #### Chrome extension vs API – which is safer? An API on your own account, by a wide margin. Extensions act through your logged-in account at machine speed and tend to get it restricted quickly; an API paces actions like a human, returns structured JSON, and is maintained against LinkedIn UI changes. #### How do I scrape a LinkedIn profile with Python? Install the SDK (`pip install linkedapi`), then call `fetch_person.execute(...)` with the profile URL and the sections you want, and read the result with `fetch_person.result(...)`. See the code above for a full example, including the bulk loop. #### Can I scrape a profile without an account? Only the limited public version LinkedIn shows logged-out, which omits much of the profile. Full, structured profile data comes through an authenticated session – your own account, driven by an API. #### How many profiles can I scrape per day? There is no universal number; it depends on your account's age and history. A new account should stay conservative and ramp up, while an established one handles more. Behavior matters more than raw count – pace like a human and stop on warnings. --- Need clean LinkedIn profile data by URL or in bulk, without running an extension or a fragile script? [Start with Linked API](/pricing) – fetch a profile or a whole list from your own account and get structured JSON back. ## How to Scrape LinkedIn in 2026 Without Getting Banned (and Is It Legal?) Yes, you can extract data from LinkedIn, and scraping *public* LinkedIn data is not a federal crime in the US. But that one fact hides two more: aggressive extraction can get your account permanently restricted, and how you store or sell the data can expose you – and your buyers – to privacy law. This guide gives you the real methods ranked by risk, how bans actually happen, and the legal picture, in plain terms. > **The short version.** Public profile and activity data is extractable. Personal contact details (emails, phone numbers) are not, and chasing them is where most trouble starts. The durable approach is to operate on *your own* authenticated account with human-like pacing, or use consented data – not to point a headless bot at LinkedIn and hope. ## What you can actually extract from LinkedIn Before picking a method, be clear about what is realistically available. Most of LinkedIn's value sits in data the platform shows publicly. The data it hides – private contact details – is exactly the data that creates legal risk. | Data | Public? | Realistic to extract | Notes | | --- | --- | --- | --- | | Name, headline, role, location | Yes | Yes | Visible on public profiles, even logged out | | Work history, education, skills | Mostly | Yes | Privacy-dependent – [scrape a full profile](/guides/linkedin-profile-scraper) | | Posts, reactions, comments | Yes | Yes | Engagement data for social selling – [scrape posts and engagers](/guides/linkedin-post-scraper) | | Company info, employees | Yes | Yes | Headcount, industry, decision-makers – [scrape a company](/guides/linkedin-company-scraper) | | Job postings | Yes | Yes | Public listings, salary when shown – [scrape jobs](/guides/linkedin-jobs-scraper) | | Your own connections | Private to you | Yes | You can export your own network | | Personal email, phone | No | No (not directly) | LinkedIn does not expose these; harvesting them is the highest-risk move | The line that matters: profile and activity data is fair to extract; personal contact data is not. Any tool that promises to "scrape verified emails straight from LinkedIn" is either guessing or pulling from a separate database – and that distinction matters legally, as we will see. ## The 5 ways to scrape LinkedIn, ranked by risk There is no single "LinkedIn scraper." There are five common approaches, and they trade off scale against the risk to your account. ![Five LinkedIn scraping methods plotted by scale and account-ban risk](/images/guides/linkedin-scraping-methods-quadrant.webp) | Method | Typical scale | Account-ban risk | Legal exposure | Best for | | --- | --- | --- | --- | --- | | Manual copy-paste | A few/day | None | Low | One-off lookups | | Browser extension | 50–100/day | High | Medium | Small lists, non-technical users | | No-code tools | Hundreds/day | High | Medium | Sales ops without engineers | | Custom code (headless + proxies) | High, until it breaks | Very high | High | Engineers who want full control | | API on your own account | Hundreds to thousands/day | Low–moderate | Medium | Products and repeatable pipelines | **Manual copy-paste.** Slow, free, zero risk. Fine for a handful of profiles. Useless at scale. **Browser extensions.** A Chrome extension reads the page you are looking at and dumps it to a sheet. Easy to start, but it acts on your logged-in account at machine speed, which is easy for LinkedIn to flag. Good for tens of profiles, not thousands. **No-code tools.** PhantomBuster, Evaboot, and similar SaaS run cloud automations on your account. More throughput than an extension, same core risk: it is still your account doing the actions, so account safety depends entirely on how conservatively the tool paces itself. **Custom code.** Python with Selenium, Playwright, or a headless browser plus rotating proxies. Maximum control, maximum maintenance, and the highest ban risk – which brings us to why this path is harder than it looks. **API on your own account.** A managed service drives a real browser session for *your* account with human-like timing and built-in limits, and returns structured JSON. This is the sweet spot for anything ongoing or product-facing, and it is where [Linked API](/) sits – see our [LinkedIn scraper API guide](/guides/linkedin-scraper-api) for how that model works. ### Why DIY headless scraping breaks Most engineers try the custom-code route first, then abandon it. Here is what they run into: - **The login wall.** LinkedIn shows only a few public profiles before forcing authentication. Once you log in to get past it, every request is tied to a real account that can be restricted. - **Fingerprinting.** LinkedIn profiles your TLS handshake, headers, timing, and browser fingerprint. Datacenter proxies and vanilla headless browsers are trivially detectable, so you end up paying for residential proxies and stealth patches. - **Fragile selectors.** The DOM changes constantly. Your parser breaks, silently returns empty fields, and you maintain it forever. - **Permanent bans.** Account restrictions on LinkedIn escalate fast and are rarely reversed. A burned account is gone, along with its connections and history. None of this means scraping is dead. It means brute force is dead. The methods that survive are the ones that behave like a human. ## How LinkedIn detects scraping (and how accounts get banned) Bans are not random. LinkedIn watches a handful of signals, and you can stay clear of all of them. - **Request velocity.** Hundreds of profile views in an hour is not human. Real users pause, scroll, and get distracted. - **Behavioral patterns.** Identical timing between actions, no mouse movement, perfectly sequential browsing – all classic automation tells. - **Fingerprint and IP reputation.** Headless browsers, datacenter IPs, and mismatched geolocation raise the score. - **The "log in to continue" wall.** Hitting it repeatedly while logged out is a strong scraping signal. When the score crosses a threshold, accounts move through a ladder: a soft warning, then a temporary restriction (search and profile views blocked for a stretch), then a permanent ban. New accounts get the least slack, so warm them up over weeks rather than firing on day one. ### Safe volume is a range, not a number You will see "500 profiles a day is safe" thrown around. Ignore single numbers. Safe volume depends on account age, your Social Selling Index, whether you have Sales Navigator, and how human-like your pacing is. | Account state | Rough safe ceiling | Approach | | --- | --- | --- | | New or cold | 20–50 actions/day | Ramp slowly over 3–4 weeks | | Established, human-like pacing | A few hundred actions/day | Spread across the day, with pauses | | Aggressive headless + proxies | You will likely be flagged | Not recommended | For the real, account-specific picture, see our guides on the [LinkedIn connection limit](/guides/linkedin-connection-limit-2026) and [understanding LinkedIn limits](/guides/understanding-linkedin-limits). **Stop signals.** If you see a CAPTCHA, a forced re-login, an "unusual activity" notice, or a sudden drop in results, stop immediately and let the account rest. Pushing through is how a temporary restriction becomes a permanent one. ## Is scraping LinkedIn legal? The three layers This is where almost every other guide gets it wrong. "Scraping public data is legal, *hiQ* said so" is half a sentence. Legality is not one test – it is three separate layers, and clearing one does not clear the next. **1. US computer-access law (the CFAA).** In [hiQ Labs v. LinkedIn (9th Cir. 2022)](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/18/17-16783.pdf), the Ninth Circuit affirmed a preliminary injunction, finding hiQ had raised serious questions that scraping publicly available data likely does not violate the Computer Fraud and Abuse Act. The arc matters: an injunction in 2017, affirmed in 2019, vacated by the Supreme Court in 2021 after [Van Buren v. United States](https://www.law.cornell.edu/supremecourt/text/19-783), then reaffirmed in 2022. So scraping public data is not a federal hacking crime. That is the layer most articles stop at. **2. Privacy law (GDPR / CCPA).** "Public" does not mean "free to use." If your dataset includes personal data of EU or California residents, privacy law applies regardless of where you found it. You need a lawful basis – for B2B prospecting that is usually legitimate interest – and data subjects keep rights over their data. Personal emails and phone numbers are the hot zone; collecting them without consent is the fastest way into trouble. **3. Enforcement reality.** LinkedIn does not just win cases, it makes examples. It has permanently banned and sued operations that used fake profiles, pushed data brokers like Apollo and Seamless to remove company pages, and in 2025 sued [Proxycurl](/guides/proxycurl-alternatives) (Nubela) – whose LinkedIn data API then shut down entirely. For a 2026 reader, that suit, not *hiQ*, is the live warning shot. > This is not legal advice. It is the mental model to bring to your own counsel: pass all three, or you are carrying the risk. The honest takeaway: public data is collectible, but how you *use* it is what gets people sued. Stay on public, business-relevant data; keep a lawful basis; and never harvest personal contact details from LinkedIn directly. ## The durable approach: automate your own account like a human Strip away the noise and the safe path is consistent across all three legal layers and the detection signals: 1. **Use your own authenticated account.** No fake profiles, no bought accounts. Fake-profile operations are exactly who LinkedIn bans and sues. 2. **Behave like a human.** Pace actions, add real delays, run sequentially, and stay inside your account's limits. 3. **Stick to public, business data.** Profiles, companies, posts, your own connections. Get personal contact data from consented enrichment sources, not by scraping it off LinkedIn. This is precisely what Linked API is built to do. It runs your account in a cloud browser that emulates a real LinkedIn user, so requests are not instant – a simple action takes seconds, a heavy one minutes – and workflows run [sequentially, never in parallel](/docs/workflows-overview#sequential-execution), mirroring how a person actually browses. When an action would exceed a configured limit, it returns a `limitExceeded` error instead of pushing your account over the edge. To be clear about what that does and does not buy you: it is not a guarantee against restriction. What it buys you is account safety and clean, structured data without maintaining a scraper. In practice that means two calls rather than a scraper: a [people search](/docs/searching-for-people) that returns matching profiles as structured data, and a [profile fetch](/docs/retrieving-person-data) that returns experience, skills and recent posts in one request. Neither involves HTML parsing, selectors, or a session file you have to keep alive. Working code for both calls lives in our guide to building a [LinkedIn scraper in Python](/guides/linkedin-scraper-python), which also compares this route against the open-source libraries and a DIY Playwright script line for line. The [SDK reference](/sdks/installation) has the TypeScript equivalents. ## Which method should you use? - **A few profiles, occasionally.** Copy-paste or a browser extension. No need for anything heavier. - **A non-technical sales team.** A no-code tool, paced conservatively. Compare options in our [best LinkedIn automation tools](/blog/best-linkedin-automation-tools-2026) roundup. - **A product or a repeatable data pipeline.** An API on your own account. Predictable, structured, and account-safe at scale. - **Personal emails and phone numbers.** Not from LinkedIn. Use a consented B2B enrichment provider and keep a lawful basis. ## Frequently Asked Questions (FAQ) #### Is LinkedIn scraping legal in 2026? Scraping publicly available data likely does not violate the CFAA in the US — in *hiQ v. LinkedIn* the Ninth Circuit affirmed a preliminary injunction on that basis. That settles the criminal-law question only: GDPR and CCPA still apply if the data touches personal information, and LinkedIn enforces against extraction operations directly, as the Proxycurl suit showed. Public does not mean unrestricted. #### How many LinkedIn profiles can I scrape per day without getting banned? There is no universal number. A new account should stay around 20–50 actions a day and ramp up over weeks; an established account with human-like pacing can handle a few hundred. Limits depend on account age, SSI, and subscription. See our [limits guide](/guides/understanding-linkedin-limits). #### Can you get banned for scraping LinkedIn? Yes. LinkedIn escalates from warnings to temporary restrictions to permanent bans, and permanent bans are rarely reversed. Human-like pacing on your own account is the main defense. #### Can I scrape LinkedIn without an account? Only the limited public pages LinkedIn shows before the login wall. No account is involved, so nothing can be restricted, but it is the hardest route technically and it still falls under privacy law. #### Does LinkedIn detect API scraping? LinkedIn detects automation through behavioral patterns, request velocity, and fingerprinting regardless of how it is driven. The mitigation is not hiding – it is behaving like a real user on a real account, which is how a managed API approach works. #### What is the difference between scraping and data enrichment? Scraping pulls data from LinkedIn's interface. Enrichment matches a name or domain against a separate, ideally consented, database. Verified emails come from enrichment, not from LinkedIn directly. #### Is it legal to scrape Sales Navigator? The same layers apply. Treat it as higher risk, not lower, and pace accordingly. For the ways to extract it, see our [Sales Navigator export guide](/guides/linkedin-sales-navigator-scraper); for programmatic access, see the [Sales Navigator API guide](/guides/linkedin-sales-navigator-api). #### Can I put scraped LinkedIn data in my CRM? For public, business-relevant data with a lawful basis (legitimate interest for B2B), generally yes. For personal contact data gathered without consent, you are exposed under GDPR and CCPA. Keep records of your basis and honor deletion requests. #### What is the safest way to scrape LinkedIn? Operate your own authenticated account with human-like pacing and conservative limits, stick to public business data, and source contact details from consented providers. That clears the detection signals and keeps you on the right side of all three legal layers. --- Want structured LinkedIn data without running a scraper or risking your account? [Start with Linked API](/pricing) – it drives your own account safely and returns clean JSON for people, companies, posts, and connections. ## LinkedIn Scraper API: What It Returns and What It Costs (2026) A LinkedIn scraper API returns structured LinkedIn data – profiles, companies, posts, search results, jobs – to your code, so you never write a selector or run a headless browser. This guide is the evaluation material: what comes back per entity, what each provider charges, and a real request with the JSON it produces. "LinkedIn scraper API" covers three different products, so it also says plainly which one fits which job, including where a dataset or a real-time scraper beats what we build. > **The short version.** Need millions of cold profiles in bulk? Use a dataset. Need fresh public pages on demand? Use a real-time scraper. Need the data your own account can see – your network, your searches, Sales Navigator, your inbox – and the ability to act on it? Use an account-based API. Only the last kind can send a connection request or a message, and only the first is cheap at a million records. ## What a LinkedIn scraper API returns Here is the whole contract, on one screen. You start a request, you get a handle back, and you read the result: ```typescript const profile = await linkedapi.fetchPerson.execute({ personUrl: 'https://www.linkedin.com/in/jane-doe', retrieveExperience: true, retrieveSkills: true, }); // execute() returns a handle, not the data - the work runs in your account const { data, errors } = await linkedapi.fetchPerson.result(profile.workflowId); ``` ```python profile = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/jane-doe", retrieve_experience=True, retrieve_skills=True, ) ) # execute() returns a handle, not the data - the work runs in your account result = linkedapi.fetch_person.result(profile.workflow_id) data, errors = result.data, result.errors ``` `data` is typed JSON: ```json { "name": "Jane Doe", "publicUrl": "https://www.linkedin.com/in/jane-doe", "headline": "Head of Growth at Acme", "location": "San Francisco, California, United States", "position": "Head of Growth", "companyName": "Acme", "followersCount": 4820, "experiences": [ { "position": "Head of Growth", "companyName": "Acme", "employmentType": "fullTime", "locationType": "hybrid", "duration": 26, "startTime": "2024-01-01T00:00:00Z", "endTime": null } ], "skills": [ { "name": "Demand Generation" }, { "name": "SEO" }, { "name": "Lifecycle Marketing" } ] } ``` **Two things to be clear about, because they are the real difference between the models.** First, this is an asynchronous workflow, not one HTTP call that hands back finished JSON: `execute` starts the work and returns a `workflowId`, `result` gives you `{ data, errors }` when it is done. Second, the work runs inside *your own* LinkedIn account in a dedicated cloud browser, at the pace of a person – a simple fetch takes seconds, a heavy one minutes, and actions run [sequentially rather than in parallel](/docs/workflows-overview#sequential-execution). The ceiling is not a rate limit we invented; it is your account's own, and you [configure the per-action limits](/docs/admin-limits) the platform then enforces. Sensible values are in the [LinkedIn limits guide](/guides/understanding-linkedin-limits). Every other operation follows the same `execute` then `result` pattern – the [people search below](#make-your-first-call) is the same two steps against a different operation. ## Is there a LinkedIn API for scraping? Not from LinkedIn. Its official API is a partner program: the open tier essentially returns your own profile and lets you publish posts, and reading other people's profiles, search results or audience data needs [Partner Program approval](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access), which is selective and slow. The full picture of what you can and cannot get through official channels is in our [LinkedIn API access guide](/guides/linkedin-api-access). So "LinkedIn scraper API" always means a third-party product, and there are three of them: | Model | How it works | Freshness | What it reaches | Can it act? | Risk sits with | | --- | --- | --- | --- | --- | --- | | Dataset / database | Query a pre-collected database | Months to years | Bulk public profiles | No | The data holder | | Real-time scraper | Pass a URL or ID, fetched live through proxies | Live | Public pages | No | The provider | | Account-based API | Runs your own authenticated account | Live | Your network, searches, Sales Navigator, inbox, public data | Yes | Your account | ## What you can extract, per entity This is the table most "scraper API" pages leave out. Ceilings below are LinkedIn's own daily limits for an established account, not ours: | Entity | What comes back | Daily ceiling on one account | Method | | --- | --- | --- | --- | | Person profile | Name, headline, location, current role, company, about, follower count, experience, education, skills, languages | ~80 views on a free account, ~150 on Premium, up to ~1,000 through Sales Navigator | [`fetchPerson`](/docs/action-st-open-person-page?surface=sdk) | | Company | Description, industry, headquarters, size, founding year, specialties, employees, decision-makers | ~100 free, ~200 Premium, up to ~1,000 through Sales Navigator | [`fetchCompany`](/docs/action-st-open-company-page?surface=sdk) | | Posts | Text, author, hashtags, media, reactions and comments with each engager's name and headline | Counts against reading limits, not a separate cap | [`fetchPost`](/docs/action-st-open-post?surface=sdk) | | People search | Matching people with filters for role, location, industry, current and past company, school | 300 searches a month on free and Premium Career; unlimited on Premium Business, Sales Navigator and Recruiter. One query returns up to 1,000 results (2,500 in Sales Navigator) | [`searchPeople`](/docs/action-st-search-people?surface=sdk) | | Jobs | Job posts and search results – title, company, location, description, posting date | Job search does not count against LinkedIn's commercial-use limit | [`searchJobs`](/docs/action-st-search-jobs?surface=sdk), [`fetchJob`](/docs/action-st-open-job?surface=sdk) | | Sales Navigator | Lead and account search, lead data, InMail and the Sales Navigator inbox | Uses your Sales Navigator seat's own limits | [`nvSearchPeople`](/docs/action-nv-search-people?surface=sdk) and the other `nv-` operations | Entity-level detail, with working code for each, lives in the dedicated guides: [profiles](/guides/linkedin-profile-scraper), [companies](/guides/linkedin-company-scraper), [posts](/guides/linkedin-post-scraper), [jobs](/guides/linkedin-jobs-scraper) and [Sales Navigator](/guides/linkedin-sales-navigator-api). What a third-party profile fetch does not reliably give you is someone else's contact details. LinkedIn shows Contact Info at the member's discretion, mostly to connections, and its own OIDC permission returns only the email of the member who consented – so emails and phone numbers at scale do not come from a profile API of any kind. Source them from a consented enrichment provider instead; harvesting them is the fastest route into legal trouble. ## LinkedIn scraper APIs compared Prices and capabilities below come from each vendor's own pricing and product pages. | Provider | Model | Input | Can it act? | Published price | Free tier | | --- | --- | --- | --- | --- | --- | | Bright Data | Real-time scraper plus a pre-collected dataset | URL batches (up to 5,000 per call), keyword discovery | No | $1.50 per 1,000 records pay-as-you-go; $499/mo including 384,000 records; datasets $250 per 100,000 | 5,000 records a month | | Scrapingdog | Real-time scraper | Profile, company and post IDs; job search by keyword and location | No | Credit-based: a person profile costs 50–100 credits, a company 10, a post 5. $40/mo buys 200,000 credits, so 2,000–4,000 profiles | 200 starting credits | | Apify | Marketplace of community-built actors – there is no first-party LinkedIn actor | Profile URLs or IDs, or a search URL with filters | Actor-dependent – the read actors cannot; one community actor sends connection requests if you paste in your session cookies | Platform plan from $0 to $999/mo *plus* per-run actor charges; a representative profile actor charges $4 per 1,000 profiles, $10 with email lookup; the connection-request actor $25/mo plus usage | $5 of usage a month | | Unipile | Account-based | A connected account plus an identifier | Yes | €5.00 per connected account per month, minimum €49/mo for up to 10 accounts, no per-request fee | 7-day trial | | Linked API | Account-based, in a dedicated cloud browser | A connected account; actions composed as workflows | Yes | $49 per seat per month billed annually, no per-record metering | 7-day trial | **What the table actually says.** The read-focused products cannot act on LinkedIn, and they are explicit about why: they never hold a session. Bright Data's LinkedIn scrapers cover "only publicly available data", Scrapingdog scrapes "publicly available LinkedIn person profiles", and the most-used Apify profile actor sells "no cookies, no login, no browser required" as its headline feature. That property is exactly what keeps them clear of your account – and what stops them sending a connection request, reading your inbox, or seeing a Sales Navigator result. Apify is the one to qualify rather than dismiss: it is a marketplace, so capability varies by actor. Alongside the read-only scrapers there is a community actor that does send connection requests – by asking you to copy your LinkedIn session cookies out of your browser and paste them into a third-party actor. That is a real capability and a different safety model from either the read-only scrapers or an account-based API, where the session is established once and stays inside the platform. Unipile is the one genuine peer: it also drives your own account, and it also acts. The difference there is execution rather than capability – it constructs reverse-engineered calls to LinkedIn's private interface and bills per connected account, where Linked API runs a real browser session on LinkedIn's own pages and bills per seat. Our [head-to-head with Unipile](/vs/unipile) covers that layer difference properly. And where the others win, they win clearly. For a million cold profiles, Bright Data's dataset at $250 per 100,000 records is a different order of magnitude from any account-based product, and no per-seat model competes with it. For cheap public reads at moderate volume, Scrapingdog is hard to beat on unit cost. For breadth of odd jobs, Apify's marketplace has an actor for almost anything – at the price of depending on a community maintainer rather than a vendor. ## Make your first call Install the SDK: ```bash # Node.js npm install -S @linkedapi/node # Python pip install linkedapi ``` Initialize the client and run a [people search](/docs/searching-for-people) – the same `execute` then `result` pattern as the profile fetch above: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const search = await linkedapi.searchPeople.execute({ term: 'head of growth', limit: 25, filter: { locations: ['United States'], industries: ['Software Development'] }, }); const { data } = await linkedapi.searchPeople.result(search.workflowId); data?.forEach((person) => console.log(person.name, '-', person.publicUrl)); ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, SearchPeopleParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) search = linkedapi.search_people.execute( SearchPeopleParams( term="head of growth", limit=25, filter={"locations": ["United States"], "industries": ["Software Development"]}, ) ) result = linkedapi.search_people.result(search.workflow_id) for person in result.data or []: print(person.name, "-", person.public_url) ``` Grab your tokens from the [installation guide](/sdks/installation). Writing this in Python rather than TypeScript? [Scraping LinkedIn in Python](/guides/linkedin-scraper-python) puts the same extraction next to a Playwright script and checks the state of the open-source libraries. ## Two ways to run it: build it in, or hand it to an agent Both routes are first-class, and which one you want depends on whether you are building a product or running a workflow. **Build it in.** The [REST API and the Node and Python SDKs](/sdks/installation) plus the shell [CLI](/cli/getting-started) give you the primitives above to compose into your own logic – your backend, your CRM, your scheduler. **Get it out of the box.** Install a ready-made [agent skill](/skills) with `npx @linkedapi/skills`, or point an assistant at the [MCP server](/mcp/overview) or the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ask in plain language: "find heads of growth at Series B fintechs in London, pull their profiles, and message the ones who posted about hiring this month." The agent composes and runs the same workflows. Nothing to wire up. ## Scraper API vs building your own scraper The real question for most engineers is "should I just build this myself?" It comes down to maintenance and risk, not difficulty: | Factor | Build it yourself | Scraper API | | --- | --- | --- | | Time to first data | Days to weeks | Minutes | | Proxies and CAPTCHAs | You manage them | Handled | | Selectors break | You fix them, forever | Handled | | Account-ban risk | High, hard to control | Lower, paced for you | | Cost at low volume | "Free" but your time | Free tier to low monthly | | Cost at massive volume | Cheaper per record | More per record | | Best when | You have niche needs and time | You want data and to ship | If you want to build it anyway, our [guide to scraping LinkedIn](/guides/how-to-scrape-linkedin) covers the methods, the detection signals, and why headless scraping breaks. ## Is a LinkedIn scraper API legal? Will my account get banned? In [hiQ Labs v. LinkedIn (9th Cir. 2022)](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/18/17-16783.pdf) the Ninth Circuit affirmed a preliminary injunction, finding hiQ had raised serious questions that scraping *public* LinkedIn data likely does not violate the CFAA – but it still falls under LinkedIn's terms, and under GDPR and CCPA when personal data is involved. The three-layer model is in our [scraping guide](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers). The cautionary tale for this category is [Proxycurl](/guides/proxycurl-alternatives): one of the most popular LinkedIn data APIs until LinkedIn [sued its operator in 2025](https://dockets.justia.com/docket/california/candce/3:2025cv00828/443258) over fake accounts and scraping, after which the service shut down and its data was slated for deletion. The lesson is about who carries the risk. An account-based API runs your own real session, holds no scraped index, and paces itself – which keeps accounts healthy, but is not immunity and will not pull a hundred thousand profiles a day. ## Frequently Asked Questions (FAQ) #### Can I use LinkedIn's official API to scrape profiles? No. The official API is partner-gated and does not expose other people's profiles, search results or audience data, which is why every "LinkedIn scraper API" is a third-party product – a dataset, a real-time scraper, or an account-based API that runs your own session. #### Which LinkedIn scraper API is free? None are free at working volume, but most have a free tier to test with: Bright Data gives 5,000 records a month, Scrapingdog 200 starting credits, and Apify $5 of monthly usage. Account-based products give trials instead – Linked API a 7-day trial, Unipile the same. #### Does LinkedIn have an official API? Yes, but restricted. The open tier mainly returns your own profile and lets you share posts. Anything broader needs Partner Program approval, which is selective and can take weeks. #### How do I get a LinkedIn API key? For a scraper API, you sign up with the provider and get a token. For Linked API you get two – an account token and a per-account identification token – from the dashboard; see the [installation guide](/sdks/installation). #### What is the difference between a scraper API and LinkedIn's official API? The official API exposes a narrow, approved slice of data through OAuth. A scraper API returns what a user can actually see – profiles, companies, posts, search – without partner approval, and operates outside LinkedIn's official program. #### Can I get LinkedIn data in real time? Yes, with a real-time scraper or an account-based API. Datasets are not real time: they serve pre-collected records that can be months or years old. #### How many profiles can I fetch per day? With an account-based API the ceiling is your account's own: roughly 80 profile views a day on a free account, ~150 on Premium, up to ~1,000 through Sales Navigator. Scrapers and datasets go higher because they do not use your account. #### Do I need Sales Navigator? Not for standard profile, company, post or job data. Sales Navigator unlocks its own search and lead surfaces, which account-based APIs reach through your seat – see the [Sales Navigator API guide](/guides/linkedin-sales-navigator-api). #### Will my account get banned? Any automation carries risk. It is lowest when requests come from your own authenticated account at human pace, which is how an account-based API is built to behave. Aggressive headless scraping on a logged-in account is what gets flagged. --- Want fresh LinkedIn data and the ability to act on it, without running a scraper? [Start with Linked API](/pricing) – your account runs in its own dedicated cloud browser at human pace, within the per-action limits you configure, on flat per-seat pricing from $49/mo billed annually with a 7-day trial – and use it both ways: build it into your product with the [REST API, SDKs](/sdks/installation) and [CLI](/cli/getting-started), or get it out of the box through the [MCP server](/mcp/overview) and ready-made [agent skills](/skills). *Facts verified September 24, 2026 – Bright Data, Scrapingdog, Apify and Unipile pricing, entity coverage and action capability checked against their live product and pricing pages on that date.* ## Proxycurl Is Gone: Best LinkedIn Data API Alternatives in 2026 (and How to Migrate) Proxycurl, for years one of the most popular LinkedIn data APIs, shut down in 2025 after LinkedIn sued the company behind it. If your pipeline broke overnight, this guide gives you the accurate story (the dates and the case, not the rumors), the real lesson for choosing your next vendor, an honest comparison of the live alternatives by model, and a concrete migration path. No gloating, no single-product hard sell – just what happened and what to do next. > **The short version.** For a like-for-like "profile URL in, JSON out" replacement, a real-time scraper (ScrapIn, Apify, Bright Data) is the closest drop-in. For bulk enrichment, a dataset provider (Coresignal, People Data Labs) fits. If you want live data plus the ability to act – and no shared scraped database that a court can order deleted – an account-based API (Linked API, Unipile) is the durable play. The thing that actually killed Proxycurl – fake accounts and a central scraped index – is the thing not to repeat. ## What happened to Proxycurl On January 24, 2025, LinkedIn sued the company behind Proxycurl (Nubela) in the U.S. District Court for the Northern District of California – *LinkedIn Corp. v. Nubela Pte. Ltd.*, No. 3:25-cv-00828 (N.D. Cal.), filed January 24, 2025; see [the docket](https://dockets.justia.com/docket/california/candce/3:2025cv00828/443258). The complaint brought six claims, including breach of contract, fraud and deceit, violation of the Computer Fraud and Abuse Act (CFAA), California's Unfair Competition Law, a Lanham Act claim, and misappropriation. The core allegation was not simply "they used LinkedIn data." It was that Proxycurl created hundreds of thousands of fake accounts to scrape millions of profiles, including non-public data, and resold that data through its API. The case settled in mid-2025; Proxycurl posted its goodbye in July 2025 and wound the service down, with customers notified and data slated for deletion. ![Why Proxycurl was shut down: fake accounts and a central scraped index led to a lawsuit, an injunction, and shutdown](/images/guides/proxycurl-why-shut-down.webp) To its credit, Proxycurl's founder was candid afterward. He wrote in his July 2025 goodbye post that the business was a roughly $10M revenue business, that about half of it came from scraping LinkedIn, and that there was "no winning in fighting this" against a company with an effectively unlimited legal budget. The team has since pivoted to a new product, NinjaPear, that explicitly does not scrape LinkedIn. ## The real lesson for picking your next API It is tempting to read this as "LinkedIn data is radioactive, stay away." That is the wrong takeaway. The fatal combination was specific: **fake accounts at scale, scraping non-public data, packaged as a central database and resold.** Each of those is a separate aggravating factor, and together they made the case easy to bring. The legal backdrop is more nuanced than the headlines. Scraping genuinely *public* data is not automatically illegal in the US – that is the takeaway from *hiQ Labs v. LinkedIn*. But the moment you spin up fake accounts or reach non-public data at scale, you are in CFAA and privacy-law territory. (We cover the full legal model in our [guide to scraping LinkedIn](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers).) For you as a buyer, the practical lesson is about continuity risk. A provider whose entire business is a central, scraped index of LinkedIn is one injunction away from gone – and that injunction can require deleting the very data you depend on, with little notice. Vendor selection now has to weigh "will this still exist, and will my data survive, in twelve months?" alongside price and coverage. ## The live alternatives, by model Proxycurl's competitors – the LinkedIn data APIs still standing – split into three models. Most "Proxycurl alternative" listicles blur them together and then crown whichever product the author sells. Here they are honestly, with the trade-offs that matter. | Model | How it works | Closest Proxycurl match | Examples | Freshness | Risk sits with | Typical price | | --- | --- | --- | --- | --- | --- | --- | | Real-time scraper | Pass a URL, scraped live via proxies | Person/Company Profile endpoints | ScrapIn, Apify, Bright Data, Scrapingdog | Live | The provider | ~$1.50–$4 / 1K profiles | | Dataset / database | Query a pre-scraped database in bulk | Search and bulk enrichment | Coresignal, People Data Labs, Crustdata | Months to years | The data holder | ~$0.005–$0.20 / record | | Account-based API | Runs your own authenticated account | Profile reads, plus actions | Linked API, Unipile | Live | Your account | Flat per connected account | **Real-time scrapers** are the closest drop-in for Proxycurl's bread-and-butter "give a profile URL, get JSON." ScrapIn (now merged into Reverse Contact) and Apify's LinkedIn actors are the usual developer picks; Bright Data is the enterprise-scale option with the strongest legal track record. You hand over a URL, they manage proxies and return structured data – and they carry the scraping risk. **Dataset providers** like Coresignal and People Data Labs sell scale: hundreds of millions of records you can query or buy in bulk. They replace Proxycurl's *search and enrichment* use case, not real-time single lookups, and the trade-off is freshness – records are often months or years out of date between refreshes. Crustdata sits in this group too: it indexes public web pages into one graph of people and companies, served through a REST API and an MCP server with flat-file datasets alongside, and it publishes no prices – real-time retrieval is an enterprise-tier option there rather than the default. **Account-based APIs** work differently: they drive *your own* authenticated LinkedIn account in a cloud browser. There is no shared scraped index for a court to seize, and you can act (connect, message, react), not just read. The honest caveat: the risk shifts to your account and LinkedIn's terms, and throughput is bounded by your account's daily limits – it is not a bulk firehose. This is the [account-based model](/guides/linkedin-scraper-api) we build at Linked API. ## What replaced the Proxycurl API? Nothing replaced the Proxycurl API one-for-one, and the team behind it is not the answer either: Nubela's new product, NinjaPear, deliberately does not touch LinkedIn. So the 21-odd endpoints you were calling now split across three kinds of product, and which one you want depends on the endpoint. Here is where each one goes: | Proxycurl endpoint | What it did | Where to go now | | --- | --- | --- | | Person Profile / Person Lookup | Profile data from a URL | Real-time scraper (ScrapIn, Apify, Bright Data) or account-based `fetchPerson` | | Person Search / Role Lookup | Find people by criteria | Dataset search (Coresignal, PDL) or account-based [people search](/docs/searching-for-people) | | Company Profile / Employee Count | Company firmographics | Dataset (Coresignal) or account-based [company data](/docs/retrieving-company-data) | | Employee Listing / Search | Roster of a company's people | Dataset, or account-based company fetch with employees | | Job Listing / Job Search | LinkedIn job posts and job search results | Account-based [job search](/docs/action-st-search-jobs?surface=sdk) and [job data](/docs/action-st-open-job?surface=sdk) – `searchJobs` and `fetchJob` in the Node and Python SDKs – or a dataset provider | | Work Email / Reverse Email / Personal Contact | Contact details | Consented enrichment (Reverse Contact, People Data Labs, Prospeo) | | Credit Balance | How much quota you had left | Nothing to port on a flat per-seat plan; the equivalent check is [API usage statistics](/docs/checking-api-usage-statistics) | One honest note on contact data: the email and phone endpoints were always the legally hottest part of Proxycurl. Personal contact details are not something you should extract from LinkedIn directly – source them from a consented B2B enrichment provider instead. We explain why in the [scraping guide](/guides/how-to-scrape-linkedin). ## Proxycurl pricing, and what the replacements cost Proxycurl sold credits, and its pricing page is still published even though the service is gone. The monthly tiers were Starter $49 for 2,500 credits at $0.020 a credit, Growth $299 for 25,000 at $0.012, Pro $899 for 89,900 at $0.010, and Ultra $1,899 for 211,000 at $0.009, with an Enterprise tier from $2,000 above that (per Proxycurl's own pricing page). Replacements do not price in the same unit, which is the part that makes a like-for-like comparison misleading. Real-time scrapers charge roughly $1.50–$4 per 1,000 profiles, so they behave most like the credit model you are leaving. Dataset providers charge per record, from fractions of a cent to about $0.20, and expect volume. Account-based APIs charge per connected account and meter nothing: Linked API is $49 per seat per month billed annually, the same headline number as Proxycurl's entry tier, but it buys a different thing – no credit balance to watch, and throughput bounded by one LinkedIn account's safe daily pace rather than by a quota you can top up. ## Migrating to an account-based API If you used Proxycurl for live profile reads and want control rather than a shared index, the account-based path maps cleanly. The Person Profile endpoint – a URL in, structured JSON out – becomes a single call against your own account. Install the SDK: ```bash # Node.js npm install -S @linkedapi/node # Python pip install linkedapi ``` Then fetch a profile by URL, the direct analog of Proxycurl's Person Profile endpoint: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const profile = await linkedapi.fetchPerson.execute({ personUrl: 'https://www.linkedin.com/in/jane-doe', retrieveExperience: true, retrieveSkills: true, }); const { data } = await linkedapi.fetchPerson.result(profile.workflowId); console.log(data?.name, data?.position, data?.followersCount); ``` ```python from linkedapi import LinkedApi, LinkedApiConfig, FetchPersonParams linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) profile = linkedapi.fetch_person.execute( FetchPersonParams( person_url="https://www.linkedin.com/in/jane-doe", retrieve_experience=True, retrieve_skills=True, ) ) data = linkedapi.fetch_person.result(profile.workflow_id).data print(data.name, data.position, data.followers_count) ``` The same `execute` then `result` pattern covers [people search](/docs/searching-for-people) and [company data](/docs/retrieving-company-data). Grab your tokens from the [installation guide](/sdks/installation) to start. ## A one-day migration checklist 1. **Put a thin interface in front of your data source** so you can swap providers without touching the rest of the app. Proxycurl taught everyone why this matters. 2. **Pick a model by job:** live single-profile reads to a real-time scraper, bulk firmographics to a dataset, your-own-account reads plus actions to an account-based API. 3. **Map your fields.** Most providers return the same core profile shape; normalize names once at the boundary. 4. **Move contact lookups to consented enrichment** rather than direct extraction. 5. **Backfill** any records you lost when Proxycurl went dark. 6. **Handle limits.** Real-time and dataset APIs have rate limits; account-based APIs surface a limit error when you would exceed your account's safe pace. ## Frequently Asked Questions (FAQ) #### Why did Proxycurl shut down? LinkedIn sued the company behind Proxycurl in January 2025 over creating fake accounts to scrape profiles, including non-public data, and reselling it. The case settled in mid-2025, and Proxycurl wound the service down. Its founder said about half the revenue came from LinkedIn scraping, and that there was "no winning in fighting this." #### Is the Proxycurl API still available in 2026? No. Its founder put it plainly in an April 2026 post: "Proxycurl is shut down. Old docs, old blog posts, and old code samples still exist. Treat them as historical reference only." The same post answers whether the team's new product is a direct replacement with a flat "No" – NinjaPear does not scrape LinkedIn – so for LinkedIn data specifically the choice is between the three models above. #### What is the best Proxycurl alternative? There is no single answer – it depends on the job. For "profile URL in, JSON out," a real-time scraper like ScrapIn or Apify is the closest drop-in. For bulk enrichment, Coresignal or People Data Labs. For live data plus actions on your own account, an account-based API like Linked API. #### Which alternative gives profile data by URL, like Proxycurl did? Real-time scrapers (ScrapIn, Apify, Bright Data, Scrapingdog) and account-based APIs both take a profile URL and return structured JSON. Dataset providers work by query and bulk export instead. #### Is scraping LinkedIn legal in 2026? Scraping genuinely public data is not automatically a crime in the US under *hiQ v. LinkedIn*, but it still falls under LinkedIn's terms and under GDPR and CCPA for personal data. Fake accounts and non-public data are what turn it into a lawsuit. See our [legal breakdown](/guides/how-to-scrape-linkedin#is-scraping-linkedin-legal-the-three-layers). #### Will my new alternative just get sued too? The risk depends on the model. A provider built on a fake-account farm and a central scraped index carries the same exposure Proxycurl did. A dataset or real-time provider that sources data carefully spreads that risk, and an account-based API moves it onto your own account and LinkedIn's terms rather than a shared index. None of these is risk-free; choose with that in mind. #### How much did Proxycurl cost, and how do alternatives compare? Proxycurl was credit-based, from $0.020 a credit on its $49 Starter tier down to $0.009 at volume. Real-time scrapers run about $1.50–$4 per 1,000 profiles, datasets from fractions of a cent to ~$0.20 per record, and account-based APIs charge a flat rate per connected account – the tier-by-tier comparison is above. #### How do I migrate from Proxycurl? Abstract your data source behind an interface, pick a replacement model by use case, map your fields, move contact lookups to consented enrichment, and backfill lost records. Most profile shapes are similar enough that the swap is a boundary-layer change, not a rewrite. --- Were you using Proxycurl for live profile reads and the ability to act? [Linked API](/pricing) runs your own authenticated account and returns clean JSON for people, companies, posts, and search – with no shared scraped database to disappear overnight. ## LinkedIn Cold Outreach That Actually Works: 7 Data-Driven Strategies The large majority of generic cold messages get ignored. Here's what the few that succeed do differently. You send 50 connection requests per week. Maybe 10 people accept. Two respond. Zero meetings booked. The problem isn't LinkedIn – it's your approach. Generic spray-and-pray outreach delivers weak response rates – often only a couple of percent. Sales teams track vanity metrics (messages sent) instead of what matters: meetings booked and deals closed. This article is different. Everything here is backed by real conversion data tested across 50,000+ LinkedIn outreach messages. You'll learn a data-driven framework that boosts response rates to 5-7% – a 3.5x improvement over generic templates. ## Why Most LinkedIn Cold Outreach Fails LinkedIn cold outreach has a failure rate problem. But the failure modes are predictable – and fixable. ### Failure Mode #1: Generic Messaging > 📈 Generic connection requests ("I'd like to connect") achieve 15-20% acceptance rates. Personalized requests referencing specific profile details hit 40-60%. That's a 3x difference from one small change. Yet most outreach still looks like this: *"Hi [Name], I help [Industry] companies with [Generic Value Prop]. Let's connect!"* Prospects see through templated messages instantly. They know you sent the same note to 50 other people. There's no reason to respond – you haven't shown genuine interest in their specific situation. ### Failure Mode #2: Single-Touch Approach > 📈 Most prospects need several touchpoints before they're ready to engage. Single-message outreach converts at 1-2%. Multi-touch sequences hit 5-7%. Most salespeople send one connection request, maybe one follow-up, then move on. They're giving up right when the prospect is starting to recognize their name. The psychology is simple: familiarity breeds trust. One cold message from a stranger triggers skepticism. Five strategic touchpoints over three weeks build recognition and credibility. ### Failure Mode #3: Poor Timing > 📈 Connection requests sent within 48 hours of prospect activity (posting content, changing jobs) convert 2.3x better than arbitrary outreach. Timing isn't just about day of week or time of day. It's about relevance windows. When someone just published a post about a challenge you solve, they're thinking about that problem right now. That's your window. Sending a connection request three weeks later? The moment has passed. The attention has moved on. ### Failure Mode #4: Vanity Metrics Focus Here's the real problem: most teams track vanity metrics like "connection requests sent," and far fewer track "meetings booked per 100 requests." You optimize what you measure. If you're tracking volume (requests sent, messages delivered, profile views), you'll optimize for volume. You'll send more generic messages to more people. If you track outcomes (acceptance rate, response rate, meeting rate), you'll optimize for quality. You'll personalize more, test more, and improve conversion at each stage. > 📈 Teams tracking outcome metrics achieve 3.1x better pipeline contribution than teams tracking vanity metrics. ### The Real Problem: Spray-and-Pray Thinking All four failure modes stem from the same root cause: treating LinkedIn outreach as a numbers game instead of a relationship-building process. Spray-and-pray assumes more volume = more results. Send 200 generic messages, get 2-3 meetings. It's mathematically tempting but strategically broken. Data-driven outreach flips the model: send 50 highly personalized messages, get 15-20 meetings. Less volume, 10x better results. The rest of this guide shows you how to make that shift. ## The Data-Driven Approach: What It Means Data-driven outreach isn't about having fancy analytics dashboards (though those help). It's about making decisions based on what actually converts, not what sounds good in a LinkedIn post. Here's what changes when you go data-driven: ### 1. You Track Full-Funnel Conversion Rates Instead of tracking "messages sent," you track the complete funnel: - **Connection requests sent** → **Accepted** (acceptance rate) - **Accepted** → **Responded** (response rate) - **Responded** → **Meeting booked** (meeting rate) - **Meeting** → **Deal created** (deal conversion) ![The LinkedIn outreach conversion funnel: connection requests sent narrow to accepted, responded, meeting booked, and deal created, with the rate to track at each stage](/images/guides/linkedin-cold-outreach-funnel.webp) Now you can see exactly where prospects drop off. If your acceptance rate is 50% but your response rate is 5%, you know the problem: your first message after connecting isn't compelling. Fix the bottleneck, measure the improvement, repeat. ### 2. You A/B Test Everything Anecdotal advice says "be conversational" or "lead with value." Data-driven teams test it. - Does a conversational tone or professional tone work better for C-level prospects in fintech? - Does asking a question or offering a resource get more responses? - Does a short connection note (50 characters) or detailed note (200 characters) perform better? You don't guess. You test with 50 prospects per variant, measure the results, and scale what wins. > 📈 Sales teams that A/B test messaging achieve 2.4x higher response rates than teams using static templates. ### 3. You Use Behavioral Data for Personalization Generic personalization: *"* > Hi {{Name}}, I see you work at {{Company}}... Data-driven personalization: > Hi Sarah, saw your recent post about AI adoption in sales ops – your point about data quality resonated with our experience at... The difference? You're using real behavioral data (recent posts, job changes, content engagement) to show genuine relevance, not just filling in template variables. > 📈 Messages personalized with recent activity convert 5.4x better than name-only personalization. ### 4. You Optimize Continuously Data-driven outreach isn't "set it and forget it." You run weekly reviews: - What's the current acceptance rate? (**Target: >40%**) - What's the response rate? (**Target: >25%**) - What's the meeting conversion? (**Target: >15%**) - Which message variants are winning? - Where are prospects dropping off? Based on the data, you adjust targeting, tweak messaging, or change timing. Each week gets incrementally better. **Why this beats spray-and-pray:** Spray-and-pray is static. You send the same template to everyone, hoping volume compensates for low conversion. Data-driven outreach improves every week. Your 40% acceptance rate becomes 50%, then 55%. Your 20% response rate becomes 30%. ## Strategy #1: Start with Deep Profile Research > 📈 Connection requests with personalized notes referencing specific profile details achieve 3.2x higher acceptance rates than generic "I'd like to connect" messages. ### Why Deep Research Works When you reference specific details from someone's profile – a recent post, a shared interest, a mutual connection – you trigger reciprocity (you invested time researching them) and relevance (you understand their world). Generic templates signal the opposite: "I sent this to 100 people." Prospects ignore that instantly. ### How to Implement You don't need 30 minutes per prospect. You need the right data points: recent posts, job changes, shared interests, mutual connections. Then reference specific insights, not just topics. **Weak:** *"Hi Sarah, I saw your recent post about sales automation."* **Strong:** *"Hi Sarah, saw your post about AI in sales ops – your point about data quality being the real bottleneck resonated."* The second example proves you actually read the post and understood the nuance. ### Scaling with Automation Use [Linked API's Fetch Person method](/docs/action-st-open-person-page?surface=sdk) to pull profile data automatically (recent posts, job changes, mutual connections), store it in your CRM with dynamic variables, and generate personalized connection notes: > Hi {{FirstName}}, saw your recent post about {{PostTopic}} – {{ContextualComment}}.I work with {{CompanyType}} companies on similar challenges. Would love to connect. **Expected results:** - Generic template: 15-20% acceptance rate - Data-enriched personalization: 45-60% acceptance rate Automation handles data collection. You control message quality and relevance. ## Strategy #2: Multi-Touch Warming Before Pitching > 📈 Prospects who received 3+ touchpoints before a connection request had 2.7x higher acceptance rate and 4.1x higher response rate than cold single-touch outreach. ### Why Multi-Touch Warming Works Single-touch outreach triggers skepticism: "Who is this person?" Multi-touch warming triggers recognition: "I've seen this name before. They engaged with my content." The psychology: [mere exposure effect](https://en.wikipedia.org/wiki/Mere-exposure_effect) – repeated exposure increases liking and trust over time. When someone sees your name three times before you ask to connect (profile visit, post like, comment), you're not a stranger anymore. Recognition lowers resistance. ### How to Implement A typical warming sequence: 4-5 low-stakes touchpoints over 1-2 weeks: 1. **Visit their profile** (Day 1) – appears in their viewer list and may notify them, planting your name. This only works if your [profile viewing options](/guides/linkedin-private-mode) show your name rather than private mode 2. **Like their recent post** (48h later) – reinforces your name 3. **Comment on their post** (optional, 48h later) – adds value, positions you as knowledgeable 4. **Send personalized connection request** (2-3 days later) – reference the post in your note 5. **Send value-add message after acceptance** (2-3 days later) – don't pitch immediately ### Automating with Linked API Build workflows using n8n, Make, or Zapier: 1. [Visit Profile](/docs/action-st-open-person-page) → delay 48h 2. [Like Post](/docs/action-st-react-to-post) → delay 48-72h 3. [Send Connection Request](/docs/action-st-send-connection-request) → wait for acceptance 4. [Send Message](/docs/action-st-send-message) after 2-3 days (value-add, not pitch) For the connection-request mechanics in code – send, check acceptance, and withdraw stale invites – see [How to Automate LinkedIn Connection Requests](/guides/how-to-automate-linkedin-connection-requests); for the message-and-read-reply flow, see [How to Automate LinkedIn Messages](/guides/how-to-automate-linkedin-messages). This guide owns the strategy – what to say and when; those own the mechanics. **Expected results:** - Single-touch cold request: 15-20% acceptance, 10% response - Multi-touch warming: 40-50% acceptance, 30-40% response ## Strategy #3: Personalize Your Offer Based on Company-Specific Needs > 📈 Messages with company-specific value propositions tailored to actual business context and workflows convert 5.4x better than generic pitches. Real personalization isn't about mentioning someone's LinkedIn post. It's about demonstrating you understand what your product solves for their specific company based on their actual situation, workflows, and friction points. ### Why Offer Personalization Works Generic pitch: > Our tool helps sales teams automate LinkedIn outreach. Personalized offer (example for DevOps SaaS): > I noticed your team uses Happy CRM for inbound lead management. We help teams like yours generate automated lead files from LinkedIn outreach and push them directly to S3 buckets for automatic Happy CRM import – cutting manual CSV uploads from 2 hours/week to zero. The difference? The second example shows you understand their specific workflow (Happy CRM → S3 import), identify a real friction point (manual CSV uploads), and explain exactly how your product fits their situation. This approach works regardless of industry – the key is demonstrating you understand their specific challenges. ### How to Implement Offer Personalization at Scale **Step 1: Identify data points relevant to YOUR product and industry** What data you need depends entirely on what you sell and who you sell to. There's no universal "tech stack" checklist – it's specific to your value proposition. **Examples:** - DevOps SaaS tool → CRM used, automation tools, integration stack, current data flows - Agricultural equipment → hectares owned, crop types, geographic region, existing machinery - Real estate services → property portfolio size, geographic focus, recent transactions - HR software → company size, hiring velocity, ATS used, remote/hybrid policies The pattern: identify data that reveals **specific friction points your product solves**. **Step 2: Map your product's value to their specific situation** Create personalization templates based on the data points you identified in Step 1: **Example: If prospect uses Happy CRM:** > I noticed your team uses Happy CRM for inbound leads. We help teams automate LinkedIn outreach lead export → S3 bucket integration → automatic Happy CRM import. Eliminates the manual CSV upload step entirely. Relevant for your workflow? **Example: If prospect uses Salesforce + Zapier:** > Saw you're running Salesforce with Zapier. We plug into that exact stack – LinkedIn leads flow directly to Salesforce via Zapier webhook, maintaining field mapping and deduplication. Would this close a gap in your current outreach process? **Step 3: Use** [**Linked API's Fetch Company method**](/docs/action-st-open-company-page?surface=sdk) **to automate research** Pull company-level data relevant to your product. Examples vary by industry: - Tech/SaaS → technologies mentioned, recent hiring signals, job postings revealing needs - Agriculture → company size, geographic mentions, equipment/technology discussed - Real estate → property mentions, market focus, transaction activity - General → company size, growth signals, recent news/announcements **Step 4: Generate tailored value propositions** Instead of generic benefits, explain the specific solution to their situation: > Hi {{FirstName}},Noticed {{CompanyName}} {{SpecificSituation}}.Quick question: are you currently {{CurrentProcess}},or do you have {{DesiredState}}?We help teams {{SpecificSolution}} – {{QuantifiedBenefit}}.Relevant for your {{Context}}? Example variables for a DevOps SaaS tool: - `{{SpecificSituation}}` = "uses Happy CRM for lead management" - `{{CurrentProcess}}` = "exporting LinkedIn leads manually" - `{{DesiredState}}` = "an automated pipeline into Happy CRM" - `{{SpecificSolution}}` = "push LinkedIn leads directly to S3 for automatic Happy CRM import" - `{{QuantifiedBenefit}}` = "cuts manual CSV uploads from 2 hours/week to zero" - `{{Context}}` = "workflow" ### Practical Example: Happy CRM + S3 Integration Workflow **Scenario:** You sell a LinkedIn automation tool. Your prospect uses Happy CRM, which imports leads from S3 buckets automatically. **Your personalized offer:** > "Hi Michael,Saw that your team at {{Company}} uses Happy CRM for inbound lead management. Quick question: when your SDRs generate leads from LinkedIn outreach, are they manually exporting CSVs and uploading to S3, or do you have that automated?We integrate directly with Happy CRM's S3 import workflow – LinkedIn leads flow automatically to your bucket in Happy CRM's required format (JSON schema with contact fields + interaction history). Eliminates the manual export/upload step.Would this close a workflow gap for your team?" **Why this works:** - Shows you researched their specific situation (Happy CRM in this case) - Identifies a real friction point (manual CSV → S3 uploads) - Explains exact solution specific to their setup (S3 bucket, JSON schema) - Positions your product as a tailored solution, not a generic tool **Expected results:** - Generic pitch: 15-20% response rate - Context-personalized offer: 45-55% response rate ### The Key: Sell Specific Solutions, Not Generic Features Don't say "We do LinkedIn automation." Say "We integrate LinkedIn outreach data directly into your Happy CRM → S3 pipeline" (or whatever is relevant to THEIR specific context). Specificity proves relevance. ## Strategy #4: Timing Matters – When to Reach Out > 📈 Connection requests sent Tuesday-Thursday between 8-10 AM achieve 1.8x higher acceptance rates than weekend or evening requests. Requests sent within 48 hours of profile activity convert 2.3x better than arbitrary timing. ### Why Timing Works **Professional hours:** Tuesday-Thursday, 8-10 AM is when prospects are in work mode – checking LinkedIn, reviewing notifications, engaging with content. Weekends and late evenings? Personal time. Professional outreach feels intrusive. **Activity-based timing:** When someone just published a post about a challenge you solve, they're thinking about that problem right now. That's your relevance window. The psychology: attention availability + recency. Send a request three weeks later? The moment has passed. ### How to Implement Activity-Based Timing Trigger outreach based on prospect behavior: 1. **Published a post** (+2.3x acceptance) – send within 24-48 hours, reference the post 2. **Changed jobs** (+2.8x acceptance) – send within first 30 days, congratulate them 3. **Shared/commented on content** (+1.9x acceptance) – engage with same content, send request 24-48h later 4. **Company news** (funding, launch, expansion) (+1.5x acceptance) – reference the news ### Automating Activity Monitoring Use [Linked API's Fetch Person](/docs/action-st-open-person-page?surface=sdk) to monitor activity daily: - IF recent post exists → [Like Post](/docs/action-st-react-to-post?surface=sdk) → delay 24h → [Send Connection Request](/docs/action-st-send-connection-request?surface=sdk) - IF job change detected → delay 7 days → send congratulations + request **Expected results:** - Arbitrary timing: 25-30% acceptance - Activity-triggered: 50-60% acceptance ## Strategy #5: Track Real Metrics > 📈 Most teams track "requests sent" (vanity metric) while far fewer track "meetings booked per 100 requests" (outcome metric). Teams tracking outcome metrics achieve 3.1x better pipeline contribution. ### Why Outcome Metrics Work **Vanity metrics** (requests sent, profile views) measure activity, not revenue. You can send 500 requests and book zero meetings. **Outcome metrics** (acceptance rate, response rate, meeting rate) measure results that correlate with pipeline and revenue. When you track outcome metrics, you optimize for what actually matters: turning cold prospects into qualified conversations into closed deals. ## Strategy #6: A/B Test Your Messaging and Approach > 📈 Sales teams that A/B test messaging achieve 2.4x higher response rates and 1.9x more meetings booked than teams using static templates. ### Why A/B Testing Works Static templates assume one message fits all audiences. A/B testing reveals what actually resonates with your specific audience based on real behavior, not assumptions. ### Testing Rules 1. **Test one variable at a time** – message angle OR personalization depth OR CTA type (not all at once) 2. **Use statistical significance** – minimum 50 prospects per variant, ideally 100+ 3. **Segment by ICP** – what works for enterprise buyers might not work for SMB buyers 4. **Give tests time** – minimum 2 weeks before calling a winner ### What to Test **High-impact variables:** - **Message angle** – problem-solution vs question-based vs value-first (2-3x response lift) - **Personalization depth** – basic (name/company) vs contextual (recent activity) vs advanced (content engagement) (2-3x acceptance lift) - **CTA type** – direct meeting ask vs value offer vs question (1.5-2x response lift) **Example:** Split 100 prospects into two groups (50 each). Test problem-solution opening vs question-based opening. Keep everything else constant. After 2-3 weeks, scale the winner, retire the loser. Each test reveals 10-20% improvement. After 6 tests, you've compounded small improvements into massive overall gains. That's how teams go from 20% to 55% acceptance rates in 6 months. ## Strategy #7: Follow Up Strategically > 📈 Most sales require several touchpoints, yet many reps give up after one follow-up. Prospects who received 3+ follow-ups had 4.6x higher meeting conversion than single-message outreach. Strategic follow-ups aren't optional. They're where most of your results come from. ### Why Follow-Ups Work When a prospect doesn't respond to your first message, it usually doesn't mean "no." It means: "I'm busy," "I didn't see it," "I need to think," or "Timing isn't right this week." Strategic follow-ups increase your chances of catching them when they have bandwidth to engage. ### Follow-Up Rules 1. **Space follow-ups 3-7 days apart** – daily follow-ups = spam; weekly follow-ups = strategic persistence 2. **Add value in every follow-up** – share new resource, reference new data, offer different help (don't just bump the thread) 3. **Know when to stop** – after 4-5 follow-ups with zero response, move on ### 4-Touch Follow-Up Framework **Follow-up #1 (Day 3):** Add new value (share insight, article, case study). Expected response: 5-10% **Follow-up #2 (Day 7):** Share different resource (tool, template). Acknowledge they're busy. Expected response: 3-7% **Follow-up #3 (Day 14):** Pattern interrupt. Acknowledge timing might be off. Create exit ramp. Expected response: 2-5% **Follow-up #4 (Day 21) – Breakup:** Give permission to decline. One final value offer ("before I close this..."). Expected response: 3-8% (highest due to scarcity psychology) ### Why Breakup Messages Work The final "I'll stop bothering you" message often gets the highest response rate. Psychology: scarcity ("this is your last chance"), permission (giving them an out makes them more likely to engage), and contrast effect (after multiple value-add messages, you've built goodwill). ### Automating Follow-Ups Use [Linked API](/) to automate the sequence: - Day 3: Check for response → if none, send Follow-up #1 - Day 7: Check for response → if none, send Follow-up #2 - Day 14: Check for response → if none, send Follow-up #3 - Day 21: Check for response → if none, send Follow-up #4 (breakup) → remove from sequence **Expected results:** - Single message: 10-15% response rate - 4-touch sequence: 40-50% cumulative response rate ## Best Practices & Common Mistakes ### 👍 DO: - **Start with small test batches** – Validate message quality with 20-30 prospects before scaling - **Personalize using real data** – Reference recent posts, job changes, tech stack using [Linked API's Fetch Person](/docs/action-st-open-person-page?surface=sdk) - **Build multi-touch warming** – Visit profile → engage with content → wait 48h → send connection request - **Track outcome metrics** – Acceptance rate (>40%), response rate (>25%), meeting rate (>15%) - **Follow up strategically** – 4-touch sequence over 3-4 weeks, add value in every message - **Use automation for scale** – [Linked API](/) automates data collection and scheduling while maintaining personalization ### 👎 DON'T: - **Scale volume before testing** – Scaling a broken process burns through your prospect list faster - **Use generic templates** – One-size-fits-all messaging looks automated and gets ignored - **Pitch immediately after connecting** – Wait 2-3 days, send value-add message first - **Track only vanity metrics** – Volume ≠ results; track acceptance rate, response rate, meeting rate - **Ignore follow-ups** – most of your value comes from follow-ups #3-5 - **Automate without personalization** – Mass sending identical messages triggers LinkedIn spam detection ## Frequently Asked Questions (FAQ) #### What is the average LinkedIn cold outreach acceptance rate? Generic LinkedIn cold outreach achieves 20-30% acceptance rates. Data-driven personalized outreach achieves 50-60% – a 2-3x improvement. Key factors: deep profile research, timing based on activity signals (send within 48 hours of their post), multi-touch warming, and contextual personalization. If your acceptance rate is below 30%, your outreach is too generic. #### How many touchpoints does successful LinkedIn outreach require? 5-7 touchpoints spread over 2-4 weeks. Most prospects don't respond to single-message outreach. Typical sequence: profile visit → like/comment on content (48h later) → connection request (48-72h later) → first message after acceptance (2-3 days later) → 3-4 follow-ups. Single-touch outreach converts at 1-2%. Multi-touch sequences convert at 5-7% – a 4-6x improvement. #### Can I automate LinkedIn cold outreach without getting banned? Yes, if you use workflow-based automation that simulates human behavior. Safety rules: respect connection limits (50-100 requests/week max), use realistic delays (2-5 minutes between actions), personalize with real data, avoid bulk patterns, and monitor account health. [Linked API](/) provides cloud browsers with unique digital identities, built-in human behavior simulation, and automatic rate limiting. For detailed limits, see our [LinkedIn Limits Guide](/guides/understanding-linkedin-limits). #### What metrics should I track for LinkedIn outreach campaigns? Track outcome metrics, not vanity metrics. Focus on: Acceptance Rate (target >40%), Response Rate (target >25%), Meeting Rate (target >15%). Don't track: requests sent, profile views, messages delivered. Use [Linked API's Stats Method](/docs/checking-api-usage-statistics?surface=sdk) to pull data automatically and calculate weekly. #### How do I personalize LinkedIn outreach at scale? Use [Linked API's Fetch Person method](/docs/action-st-open-person-page?surface=sdk) to extract profile data automatically (recent posts, job changes, mutual connections). Integrate with AI (ChatGPT, Claude) to generate contextual messages using extracted data. Send via Linked API with realistic delays. Data-enriched personalization achieves 50-60% acceptance rates vs 15-20% for generic templates. #### What's the best time to send LinkedIn connection requests Tuesday-Thursday, 8-10 AM in prospect's timezone converts 1.8x better than evenings/weekends. Activity-based timing is even better: send within 24-48 hours of their recent post (+2.3x acceptance) or job change (+2.8x acceptance). Use [Linked API](/) to monitor prospect activity and trigger outreach automatically when they're most receptive. #### How many follow-up messages should I send before giving up? 3-4 follow-up messages over 3-4 weeks. Most meetings come from follow-ups #3-5. Sequence: Day 3 (add new value, 5-10% response), Day 7 (share resource, 3-7% response), Day 14 (pattern interrupt, 2-5% response), Day 21 (breakup message, 3-8% response). Space messages 3-7 days apart, add value in every message, stop after 4-5 touches with no response. ## LinkedIn Connection Limit 2026: Weekly Caps and When They Reset You've sent 201 connection requests this week, and suddenly LinkedIn blocks you. The message is vague, the timeline unclear, and panic sets in. This happens because LinkedIn's limits aren't what most guides claim. There's no universal "200 per week" rule – your actual capacity depends on your account reputation, not a fixed number. This guide decodes how LinkedIn's limit system really works through a myth-busting approach, so you can work strategically within your constraints. For technical deep-dives and other LinkedIn limits, see our [detailed limits guide](/guides/understanding-linkedin-limits). ## Myth #1: Everyone Has the Same 200/Week Connection Limit ### ✅ Truth: Limits Are Dynamic, Based on Account Reputation LinkedIn doesn't use fixed categories – your reputation exists on a **continuous gradient**. Your actual weekly capacity depends on account age, activity consistency, acceptance rate, and engagement quality – not a single fixed number. For clarity, we can think of this gradient in **three broad ranges**: | Reputation | Daily Limit | Weekly Limit | Key Factors | | --- | --- | --- | --- | | **New** | ~10-15 | ~50-75 | Recently created, minimal activity history | | **Established** | ~20-25 | ~100 | Active 3-12 months, regular posting/engagement | | **Trusted** | ~30-40 | Up to 200 | Long history, high acceptance rate (>40%), strong SSI | > ✍️ These ranges aren't rigid categories – your limit fluctuates continuously based on recent behavior and engagement patterns. ### The 500 Pending Request Rule Beyond weekly sending limits, LinkedIn watches how many of your invitations sit unanswered – its [help pages](https://www.linkedin.com/help/linkedin/answer/a551012) list "many of your invitations have been ignored, left pending" among restriction triggers, without publishing a numeric cap. As a community-observed guardrail, keep outgoing (pending) connection requests under **500** (restrictions are commonly reported around 700). Exceeding 500 pending requests signals poor targeting and low acceptance rate. This hurts your reputation faster than hitting weekly send limits. > ✍️ **Best practice:** withdraw unaccepted connection requests after 2-3 weeks to maintain account health. You can [automate the whole send-track-withdraw lifecycle](/guides/how-to-automate-linkedin-connection-requests). ## Myth #2: Buying LinkedIn Premium Increases Your Connection Limits ### ✅ Truth: Premium Doesn't Affect Connection Request Limits **Premium, Sales Navigator, and Recruiter Lite do NOT increase your weekly connection request capacity.** Your limits remain reputation-based regardless of subscription tier. **Why the confusion?** Premium users appear to send more requests because they have better targeting tools (InMail, advanced search filters), not higher connection limits. If your only goal is increasing connection capacity, Premium won't help. Focus on building account reputation instead. ## Myth #3: Weekly Limits Reset Every Monday ### ✅ Truth: LinkedIn Uses Rolling 7-Day Windows LinkedIn's limits reset **7 days from when you send each request**, not on calendar weeks (Monday-Sunday). This creates a rolling window that tracks your last 7 days of activity at any given moment. **Example scenario:** - **Monday:** Send 30 requests - **Tuesday:** Send 40 requests (rolling 7-day total: 70) - **Wednesday:** Send 50 requests (rolling 7-day total: 120) - **Following Monday:** Only the 30 requests from previous Monday "expire" from your count, not all 120 ![LinkedIn's rolling 7-day connection-request window: 30 sent Monday, 40 Tuesday, 50 Wednesday reach a rolling total of 120, and the next Monday only the first 30 expire, dropping the count to 90](/images/guides/linkedin-connection-limit-rolling-window.webp) **Why this matters:** Bulk-sending 100+ requests on Monday morning burns through your weekly quota immediately and triggers spam detection. Spread requests evenly throughout the week (20-30 per day) for consistent capacity and natural behavior patterns. ## Myth #4: Once You Hit the Limit, You're Blocked for Exactly 7 Days ### ✅ Truth: Restriction Types and Timelines Vary Hitting the invitation limit does not put you on a fixed seven-day clock. LinkedIn treats invitation restrictions separately from policy restrictions, and per its [invitation restrictions page](https://www.linkedin.com/help/linkedin/answer/a551012), "Most restrictions will automatically be removed within one week" – and "LinkedIn won't be able to remove invitation restrictions upon request", so contacting Support will not speed it up. For the full picture – which restriction type you have, the documented route back for each, and which durations LinkedIn actually publishes – see our guide to a [restricted LinkedIn account](/guides/linkedin-account-restricted). ## Myth #5: It's Impossible to Track Your Limits and Reputation Signals ### ✅ Truth: Automated Monitoring Is Straightforward with Linked API Manually tracking connection limits requires logging into LinkedIn daily, counting pending requests, calculating acceptance rates, and checking your SSI score separately – tedious, time-consuming, and error-prone. **Linked API provides dedicated actions** to automate this monitoring: - [**Retrieve SSI**](/docs/action-st-retrieve-ssi?surface=sdk) – Automatically pulls your current Social Selling Index (SSI) score*.* - **Use case:** Track SSI weekly to monitor engagement quality. Drops in SSI often signal declining reputation or approaching spam territory. - [**Retrieve Performance**](/docs/action-st-retrieve-performance?surface=sdk) – Gets LinkedIn dashboard analytics (profile views, search appearances, post engagement)*.* - **Use case:** Monitor overall account health. Declining performance metrics (fewer profile views, lower engagement) often precede connection limit restrictions. - [**Get Actions Statistics**](/docs/checking-api-usage-statistics?surface=sdk) – Returns requests sent, acceptance rate, and pending request count*.* - **Use case:** Calculate real-time acceptance rate and pending ratio to maintain reputation. Essential for catching problems early. **Workflow Example (n8n/Make/Zapier):** ``` Weekly Account Health Check: 1. Schedule trigger: Every Monday 9 AM 2. Retrieve SSI → Log to Google Sheets 3. Get Actions Statistics → Calculate: - Acceptance rate (last 30 days) - Pending requests count - Requests sent (rolling 7 days) 4. Conditional alerts: - IF acceptance rate <30% → Slack: "🚨 Slow down outreach" - IF pending requests >400 → Slack: "⚠️ Withdraw old requests" - IF rolling 7-day total >150 → Slack: "⏸️ Approaching limit" ``` **Why proactive monitoring prevents blocks:** Users who track these metrics proactively rarely experience hard blocks. You catch declining reputation signals before LinkedIn restricts you, adjust strategy in real-time based on data (not guesswork), and maintain long-term account health rather than reactive damage control. ## Myth #6: You Need 'Hacks' to Scale Beyond Limits ### ✅ Truth: Quality Over Quantity Wins Every Time **Case study comparison:** - **Approach A:** 50 personalized requests/week with 60% acceptance rate = 30 new connections - **Approach B:** 200 generic requests/week with 15% acceptance rate = 30 new connections Same outcome, but Approach A maintains account reputation and avoids restrictions. Approach B burns through your limit, tanks acceptance rate, and risks hard blocks. ### Smart Strategies Within Limits **1. Multi-touch engagement before connecting** Warm up prospects before sending requests. Sequence: Visit profile → Like/comment on recent post → Wait 2-3 days → Send personalized connection request mentioning shared interest or recent content. > ✍️ Higher acceptance rates from warm prospects improve your reputation, which gradually increases your limits over time. **2. Tight ICP targeting** Define your Ideal Customer Profile precisely. Use LinkedIn's advanced search filters (job title, company size, industry, seniority) to target only high-fit prospects. > ✍️ Better targeting = higher acceptance rate = better reputation = higher limits. This creates a positive feedback loop. **3. Consistent pacing (not bulk sending)** Send 20-30 requests per day consistently instead of 150 on Monday followed by nothing. LinkedIn's algorithm rewards natural, consistent behavior patterns. > ✍️ Bulk activity triggers spam detection regardless of your limits. **Linked API workflow example:** ``` Smart Outreach (30-40 requests/week, 50-70% acceptance): 1. Search People (Linked API) → Filter by ICP criteria 2. For each prospect: - Fetch Person → Get recent activity, posts, shared interests - Visit profile (simulated engagement) - IF they posted in last 7 days: - Extract post content - Generate contextual comment (ChatGPT integration) - Comment on Post (Linked API) - Wait 2-3 days (natural pacing) - Generate personalized connection note mentioning shared interest - Send Connection Request (Linked API) Result: 50-70% acceptance rate vs. 15-20% from generic templates ``` > ✍️ Automation doesn't mean sending more requests. It means personalizing at scale while respecting your limits and maintaining account health. For the connect → check-acceptance → message flow in code, see [How to Automate LinkedIn Messages](/guides/how-to-automate-linkedin-messages); to automate sending, tracking, and withdrawing the requests themselves, see [How to Automate LinkedIn Connection Requests](/guides/how-to-automate-linkedin-connection-requests). ## Best Practices & Common Mistakes ### 👍 DO: - **Know your reputation level** – Set realistic weekly targets: New accounts (~50-75/week), Established (~100/week), Trusted (up to 200/week) - **Target your ICP tightly** – Higher acceptance rate improves reputation and gradually increases your limits over time - **Warm up prospects first** – Multi-touch engagement (profile visit, post comment) before connecting boosts acceptance rate - **Personalize every request** – Custom notes dramatically increase acceptance (even Free accounts get a limited number of personalized invitations per month, per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a563153)) - **Monitor metrics weekly** – Track SSI, acceptance rate, and pending count using [Linked API](/) - **Maintain >40% acceptance rate** – LinkedIn rewards high acceptance rates with better reputation and potentially higher limits - **Withdraw old pending requests** – Keep pending count under 400-500; withdraw requests older than 2-3 weeks - **Spread requests evenly** – Send 20-30 per day consistently instead of 150 on Monday ### 👎 DON'T: - **Bulk send 100+ on Monday morning** – Unnatural behavior pattern triggers spam detection algorithms - **Ignore pending request ratio** – Exceeding 500 pending requests signals poor targeting and hurts reputation - **Use generic templates** – Low acceptance rates from "I'd like to connect" messages limit future capacity - **Skip metric monitoring** – You'll miss early warning signs before LinkedIn restricts your account - **Connect with everyone** – Shotgun approach tanks acceptance rate and wastes limited request slots - **Buy Premium for higher limits** – Premium doesn't increase connection limits (it only adds InMail, custom notes, advanced search) ## Frequently Asked Questions (FAQ) #### What is the LinkedIn connection limit in 2026? There's no single universal limit. LinkedIn uses a **reputation-based gradient** – your capacity ranges from ~50-75 requests/week for new accounts, ~100/week for established accounts, up to 200/week for highly trusted accounts. Your actual limit depends on account age, activity consistency, and acceptance rate – not fixed categories. #### Does LinkedIn Premium increase connection request limits? No. Premium subscriptions don't affect connection request limits at all. Premium adds [InMail credits](/guides/linkedin-inmail) (direct messaging without connecting), unlimited custom notes on requests, advanced search filters, and Open Profile messaging – but your weekly connection request cap remains reputation-based regardless of subscription tier. #### How do I know what my actual connection limit is? LinkedIn doesn't publicly display your exact limit. Best approach: monitor key metrics (SSI score, acceptance rate, pending requests) using Linked API monitoring actions and test conservatively. Start with 15-20 requests per day and adjust based on whether you encounter restrictions. #### What happens if I exceed my connection limit? You lose the ability to send new invitations while everything else on the account keeps working. LinkedIn publishes one figure for this: "Most restrictions will automatically be removed within one week", and Support cannot lift one on request or tell you the reason. Longer or account-wide consequences belong to other restriction types – our guide to a [restricted LinkedIn account](/guides/linkedin-account-restricted) covers which type is which and the documented route back for each. #### Can I track my SSI score and acceptance rate automatically? Yes, using Linked API's dedicated actions: [Retrieve SSI](/docs/action-st-retrieve-ssi?surface=sdk) pulls your Social Selling Index score, [Retrieve Performance](/docs/action-st-retrieve-performance?surface=sdk) gets dashboard analytics, and [Get Actions Statistics](/docs/checking-api-usage-statistics?surface=sdk) calculates acceptance rate and pending count. Automate these in workflows (n8n, Make, Zapier) to monitor proactively. #### What is a good acceptance rate for LinkedIn connection requests? Aim for **>40% acceptance rate**. LinkedIn rewards high acceptance rates with better account reputation and potentially higher limits over time. Below 30% signals poor targeting and may trigger restrictions. Track acceptance rate weekly to catch declining trends early. #### How many pending connection requests is too many? Keep pending requests under **500** – a community-observed guardrail, since LinkedIn publishes no numeric cap; restrictions are commonly reported around 700. Exceeding 500 pending requests signals poor targeting and low acceptance rate, which hurts your reputation. Withdraw requests older than 2-3 weeks regularly to maintain account health and stay below this threshold. #### Can I bypass LinkedIn connection limits safely? No, there's no safe "bypass." The smart approach: work strategically within your limits by focusing on quality over quantity. Use multi-touch engagement (warm up prospects before connecting), tight ICP targeting (only connect with high-fit prospects), and personalization to maximize results with fewer requests. [Linked API](/) automates this smarter approach while respecting limits. ## LinkedIn Limits Explained: Connection, Message, Search and LinkedIn API Rate Limits When using [Linked API](/docs), it's necessary to understand the limitations of your LinkedIn account to avoid potential account restrictions or blocks. While LinkedIn doesn't publish official limits, this guide combines community knowledge and our hands-on expertise. If a restriction has already been applied to your account, start instead with our guide to a [restricted LinkedIn account](/guides/linkedin-account-restricted), which covers the documented types and the route back for each. > Account limits primarily depend on two factors: **subscription type** (Premium, Sales Navigator, Recruiter etc.) and **reputation level**. Throughout the article, we divide reputation into 3 virtual levels: | Reputation Level | Account Characteristics | | --- | --- | | New | Recently created with minimal activity and engagement. | | Established | Active for 3–12 months with growing engagement and network size. | | Trusted | Long-established with a strong presence and proven activity patterns. | While we use these division for simplicity, think of reputation as a continuous gradient rather than fixed categories. Consider [Social Selling Index (SSI)](/guides/linkedin-social-selling-index) as one of the indirect metrics to assess your account's reputation. ## Connection request limits Connection requests are limited on a rolling weekly basis: | Reputation Level | Daily Limit | Weekly Limit | Notes | | --- | --- | --- | --- | | New | ~10-15 | ~50-75 | Start with 5-10/day and gradually increase. | | Established | ~20-25 | ~100 | Standard LinkedIn weekly cap. | | Trusted | ~30-40 | Up to 200 | Requires high acceptance rate. | **Good to know:** - Keep outgoing (pending) connection requests under 500 (a community-observed guardrail – LinkedIn publishes no numeric cap, and restrictions are commonly reported around 700) by [withdrawing unaccepted requests](/docs/working-with-invitations). - Personal notes don't increase limits. Free accounts are limited to ~10 noted invites monthly, while any paid plan removes this restriction. - Monitor acceptance rates – higher acceptance rates may increase your limits. ## Direct message limits There is no strict limitation, but high volumes trigger spam flags. Here are our recommended limits: | Reputation Level | Daily Limit | Notes | | --- | --- | --- | | New | ~50 | Monitor closely for spam flags. | | Established | ~100 | Moderate and steady pace recommended. | | Trusted | ~100-150 | Requires high engagement rates. | **Good to know:** - Personalize messages to maintain higher allowed volumes. - Monitor response rates – ignored messages can lower limits. - Consistent engagement is better than irregular bulk sending. To automate messages and connection requests while staying inside these limits, see [How to Automate LinkedIn Messages](/guides/how-to-automate-linkedin-messages). ## InMail and Open Profile message limits InMail works differently from the behavioral limits on this page: allowances are plan-defined monthly credits (5–50 depending on subscription, more on Recruiter products), not reputation-based caps, and a credit is returned whenever the recipient responds within 90 days. The full breakdown – credits and accumulation caps per plan, the credit-back rule, purchase rules, character limits, and the Open Profile exception that lets anyone message opted-in Premium members free – lives in the [LinkedIn InMail guide](/guides/linkedin-inmail). Pacing still applies here as everywhere: spread InMails across days even when credits are available, and keep Open Profile outreach at a steady, human volume – LinkedIn limits it without publishing a number. ## Company page view limits LinkedIn sets different limits for viewing company pages based on subscription type: | Subscription Type | Daily Views | Weekly Views | | --- | --- | --- | | Free | ~100 | ~700 | | Premium (all plans) | ~200 | ~1400 | | Sales Navigator (all plans), Recruiter (all plans) | ~200 / 1000 * | ~1400 / 7000* | **Good to know:** - Sales Navigator and Recruiter higher limits (1000/day) apply only when using their respective interfaces. - While there's no direct correlation with account reputation, your actual limits may slightly increase or decrease depending on the reputation level. ## Person profile view limits LinkedIn sets different limits for viewing person profiles based on subscription type: | Subscription Type | Daily Views | Weekly Views | | --- | --- | --- | | Free | ~80 | ~560 | | Premium (all plans) | ~150 | ~1050 | | Sales Navigator (all plans), Recruiter (all plans) | ~150 / 1000* | ~1050 / 7000* | **Good to know:** - Sales Navigator and Recruiter higher limits (1000/day) apply only when using their respective interfaces. - While there's no direct correlation with account reputation, your actual limits may slightly increase or decrease depending on the reputation level. - Whether those views are visible to the people you viewed is a separate setting – see [LinkedIn private mode](/guides/linkedin-private-mode). ## Search query limits LinkedIn officially defines **Commercial Use Limit (CUL)** for search operations, regardless of whether you're searching for people, companies, or other content. | Subscription Type | Monthly Limit | Daily Recommended | Notes | | --- | --- | --- | --- | | Free | 300 | ~10 | Resets on 1st day of calendar month. | | Premium Career | 300 | ~10 | Resets on 1st day of calendar month. | | Premium Business | Unlimited | - | No monthly cap. | | Sales Navigator (all plans), Recruiter (all plans) | Unlimited | - | No monthly cap. | **Good to know:** - When Commercial Use Limit (CUL) is reached, users receive a notice to wait until the next calendar month (resets on the 1st), per [LinkedIn Help](https://www.linkedin.com/help/linkedin/answer/a564226). - Only business-oriented subscriptions (Premium Business, Sales Navigator, Recruiter) remove the Commercial Use Limit. - Certain search types don't count towards CUL, such as viewing 1st-degree connections, job searches, or searching by specific names. ## Search results limits LinkedIn limits the number of search results displayed within one search session **based on the interface used, not subscription type**. Here's a comparison between LinkedIn interfaces: | Interface | People Search | Companies Search | | --- | --- | --- | | Standard LinkedIn | 1000 (100 pages × 10) | 1000 (100 pages × 10) | | Sales Navigator | 2500 (100 pages × 25) | 1000 (40 pages × 25) | | Recruiter | 1000 (100 pages × 10) | 1000 (100 pages × 10) | **Good to know:** - To bypass search results limit, break your search into narrower segments (e.g. by region or industry) so each query shows fewer than the limit maximum. - Viewing search results doesn't count towards profile/company view limits. These limits only restrict how many results are displayed per search query. ## Jobs browsing limits Unlike the surfaces above, no concrete jobs-specific limits have surfaced in the community yet, so automated job search and viewing are bounded mostly by behavioral rate limiting rather than fixed caps. | Operation | Limit | Notes | | --- | --- | --- | | Job search (Commercial Use Limit) | Exempt | Doesn't count towards the CUL, unlike people or company search, and isn't affected by subscription tier. | | Job search results per query | ~1000 (100 pages × 10) | Same display cap as people search; split into narrower searches to see more. | | Individual job views | No published limit | Opening a job posting isn't a profile or company page view and doesn't consume those quotas. | | Daily job applications (Easy Apply) | ~50 | Soft daily throttle for Free and Premium alike, plus a speed limit that briefly pauses Easy Apply on rapid submissions. | **Good to know:** - Pace matters more than counts: paging through hundreds of results or opening postings back-to-back still trips anti-automation systems despite the exemptions above. Keep a human-like pace of a few seconds between actions. - Reputation still governs safe volume – ramp up bulk job viewing gradually, just as you would profile views, especially on new accounts. ## Likes and comments limits LinkedIn monitors your likes and comments, focusing on frequency and quality of interactions rather than strict numerical limits. Here are approximate daily thresholds based on account reputation: | Reputation Level | Daily Likes | Daily Comments | | --- | --- | --- | | New | ~20-30 | ~10-15 | | Established | ~40-60 | ~20 | | Trusted | ~100 | ~30 | **Good to know:** - Regardless of your reputation level, start with lower volumes and gradually increase interactions over time to reach your limit organically. ## Post publishing limits While LinkedIn doesn't enforce strict posting limits, excessive posting may trigger spam filters or diminish overall reach. Here are recommended daily posting frequencies: | Reputation Level | Recommended Posts per Day | | --- | --- | | New | 1-2 | | Established | 3-5 | | Trusted | 5-7 | **Good to know:** - Focus on content quality over quantity: one engaging post typically generates better results than multiple average-quality posts. ## Other LinkedIn limits | Category | Limit | | --- | --- | | Total network connections | 30,000 | | Outgoing (pending) connection requests | ~700 (community-observed – no official cap published) | | Group memberships | 100 | | Connection request note length | 200 characters (Free), 300 (Premium) | | Standard message length | 8,000 characters | | InMail subject length (LinkedIn UI) | 200 characters | | InMail message length (LinkedIn UI) | 2,000 characters | ## LinkedIn API limits and rate limits Two different things share the name. The limits above are account limits – how much a LinkedIn account can do through the interface before LinkedIn restricts it – and they apply whether a person or an automation performs the actions. LinkedIn's official APIs have their own throttles, documented on [Microsoft Learn](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits): an application-level and a member-level quota per 24 hours, reset at midnight UTC, a `429` response when exceeded, and numbers that are not published – you read them per endpoint in the Developer Portal's Analytics tab after making at least one call. Those quotas only matter if you hold approved API access. An account-based route such as Linked API is bound by the account limits on this page instead, and lets you set your own per-action ceilings below them – see the [admin limits documentation](/docs/admin-limits) and the next section. ## Enforcing limits with Linked API Linked API allows you to configure action limits for each connected LinkedIn account directly from the [platform](https://app.linkedapi.io/). Limits are organized by action category (profile views, connection requests, messages, etc.) and time period (daily, weekly, monthly). When a workflow contains an action that would exceed a configured limit, the action will return a `limitExceeded` error instead of executing. This helps you stay within safe boundaries automatically, without needing to track usage manually. For more details on how errors are returned, see the [documentation](/docs/executing-workflows). > This guide reflects common LinkedIn limits based on community knowledge and hands-on experience. It should be used as a reference point rather than official guidance, as actual limits vary by account and may change over time. Experiment carefully with your own limits while monitoring account health and engagement metrics. --- # Comparisons ## Linked API vs Dripify (2026): Features, Pricing, and Safety Compared Linked API vs Dripify is a choice between two shapes of LinkedIn automation. Linked API is a programmable LinkedIn automation API: you compose workflows from primitives and run them from your own code or an AI agent, with the execution layer – a dedicated cloud browser per account, human pacing, enforced limits – built in. Dripify is a no-code tool for LinkedIn and email drip campaigns: you assemble a fixed sequence of steps in a visual builder and it runs in Dripify's cloud. > **The short version.** Both are cloud tools that automate your own account and let you set daily activity limits; they differ in how you connect and how the pacing is enforced. Linked API connects your account into its own dedicated cloud browser that behaves like a person – a real browser on LinkedIn's own pages, and Linked API never stores your password – and enforces the daily limits you set. Dripify instead takes your LinkedIn password and uses it to obtain your session cookies, then drives your account through those (a newer, optional Chrome extension lets you connect without entering the password in Dripify). Beyond safety, Linked API covers two jobs: embed LinkedIn automation into your own product through 55+ composable actions, or run it out of the box – a ready-made skill lets an AI agent build and run the workflows for you. Dripify fits sales, founder, and recruiting teams who want a ready-made drip-campaign builder with a built-in inbox and analytics, no code involved. Pick by the job, not the brand. ## Pick in 10 seconds :::cards **Choose Linked API if…** - You need automation that is not a fixed campaign: compose [55+ actions](/docs/actions-overview) – search, fetch profiles and companies, connect, message, react, post – into any logic, branching on real profile data or your CRM. - You want a real developer surface: a REST API, Node/Python SDKs, and a shell CLI to embed LinkedIn automation in your product – or a ready-made skill an AI agent runs out of the box. Dripify's public API and its new MCP server report on campaigns and upload leads – neither has endpoints that act on LinkedIn. - You want predictable cost: flat per-seat pricing with no usage metering, and per-action limits enforced in a dedicated cloud browser. **Choose Dripify if…** - You want a ready-made, no-code drip-campaign builder: assemble a sequence from 15+ actions and conditions in a visual editor, with nothing to code. - You need the campaign extras built in: sequence templates, a unified reply inbox, analytics dashboards, and team management, plus an email channel alongside LinkedIn. - A standard drip campaign is exactly the job – you are not trying to build automation into your own product or branch on custom logic. ::: ## Linked API vs Dripify at a glance | | Linked API | Dripify | |---|---|---| | What it is | Programmable LinkedIn automation API | No-code LinkedIn + email drip-campaign tool | | Model | Compose 55+ actions into any workflow – from your code or an AI agent | Assemble a fixed sequence of steps in a visual campaign builder | | Developer surface | REST API, Node/Python SDKs, shell CLI, MCP server, agent skills | Reporting API and an MCP server (Pro and Advanced) over campaigns, leads and teams, read-only apart from uploading leads; no endpoints that act on LinkedIn; plus Zapier, Make and outbound webhooks | | Channels | LinkedIn, including Sales Navigator | LinkedIn and email | | Account connection | Your own [personal cloud browser](/safety) – Linked API never stores your password | You give Dripify your LinkedIn password, which it uses to obtain your session cookies – or connect through its optional Chrome extension without entering the password in Dripify | | Entry price | From $49/mo per seat, billed annually ($69 month-to-month) | Basic $39/user/mo billed annually ($59 month-to-month), per Dripify's pricing page | | Pricing model | Flat per seat, no usage metering | Per user/seat, tiered (Basic / Pro / Advanced / Enterprise) | | Safety model | Dedicated cloud browser behaving like a person; per-action limits you configure and the platform enforces | Cloud execution with daily activity limits you set, optionally auto-adjusted on Advanced | | Free trial | 7 days | 7 days | | Best for | Building automation into a product or AI-agent workflow | Ready-made no-code drip campaigns | Shopping a full list rather than a head-to-head? See the [best Dripify alternatives](/blog/dripify-alternatives). ## Does Dripify have an API? Yes – since September 2026, and it is a reporting API. On the Pro and Advanced plans, Dripify's public API reads your campaigns, lead lists, campaign statistics, leads, teams, and members, and its one write path uploads leads into a campaign. Requests authenticate with an API key and are capped at 60 a minute and 5,000 a day (Dripify's API reference). Dripify has since added an MCP server on the same plans: an AI assistant can view your campaigns and lead lists, look up leads in your Dripify account and upload up to 1,000 LinkedIn profiles to a campaign (Dripify's help center). Like the API, it does not send messages or invitations. What neither has endpoints for is acting on LinkedIn: neither can send a connection request or a message, and Dripify lists "sending LinkedIn messages through the API" among capabilities still to come (Dripify's help center). Its other integrations are Zapier, Make, and outbound webhooks. So it answers "what are my Dripify campaigns doing?" rather than "make LinkedIn do this from my code" – which is the job Linked API's [actions](/docs/actions-overview) and [SDKs](/sdks/installation) are built for. ## Programmable API or a fixed campaign builder? This is the distinction that decides the choice. Linked API gives you primitives – search, fetch profiles and companies, connect, message, react, comment, post – and you compose them into whatever logic you want, branching on any data rather than a preset checklist. Filter on a real profile field, pull data from your CRM mid-flow, trigger from your app: ![A programmable API versus a fixed drip sequence: Linked API exposes primitives you compose into any logic inside your own product, while Dripify runs a fixed templated sequence in its cloud](/images/vs/dripify-sequence-vs-api.webp) Dripify is a different shape: a closed no-code UI where you arrange a fixed sequence of steps – its help center describes building campaigns "by selecting automated LinkedIn actions, delays, and conditions" (Dripify's help center) – that runs in Dripify's cloud. It does offer branching, but from a fixed set of four predefined conditions (connected, message viewed, email available, open profile), each "a one-time action that checks the status at a specific moment" (Dripify's docs). It reaches its edge the moment your logic needs a field those conditions do not cover, or you want the automation to live inside your own product. Concretely, the primitive model looks like this: ```typescript import LinkedApi from '@linkedapi/node'; const linkedapi = new LinkedApi({ linkedApiToken: process.env.LINKED_API_TOKEN, identificationToken: process.env.IDENTIFICATION_TOKEN, }); const search = await linkedapi.searchPeople.execute({ term: 'head of growth', filter: { locations: ['United States'] }, }); const { data: people } = await linkedapi.searchPeople.result(search.workflowId); for (const person of people ?? []) { // Branch on a real profile field - your rule, not a fixed template const profile = await linkedapi.fetchPerson.execute({ personUrl: person.publicUrl }); const { data } = await linkedapi.fetchPerson.result(profile.workflowId); if (!data || (data.followersCount ?? 0) < 1000) continue; const req = await linkedapi.sendConnectionRequest.execute({ personUrl: person.publicUrl, note: `Hi ${person.name.split(' ')[0]}, loved your growth work, let's connect.`, }); await linkedapi.sendConnectionRequest.result(req.workflowId); } ``` ```python from linkedapi import ( LinkedApi, LinkedApiConfig, SearchPeopleParams, FetchPersonParams, SendConnectionRequestParams, ) linkedapi = LinkedApi( LinkedApiConfig( linked_api_token="your-linked-api-token", identification_token="your-identification-token", ) ) search = linkedapi.search_people.execute( SearchPeopleParams(term="head of growth", filter={"locations": ["United States"]}) ) people = linkedapi.search_people.result(search.workflow_id).data or [] for person in people: # Branch on a real profile field - your rule, not a fixed template profile = linkedapi.fetch_person.execute(FetchPersonParams(person_url=person.public_url)) data = linkedapi.fetch_person.result(profile.workflow_id).data if not data or (data.followers_count or 0) < 1000: continue req = linkedapi.send_connection_request.execute( SendConnectionRequestParams( person_url=person.public_url, note=f"Hi {person.name.split(' ')[0]}, loved your growth work, let's connect.", ) ) linkedapi.send_connection_request.result(req.workflow_id) ``` And this is not something you can bolt onto Dripify. Its [reporting API](#does-dripify-have-an-api) reads campaign data but has no endpoint that acts on LinkedIn, and its other integrations are Zapier, Make, and outbound webhooks – "native integrations and modern webhooks let you easily connect Dripify with all the software your team already uses" (Dripify's integrations page). Those webhooks are data-out only: Dripify's own docs state they "can transfer lead's data ONLY … not … message content and other data", fire on a fixed event set (invite sent, message sent, reply), allow "only one condition per webhook integration", and are available on the Pro tier and up. Together they are useful for reporting on campaigns and piping leads into a CRM; neither is a surface for building LinkedIn automation on. Use Linked API's primitives two ways, both first-class: build them into your product through the REST API, SDKs, and CLI, or hand a ready-made [agent skill](/skills) to an AI agent that composes and runs the same workflows from a plain-language ask. ## What does account safety look like on each? Both products automate your own LinkedIn account from the cloud, both let you set daily activity limits, and both are unofficial – neither is a LinkedIn product. Two differences matter: how each connects to your account, and how limits are enforced. **How each connects.** Linked API connects the account into its own [personal cloud browser](/safety) – a real browser loading LinkedIn's own pages – and never stores your LinkedIn password. Dripify works the other way around: its onboarding step is titled "Enter Your LinkedIn Account Credentials", and it stores that password encrypted (Dripify's help center) to obtain your LinkedIn session cookies and drive your account through them. A newer, optional Chrome extension lets you connect without entering your password in Dripify (Dripify's help center). The difference in one line: your account lives in a real browser that stays yours, versus your password and session cookies held on Dripify's side. **How limits are enforced.** Linked API runs each action in a dedicated cloud browser where it [behaves like a person](/safety) on the real pages – a simple visit-and-like takes around 20 seconds by design – and executes workflows sequentially at human pace. You [configure per-action limits](/docs/admin-limits) once, and any action that would exceed one returns a `limitExceeded` error instead of running; our [limits guide](/guides/understanding-linkedin-limits) covers sensible values. Dripify also lets you set daily limits per action type, runs "in the cloud" with "random delays to make sure your LinkedIn activity looks manual" (Dripify's safety page), and on its Advanced plan adds an optional "activity control" feature that auto-adjusts your connecting and messaging limits. Those are daily caps Dripify applies, rather than a per-action gate enforced in a dedicated browser as each action runs. Neither model is free of risk – automating a real account never is. The honest framing: Linked API runs your account in its own real [personal cloud browser](/safety) and enforces limits per action, while Dripify holds your password and the session cookies it derives from it (unless you connect through its optional extension instead), applying daily caps from its side, with optional auto-adjust on its top tier. ## How does Linked API pricing compare with Dripify? | Plan | Price | Notes | |---|---|---| | Linked API – Core | $49/mo per seat billed annually; $69 month-to-month | Unlimited workflow execution within the limits you configure | | Dripify – Basic | $39/user/mo billed annually; $59 month-to-month | One drip campaign; no webhooks | | Dripify – Pro | $59/user/mo billed annually; $79 month-to-month | Unlimited campaigns, dedicated inbox, CSV export, webhooks, reporting API | | Dripify – Advanced | $79/user/mo billed annually; $99 month-to-month | Multi-team management, advanced protection, step analytics | Dripify prices are per user/seat as displayed on its pricing page; both tools bill per seat and offer a 7-day free trial. The seats hold different things. A Linked API seat is one LinkedIn account driven by a full API, SDKs, and CLI, with no metering on how much you run. A Dripify seat is a campaign builder for one user; its programmatic surface – webhooks and a reporting API – unlocks at Pro ($59/user annual) and even then reads and pushes campaign data out rather than letting you build. At entry, Dripify's Basic ($39/user annual) sits just below Linked API's Core ($49/seat annual), and Basic is capped at a single campaign with webhooks unavailable until Pro. The seat prices are close; what each seat carries – a developer surface or a no-code builder – is the substantive difference. ## Frequently Asked Questions (FAQ) #### Can Dripify's API or webhooks replace Linked API? No – they solve different problems. Dripify's API, on the Pro and Advanced plans, reads campaigns, leads, and statistics and can upload leads into a campaign; its webhooks are outbound notifications that "can transfer lead's data ONLY" (not message content) and fire on a fixed set of events (Dripify's help center). Between them you can report on your Dripify campaigns and push leads into your CRM, but neither makes LinkedIn do anything – there is no endpoint to send a connection request or a message. Linked API is the inverse – an inbound control surface for LinkedIn itself: your code calls [send message, fetch profile, search, connect](/docs/actions-overview) and gets structured JSON back. If you need to trigger and compose LinkedIn actions from your own stack rather than read campaign data, a reporting API is not a substitute. #### Can I move my Dripify campaigns to Linked API? The lead data moves; the campaign is rebuilt as code or an agent workflow. Export your leads from Dripify (CSV export is available on Pro and up) and feed the LinkedIn URLs into Linked API [actions](/docs/actions-overview) as parameters. Each campaign step maps to an action – a connection request with a note, a follow-up message, a profile view – chained with `then` and iterated with `doFor`, so the same sequence becomes a workflow the platform runs for you, with the branching logic a fixed template could not express. The account runs in its own [personal cloud browser](/safety), and piloting one account while Dripify runs the rest is the low-risk path. #### Is Linked API safe to use compared with Dripify? Both automate your own account from the cloud with human-like pacing and configurable daily limits, and both are unofficial tools that carry the risk any LinkedIn automation does. Two differences: Linked API runs your account in its own [personal cloud browser](/safety) and enforces the [per-action limits you configure](/docs/admin-limits), while Dripify holds your LinkedIn password and the session cookies it derives from it (unless you connect through its optional extension instead), applying daily caps (auto-adjusted by activity control on its top tier). Accounts that behave like people are the ones that keep working – on either tool, conservative limits matter more than the brand. #### Do I need Sales Navigator for either tool? Not for standard LinkedIn automation. Linked API needs Sales Navigator only for its `nv-*` actions, not for standard people, company, or post data; Dripify states it is "100% compatible with Free LinkedIn, Premium, Sales Navigator and Recruiter Lite accounts" (Dripify's help center), documenting Sales Navigator setup through its browser extension. Either works on a free or Premium account for core outreach. --- Automating LinkedIn for your team, product, or AI agent? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually, with a 7-day free trial – and use it both ways: build it into your product with the [REST API and Node/Python SDKs](/sdks/installation) and the shell [CLI](/cli/getting-started), or get automations out of the box through the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`). *Facts verified July 24, 2026 – Dripify prices, quotes, and feature coverage checked against its live pricing page, features pages, and help center on that date; its public API re-checked against Dripify's live API reference and help center on September 17, 2026; its MCP server and optional Chrome-extension connection checked against its help center on October 5, 2026.* ## Linked API vs Expandi (2026): Features, Pricing, and Safety Compared Linked API vs Expandi is a choice between two shapes of LinkedIn automation. Linked API is a programmable LinkedIn automation API with safety built into the execution layer: you compose workflows from primitives and run them from your own code or an AI agent, and every action runs in a dedicated cloud browser per account, at a human pace, within limits the platform enforces. Expandi is a no-code LinkedIn outreach tool that emphasizes managed safety features – an automatic warm-up ramp and vendor-tuned daily limits – with conditional campaign sequences you assemble in a visual builder. > **The short version.** Both automate your own account from the cloud, and both are built with account safety in mind – they take different approaches. Linked API builds safety into how every action runs: a dedicated cloud browser that behaves like a person, per-action limits the platform enforces, and never stores your LinkedIn password. Expandi layers managed safety features onto a credential-based model – you enter your LinkedIn password for Expandi to obtain your session, with an automatic warm-up that ramps a new account from 5 to 21 actions a day and daily limits from vendor-defined presets. Beyond safety, Linked API is built for two jobs – embed LinkedIn automation into your own product through 55+ composable actions, or run it out of the box via an AI agent. Expandi fits agencies and sales teams who want a no-code campaign builder. Pick by the job, not the brand. ## Pick in 10 seconds :::cards **Choose Linked API if…** - You need a real developer surface: a REST API, Node/Python SDKs, and a shell CLI to compose [55+ actions](/docs/actions-overview) – search, fetch, connect, message, react, post – into your own product. Expandi's Open API schedules actions inside its own campaign and account model, not primitives you compose. - You want automation working out of the box without building it: hand a ready-made skill to an AI agent that runs the workflows for you. - You want safety built into the execution: a dedicated cloud browser that behaves like a person, per-action limits the platform enforces, and no LinkedIn password stored. **Choose Expandi if…** - You want safety handled hands-off with no setup: an automatic warm-up that ramps a new account for you and vendor-tuned daily limits, with nothing to configure. - You want a no-code campaign builder: behaviour-based sequences combining "10 different actions and 10 conditions", A/B testing, a smart inbox, and multichannel LinkedIn, InMail, and email. - You run an agency: centralized multi-account management, client reporting, roles, and a white-label option. ::: ## Linked API vs Expandi at a glance | | Linked API | Expandi | |---|---|---| | What it is | Programmable LinkedIn automation API with built-in safety | No-code LinkedIn outreach tool with managed safety features | | Model | Compose 55+ actions into any workflow – from your code or an AI agent | Assemble behaviour-based campaign sequences in a visual builder | | Developer surface | REST API, Node/Python SDKs, shell CLI, MCP server, agent skills | Open API (Swagger) that schedules actions per connected account, plus webhooks, Zapier and native CRM integrations; MCP in waitlisted beta | | Channels | LinkedIn, including Sales Navigator | LinkedIn, InMail, and email | | Account connection | Your own [personal cloud browser](/safety) – Linked API never stores your password | You enter your LinkedIn password; Expandi uses it to obtain your session | | Safety model | Dedicated cloud browser behaving like a person; per-action limits you configure and the platform enforces | Automatic warm-up ramp; daily limits from vendor-defined "Safe Presets" | | Entry price | From $49/mo per seat, billed annually ($69 month-to-month) | Business $79/account/mo billed annually ($99 month-to-month), per Expandi's pricing page | | Free trial | 7 days | 7 days | | Best for | Building automation into a product or AI-agent workflow | No-code outreach and agency campaign management | ## Does Expandi have an API? Yes. Expandi documents an Open API in a Swagger specification linked from its help center, and its endpoints schedule real LinkedIn actions on one of your connected accounts: a connection request, a message, an InMail, a profile visit, a comment, a post like, or a scheduled post – alongside campaign contacts, searches, and reading conversations. Requests authenticate with an API key and secret, and Expandi describes the API as "intended only for users with existing API knowledge" (Expandi's help center). Three things are worth knowing before you build on it. The specification is the documentation – there is no written guide around it. An older help-center article still says "reversed webhook actions are the closest to the publicly available API we have", the sentence many comparisons quote when they say Expandi has no API. And its pricing page does not say which plans include the API. Separately, Expandi's MCP server is in private beta behind a waitlist, and it works on drafts: "Replies are drafted and campaigns are built as drafts. You review and launch" (Expandi's MCP page). ## A programmable API or a no-code outreach tool? This is the distinction that decides the choice. Linked API gives you primitives – search, fetch profiles and companies, connect, message, react, comment, post – and you compose them into whatever logic you want, in your own code or through an AI agent. You reach the engine two ways, both first-class: build it into your product through the REST API, SDKs, and CLI, or hand a ready-made [agent skill](/skills) to an AI agent that composes and runs the same workflows from a plain-language ask. Expandi is a no-code outreach tool. Its sequence builder combines "10 different actions and 10 conditions in one sequence based on your prospects' behavior" (Expandi's site), with A/B testing, a unified inbox, personalization, and multichannel LinkedIn, InMail, and email. It is also programmable from the outside, through the [Open API](#does-expandi-have-an-api) covered above. So the difference is not whether you can drive it from code – it is what you drive. Expandi's endpoints schedule actions into its own campaign and account model, on an account you connected by entering your LinkedIn password, which Expandi uses to obtain your session cookies. Linked API's surface is the LinkedIn action itself – search, fetch, connect, message, react, comment, post – composed by your code or an AI agent with no campaign object in between, and run in a dedicated cloud browser that behaves like a person. Linked API never stores your password. The same line runs through the agent route: Expandi's MCP beta prepares replies and campaigns as drafts for you to launch, while an AI agent on Linked API runs the LinkedIn actions themselves. If your logic fits a campaign, Expandi's API reaches it; if it needs to branch on data a campaign does not model, or live inside your own product, that is the gap an API of composable actions closes. ## How does each keep an account safe? Both products automate your own LinkedIn account from the cloud, both are unofficial, and both are built with account safety in mind – Linked API builds it into how every action runs, and Expandi markets it heavily ("account safety is why Expandi was built", Expandi's site). Neither claims zero ban risk, and neither should. They take different approaches. Linked API builds safety into the execution layer. Each seat is a dedicated cloud browser matched to your device, where LinkedIn's own pages load and every action [behaves like a person](/safety) – real clicks, human pauses, a simple visit-and-like taking around 20 seconds by design – and workflows execute sequentially at human pace. Linked API never stores your LinkedIn password, and you [configure per-action limits](/docs/admin-limits) that the platform enforces, returning a `limitExceeded` error before any action crosses the line; our [limits guide](/guides/understanding-linkedin-limits) covers sensible values, including ramping a new account conservatively. Expandi takes a managed-features approach layered on a credential-based model. To connect, you enter your LinkedIn password, which Expandi uses to obtain your session; from there it adds "random delays… so that your automation mimics human behavior" and runs an automatic warm-up that ramps a new account gradually – "a default limit that starts at 5 actions per day" that increases "by 3 actions" "every two days" to "a maximum of 21 actions per day over the course of the warm-up period" (Expandi's help center). Daily volumes stay inside randomized ranges from "Safe Presets… defined by our team" to keep activity at "low-risk levels". The automatic warm-up is a convenience Linked API does not package as a named feature – if you want the ramp handled for you, that is a point in Expandi's favor. The counterweight: Expandi's model asks for your LinkedIn password and lets its team define the safe numbers, where Linked API takes no password and lets you set and enforce your own. Neither model removes the underlying risk any real-account automation carries – on either tool, conservative limits and human-like pacing are what keep an account healthy. ## How does Linked API pricing compare with Expandi? | Plan | Price | Notes | |---|---|---| | Linked API – Core | $49/mo per seat billed annually; $69 month-to-month | Full API, SDKs, and CLI; unlimited workflow execution within the limits you configure | | Expandi – Business | $79/account/mo billed annually; $99 month-to-month | Cloud-based outreach, warm-up, unlimited campaigns | | Expandi – Agency | Custom (10+ seats) | Centralized management, client reporting, white-label | Linked API bills a flat per-seat subscription; Expandi bills per LinkedIn account/seat, with the prices above as displayed on its pricing page (annual billing gives two months free). Both offer a 7-day free trial; on Linked API there is no cap on workflow runs while you evaluate it. The units carry different things. A Linked API seat is one LinkedIn account driven by a full API, SDKs, and CLI, with no metering on how much you run. An Expandi seat is one account in a no-code campaign tool; at $79/account/mo annual it sits above Linked API's $49 Core, and some personalization – dynamic images and GIFs – runs through a separate paid Hyperise subscription rather than the base plan. The seat prices are in the same range; what each seat carries – a developer surface of composable actions, or a managed no-code campaign tool with an API into its campaigns – is the substantive difference. ## Frequently Asked Questions (FAQ) #### Does Linked API include account warm-up like Expandi? Not as a named auto-warm-up feature, and that is a real difference to weigh. Linked API gives you [per-action limits you configure](/docs/admin-limits) and the platform enforces, so you ramp a freshly connected account by starting conservative and raising the limits over the first weeks yourself – our [limits guide](/guides/understanding-linkedin-limits) covers sensible starting values. Expandi instead automates that ramp with a built-in warm-up that starts small and climbs gradually on its own. If a hands-off warm-up matters most, that is a point for Expandi; if you would rather set the pace, Linked API's configurable limits reach the same gradual ramp manually. #### How does account safety compare? Both are built for safety and neither eliminates risk. Linked API's is structural: your account runs in a dedicated cloud browser that behaves like a person, Linked API never stores your LinkedIn password, and the platform enforces the [per-action limits you configure](/docs/admin-limits). Expandi layers managed features – an automatic warm-up and daily limits from vendor-defined "Safe Presets" – onto a model where you enter your LinkedIn password for it to obtain your session, and its team defines the numbers. Both can keep an account healthy; the difference is a transparent, password-free execution model where you hold the controls versus a managed one where more is delegated to the vendor. #### Can I move from Expandi to Linked API? Yes – the campaign is rebuilt as a Linked API workflow, and your lead data comes with it. Each campaign step maps to a Linked API [action](/docs/actions-overview) – a connection request with a note, a follow-up message – chained with `then` and iterated with `doFor`; export your leads from Expandi and feed the LinkedIn URLs in as parameters. The account connects into its own [personal cloud browser](/safety), and because Linked API lets you set conservative per-action limits, you can ramp a freshly connected account gradually rather than switching it to full volume at once. Piloting one account while Expandi runs the rest is the low-risk path. #### Do I need Sales Navigator for either tool? Not to start. Linked API needs Sales Navigator only for its `nv-*` actions, not for standard people, company, or post data. Expandi states "you do not need any premium subscription to begin your outreach", with Sales Navigator or Recruiter searches requiring those subscriptions (Expandi's FAQ). Either works on a free or Premium account for core outreach. --- Automating LinkedIn for your team, product, or AI agent? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually, with a 7-day free trial – and use it both ways: build it into your product with the [REST API and Node/Python SDKs](/sdks/installation) and the shell [CLI](/cli/getting-started), or get automations out of the box through the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`). *Facts verified July 24, 2026 – Expandi prices, quotes, and feature coverage checked against its live pricing page, product pages, and help center on that date; its Open API and MCP beta re-checked against Expandi's live API specification, help center, and MCP page on September 17, 2026.* ## Linked API vs PhantomBuster (2026): Features, Pricing, and Safety Compared Linked API vs PhantomBuster is a choice between two different shapes of LinkedIn automation. Linked API is a programmable LinkedIn automation API: you compose workflows from primitives, and the execution layer – a dedicated cloud browser per account, human pacing, enforced limits – is built in. PhantomBuster is a no-code catalog of ready-made cloud automations ("Phantoms") for LinkedIn and 15+ other platforms, which you configure, schedule, and meter by execution time. > **The short version.** The safety difference is structural: Linked API runs your account in its own dedicated cloud browser that behaves like a person, with pacing and per-action limits enforced by the platform; PhantomBuster runs Phantoms from its cloud on a session cookie you supply, with published limits that are recommendations you configure and divide across Phantoms yourself. Beyond safety, Linked API covers two jobs: embed LinkedIn automation into your own product through 55+ composable actions, or run it out of the box – a ready-made skill lets an AI agent build and run the workflows for you. PhantomBuster fits sales and growth teams who prefer configuring template automations in a UI, across many platforms. Pick by the job, not the brand. ## Pick in 10 seconds :::cards **Choose Linked API if…** - You are building LinkedIn automation into a product, backend, or agent workflow: [55+ composable actions](/docs/actions-overview) behind a REST API, Node/Python SDKs, a shell CLI, an MCP server, and packaged agent skills. - You want the safety layer run for you: a dedicated cloud browser per account, sequential human-paced execution, and per-action limits the platform enforces. - You want automation working out of the box without assembling it: install a ready-made skill (`npx @linkedapi/skills`) and ask your AI agent in plain language – nothing to configure, schedule, or chain by hand. **Choose PhantomBuster if…** - You want no-code, ready-made automations: 100+ Phantoms and workflows you configure in a UI – around 40 for LinkedIn and 13 for Sales Navigator – instead of writing code. - You need built-in email enrichment: finding professional email addresses from LinkedIn searches and profiles is a core, metered feature. - Your team prospects across many platforms at once – Google, Instagram, Facebook, X, YouTube and more – not just LinkedIn. ::: ## Linked API vs PhantomBuster at a glance | | Linked API | PhantomBuster | |---|---|---| | What it is | Dedicated LinkedIn automation API | No-code catalog of cloud automations for 15+ platforms | | Model | Workflows from 55+ actions – composed in your code or by your AI agent; the platform executes them | You configure and schedule ready-made Phantoms; chains come as pre-made Workflows | | Entry price | From $49/mo per seat, billed annually ($69 month-to-month) | Start plan €56/mo billed annually, €69 month-to-month (per PhantomBuster's pricing page, in EUR) | | Pricing model | Flat per seat, no usage metering | Metered: monthly quotas for execution hours and email/AI/URL-finder credits, plus a fixed cap on automation slots | | LinkedIn connection | Connect once; your account then runs in its own [personal cloud browser](/safety) – no cookie to handle | Your li_at session cookie + user agent, via browser extension or manual paste | | Safety model | Real browser session behaving like a person – requests come from LinkedIn's own pages; enforced per-action limits | Runs from PhantomBuster's cloud on your cookie; published limits are recommendations you configure per Phantom | | Public API | LinkedIn actions as API calls, returning structured JSON | Account control – launches and monitors Phantoms; no LinkedIn action endpoints | | Free trial | 7 days | 14 days | | Best for | Developers, products, AI agents going deep on LinkedIn | Sales and growth teams prospecting no-code across many platforms | Shopping a full list of alternatives instead of a head-to-head? See the [10 best PhantomBuster alternatives](/blog/phantombuster-alternatives). ## Does PhantomBuster have an API? Yes, on every plan – but it is an API to your PhantomBuster account, not to LinkedIn. By its own description it "gives you control over your account" (PhantomBuster's docs): you launch and stop Phantoms, monitor their runs, fetch their output, and manage scripts, authenticating with an API key. A LinkedIn action – a connection request, a message – still happens only by launching the Phantom that performs it; there is no endpoint that acts on LinkedIn directly. Its hosted MCP server wraps "a curated subset" of the same account API, and a custom-scripts SDK lets you write your own Phantoms in JavaScript. That is the practical line between the two: PhantomBuster's API orchestrates finished automations, while Linked API's [actions](/docs/actions-overview) are the LinkedIn operations themselves, each returning structured JSON to your code. ## What are you actually buying: an API or a catalog? Linked API sells you primitives – and two first-class ways to use them. Automation is expressed as [workflows](/docs/workflows-overview) – actions chained with `then`, iterated with `doFor` blocks – and the platform executes them sequentially in a dedicated cloud browser, returning structured JSON. The embedding route: if you can describe a LinkedIn workflow, you can build it into your product through the API and SDKs; there is no fixed menu. The out-of-the-box route: hand a ready-made [agent skill](/skills) to an AI agent, and it composes and runs those same workflows from a plain-language ask – nothing to wire up or maintain. PhantomBuster sells you finished units. "Phantoms are individual automations. Each Phantom performs one task" (PhantomBuster's pricing FAQ) – its store lists around 40 LinkedIn and 13 Sales Navigator Phantoms, from search export and profile scraping to auto-connect, message sending, and post engagement. Multi-step sequences come as Workflows, which are "ready-made chains of Phantoms" with "predefined launch patterns that are automatically optimized and can't be fully customized" (PhantomBuster's docs). Its [public API](#does-phantombuster-have-an-api) sits at that same layer: it controls Phantoms rather than exposing LinkedIn actions. Coverage differs in both directions: - **First-class in Linked API, not in PhantomBuster:** LinkedIn actions as direct API calls from your backend (send a message, fetch a profile, react, comment – each returning JSON), free-form workflow composition with conditional chains, and account analytics – no SSI or dashboard-performance automation appears in PhantomBuster's LinkedIn catalog. - **Native in PhantomBuster, not in Linked API:** group surfaces (member export and messaging), event guest export and inviting, poll-voter export, Recruiter profile scraping, and AI copywriting Phantoms for messages and comments. - **Both offer:** MCP servers for AI assistants, Sales Navigator coverage, and no-code integrations – n8n and Make on both sides, with PhantomBuster adding Zapier and HubSpot-native sync. ## What does account safety look like on each? Both products automate real member accounts, and neither is an official LinkedIn product. The difference is what LinkedIn's servers see and where the safety engineering lives. ### What LinkedIn's servers see **Linked API – a dedicated browser per account:** - Each seat is a cloud browser dedicated to that account, matched to your device profile, where [every action fully emulates a real user](/safety) – real pages, real clicks, human pauses. - The session lives inside that browser; there is no cookie to extract, paste, or keep alive. **PhantomBuster – your cookie in their cloud:** - Phantoms authenticate with a LinkedIn session cookie and user agent you supply – via its browser extension, or "by copying your li_at session cookie and user agent from your browser's Developer Tools" (PhantomBuster's help center). - Its automations run from PhantomBuster's infrastructure, and its own docs note they "may operate from a different location than where you're actually based", which "can trigger login alerts"; built-in proxies exist in five fixed regions, though PhantomBuster does not recommend proxies for LinkedIn by default. - Your real browsing and its cloud runs act on the same session, and the accounting is yours: "If you run multiple Phantoms on the same LinkedIn account, LinkedIn sees the combined activity" (PhantomBuster's help center). ### Where the safety engineering lives **Linked API – in the platform:** - Workflows execute one at a time at realistic speed – a simple visit-and-like takes around 20 seconds by design. - You [configure per-action limits](/docs/admin-limits) once; an action that would exceed them returns a `limitExceeded` error instead of executing. Our [limits guide](/guides/understanding-linkedin-limits) covers sensible values. **PhantomBuster – mostly in your configuration:** - Its documentation is detailed – per-action daily limit tables by account type, warm-up advice, and recovery steps after a restriction – but the numbers are framed as "starting guidelines, not guarantees" (PhantomBuster's help center). - Applying them is your job: you set volumes in each Phantom's Behavior settings, schedule launch frequency "based on rate limits", and when several Phantoms share one account you "divide the recommended limit by the number of Phantoms" yourself. One exception: Auto Connect invitation limits are enforced dynamically by the product. - Its own FAQ acknowledges the residual risk plainly: restrictions can occur "below published thresholds" when Phantoms stack, manual and automated activity mix, or volume resumes after a quiet period. Neither model is free of risk – automating a real account never is. The honest framing: on Linked API the safety engineering is the platform's job end to end; on PhantomBuster it is shared – the platform documents limits in unusual detail and enforces them for Auto Connect, and you configure everything else. ## How does Linked API pricing compare with PhantomBuster? ![Linked API's flat per-seat fee compared with PhantomBuster's five meters – monthly quotas for execution time and email, AI, and URL-finder credits, plus a fixed cap on automation slots](/images/vs/phantombuster-meters-vs-flat.webp) | Plan | Price | What it includes | |---|---|---| | Linked API – Core | $49/mo per seat billed annually; $69 month-to-month | Unlimited workflow execution within the per-action limits you configure; one seat = one LinkedIn account | | PhantomBuster – Start | €56/mo billed annually; €69 month-to-month | 20 h execution time, 5 automation slots, 500 email + 10k AI + 1k URL-finder credits per month | | PhantomBuster – Grow | €128/mo billed annually; €159 month-to-month | 80 h execution, 15 slots, 2,500 email + 30k AI + 10k URL-finder credits | | PhantomBuster – Scale | €352/mo billed annually; €439 month-to-month | 300 h execution, 50 slots, 10,000 email + 90k AI + 20k URL-finder credits | PhantomBuster prices are shown in EUR; its pricing page localizes currency by region. The units mean different things. A Linked API seat is one LinkedIn account with its own dedicated cloud browser; cost tracks the number of accounts you automate, and each runs unmetered. A PhantomBuster plan is a pool of metered resources shared by a workspace – "execution time is the total monthly time your automations can run", and when it runs out "your automations will pause until your execution time resets or you upgrade your plan", with no automatic overage charges (PhantomBuster's pricing FAQ). Slots count every Phantom kept on the dashboard, "including ones that are inactive or have never been launched", a Workflow "can occupy up to 3 slots", and Workflows run background tasks that consume execution time outside working hours (PhantomBuster's help center). The models meter differently: a PhantomBuster workspace can connect many LinkedIn accounts, but they all draw from that one shared, capped pool of hours and credits, so more accounts means a thinner slice per account; a Linked API seat is one account with its own browser and no metering on how much it runs. Both let you start free – a 7-day trial on Linked API, a 14-day trial on PhantomBuster. ## Frequently Asked Questions (FAQ) #### Can I build a product on PhantomBuster's API the way I can on Linked API? They expose different layers. PhantomBuster's API, by its own description, "gives you control over your account" – it launches and stops Phantoms, monitors runs, and fetches their output; LinkedIn behavior itself stays inside the packaged Phantoms. Linked API exposes the LinkedIn actions themselves: your backend calls [send message, fetch profile, connect, react](/docs/actions-overview) and receives structured JSON, so the automation logic lives in your code rather than in a launched job. #### Do Linked API and PhantomBuster connect to my LinkedIn account the same way? No. PhantomBuster authenticates with your LinkedIn session cookie (li_at) and user agent, supplied through its browser extension or pasted from your browser's developer tools, and expired sessions stop runs until you reconnect. Linked API connects the account once and maintains the session inside its own [personal cloud browser](/safety), with no cookie handling on your side. #### Can I use Linked API and PhantomBuster together? On different LinkedIn accounts, yes – for example PhantomBuster for cross-platform scraping and enrichment on one account, Linked API automating outreach from another, or Linked API's [actions](/docs/actions-overview) consuming lead lists a Phantom exported. On the same account, be careful: PhantomBuster's own docs note that LinkedIn sees combined activity across everything driving a session, so two automation tools sharing one account undermine each other's pacing. #### What does moving from PhantomBuster to Linked API look like? Lead data moves easily; the automation logic is rebuilt. Export your existing lead lists from Phantoms (they output spreadsheets), and feed the LinkedIn URLs straight into Linked API [actions](/docs/actions-overview) as parameters. Each Phantom's job then maps to a workflow you compose once – a search-export Phantom becomes a search action with `doFor` chains, an auto-connect Phantom becomes `sendConnectionRequest` with a note, follow-ups become message actions triggered on acceptance. The account runs in its own [personal cloud browser](/safety) with no cookie to paste, and the practical path is piloting one account on Linked API while PhantomBuster keeps running the rest – just not both tools on the same account at once. #### Does PhantomBuster enforce LinkedIn limits for me? Mostly no – with one exception. Its published limit tables are explicitly "starting guidelines, not guarantees"; you configure volumes per Phantom and divide limits when several share an account. The exception is Auto Connect, whose invitation limits the product enforces dynamically. Linked API enforces limits across every action: you [set them once](/docs/admin-limits), the platform paces each workflow like a human, and any action that would exceed a limit returns `limitExceeded` instead of executing. --- Automating LinkedIn for your team, product, or AI agent? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually, with a 7-day free trial – and use it both ways: build it into your product with the [REST API and Node/Python SDKs](/sdks/installation) and the shell [CLI](/cli/getting-started), or get automations out of the box through the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`). *Facts verified July 24, 2026 – PhantomBuster prices, quotes, and feature coverage checked against its live pricing page, Phantom store, and Help Center on that date; prices as displayed in EUR. Its public API re-checked against PhantomBuster's live developer docs on September 17, 2026.* ## Linked API vs Unipile (2026): Features, Pricing, and Safety Compared Linked API vs Unipile is really a choice between two layers of the same problem – letting your software act on LinkedIn. Linked API is a dedicated LinkedIn automation platform where the execution layer – a cloud browser per account, human pacing, enforced limits – is built in. Unipile is a unified multi-channel communication API – LinkedIn is one of its six-plus channels – and you build the automation logic on top. > **The short version.** The safety difference is structural: Linked API automates through a real browser session that behaves like a person, with pacing and per-action limits enforced by the platform; Unipile constructs reverse-engineered wire-level calls to LinkedIn's private interface and, on its GA API, leaves pacing to your application. Beyond safety, Linked API covers two jobs: embed LinkedIn automation into your own product through 55+ composable actions, or run it out of the box – a ready-made skill lets an AI agent build and run the workflows for you. Unipile fits SaaS products that need messaging across LinkedIn, WhatsApp, Instagram, Telegram, email, and calendars, with sequencing and campaign logic yours to build. Pick by the job, not the brand. ## Pick in 10 seconds :::cards **Choose Linked API if…** - LinkedIn automation is the actual job: search-to-outreach workflows (visit → connect → message → react → comment) composed from [55+ actions](/docs/actions-overview), not just messaging endpoints. - You want the safety layer run for you: every action executes in a dedicated cloud browser at a human pace, within per-action limits the platform enforces. - You want automation working out of the box: install a ready-made skill (`npx @linkedapi/skills`) and ask your AI agent in plain language – no flows to assemble, nothing to configure by hand. **Choose Unipile if…** - You need channels beyond LinkedIn behind one API – WhatsApp, Instagram, Telegram, Gmail/Outlook/IMAP email, and calendars – with a unified inbox in your product. - You connect many end-user accounts and per-account economics matter: €49 / $55 per month covers the first 10 accounts, then €3.00–5.00 per account as volume grows (per Unipile's pricing). - You need recruiter-side surfaces: Recruiter and Company Page inboxes, publishing job posts, retrieving applicants and their resumes. ::: ## Linked API vs Unipile at a glance | | Linked API | Unipile | |---|---|---| | What it is | Dedicated LinkedIn automation API | Unified multi-channel communication API | | Layer | Execution engine – workflows run in a cloud browser, pacing and limits enforced | Unified transport – REST endpoints over LinkedIn's private interface; sequencing and campaign logic are yours to build | | Channels | LinkedIn, including Sales Navigator | LinkedIn, WhatsApp, Instagram, Telegram, Gmail/Outlook/IMAP, Google/Outlook calendars | | Entry price | From $49/mo per seat, billed annually ($69 month-to-month); one seat = one LinkedIn account | €49 / $55 per month minimum, covering up to 10 connected accounts (per Unipile's pricing) | | Pricing model | Flat per seat, no usage metering | Per connected identity, sliding from €5.00 down to €3.00 per account at volume, post-paid on peak usage | | Account connection | Your account runs in its own [personal cloud browser](/safety) | White-label hosted auth wizard, or username/password or li_at cookie through your own UI | | Safety model | Real browser session behaving like a person – requests come from LinkedIn's own pages; enforced per-action limits | Direct calls to LinkedIn's private endpoints (reverse-engineered) via fixed proxies; on the GA v1 API pacing is implemented by you (the v2 beta adds enforced rate limits) | | Free trial | 7 days | 7 days | | Best for | LinkedIn automation depth – teams, products, AI agents | Multi-channel messaging inside a SaaS at end-user scale | Shopping a wider list than these two? See the [best LinkedIn automation tools roundup](/blog/best-linkedin-automation-tools-2026). ## What layer does each product sit at? Linked API sits at the execution layer. Automation is expressed as [workflows](/docs/workflows-overview) – actions chained with `then`, iterated with `doFor` blocks – and the platform executes them sequentially in a dedicated cloud browser that matches your device, at the pace of a real user. You describe *what* ("search these people, visit each profile, connect with a note, message on accept"); the execution layer owns *how fast* and *how humanly*. And you reach that engine two ways, both first-class: embed it in your product through the REST API, SDKs, and CLI – or skip building entirely and hand a ready-made [agent skill](/skills) to an AI agent, which composes and runs the same workflows from a plain-language ask. Unipile sits one layer down, at transport. It describes its API as "designed for messaging use cases" (Unipile's AI-agent page) and aggregates channels into one schema of 100+ REST endpoints: connect an account, then send and sync messages, list chats, manage invitations, retrieve profiles, react, comment, post. For LinkedIn specifically it works "through reverse engineering" of LinkedIn's private interface (Unipile's pricing FAQ), routing each account's requests through a fixed proxy. What it deliberately does not include is automation semantics: there is no campaign or sequence object in its API – its own outreach guide shows you how to wire routes and webhooks into a sequencer you build. Feature coverage follows the layer split, in both directions: - **First-class in Linked API, not native in Unipile's GA API:** [SSI retrieval](/docs/action-st-retrieve-ssi), dashboard performance analytics, own-feed retrieval, and removing existing connections. Unipile's raw-data "magic route" can reach some of these if you identify LinkedIn's private endpoints yourself, and its v2 beta adds a native Delete Relation method – the practical difference is DIY endpoints versus documented, supported actions. The structural difference stands regardless: composed multi-step workflows with built-in pacing make a prospecting sequence one API call rather than an engine you host. - **Native in Unipile, not in Linked API:** editing and deleting sent messages within LinkedIn's short edit window, and voice notes. - **Both offer:** REST APIs, official Node.js and Python SDKs (Unipile's Python SDK targets its v2 beta), MCP servers, and n8n integrations. Linked API adds a shell [CLI](/cli/getting-started) and packaged [agent skills](/skills); Unipile adds a PHP wrapper. ## What does account safety look like on each? Both products automate real member accounts, and neither is an official LinkedIn product – Unipile states plainly that it "is not affiliated with, endorsed by, or sponsored by LinkedIn" (Unipile's LinkedIn API page), and the same is true of Linked API. Two differences matter for risk, taken in turn below. ### What LinkedIn's servers see **Linked API – a real browser session:** - No request construction at all: a dedicated cloud browser loads the real LinkedIn pages, and every backend call is made by LinkedIn's own web app – exactly as in a normal browsing session. - After a LinkedIn update, the browser simply runs whatever code LinkedIn shipped – there is no synthesized request shape that could go stale. - What LinkedIn's servers see is a logged-in browser session [behaving like a person](/safety): real pages, real clicks, human pauses, on a device profile matching yours. **Unipile – wire-level API calls:** - Its reverse-engineered integration (its own description, quoted above) constructs requests to LinkedIn's private backend interface and sends them through each account's fixed proxy. - That interface is private and undocumented, so LinkedIn can change its URL, parameters, or protocol without a public compatibility contract. - This creates a structural risk pacing cannot address: if LinkedIn changes a private endpoint before a reverse-engineered client adapts, the client can keep sending an outdated request shape that the current frontend no longer produces – a potentially distinctive server-side signal. The timing follows LinkedIn's release schedule, not a change in your integration. ### Where the safety engineering lives **Linked API – in the platform:** - Workflows execute one at a time at realistic speed – a simple visit-and-like takes around 20 seconds by design. - You [configure per-action limits](/docs/admin-limits) once; an action that would exceed them returns a `limitExceeded` error instead of executing. Our [limits guide](/guides/understanding-linkedin-limits) covers what values are sensible. **Unipile's GA v1 API – mostly in your codebase:** - Each account gets a fixed, geo-matched proxy (you can bring your own), but enforcement is explicitly absent: "We don't enforce any limits on our side, so you'll have the exact same limit in the LinkedIn UI" (Unipile's developer docs). - The documented daily numbers are labeled conservative recommendations for your application to implement, and its docs advise spacing calls out "with random values" across working hours. - Your application also handles surfaced checkpoints – 2FA, OTP, CAPTCHA, in-app validation – within a five-minute window, and its docs note that Recruiter accounts hit a session conflict that forces cookie-based reconnection, with a Chrome extension suggested as the workaround. **Unipile's v2 beta – part moves into the platform:** - It enforces rate limits at its Methods API level, rejecting over-limit requests "before they are sent to the provider", with limits configurable in its dashboard and a caching layer that serves repeat reads without hitting LinkedIn (per Unipile's v2 documentation). - Caveats run in both directions: v2 is a beta that "may introduce breaking changes" and requires a new account per its migration docs – and rate limiting is throttling, not execution. Rejected requests are your application's to reschedule; sequencing and humanization still live in your code, where on Linked API the platform executes the queue itself at a human pace. Neither model is free of risk – automating a real account never is. The honest framing: on Linked API the safety engineering is the platform's job end to end; on Unipile it is split between you and the platform (mostly you on v1, more of the platform in the v2 beta). ## How does Linked API pricing compare with Unipile? | Connected LinkedIn accounts | Linked API (annual billing) | Unipile (per its pricing) | |---|---|---| | 1 | $49/mo | €49 / $55/mo (minimum, covers up to 10) | | 10 | $490/mo | €49 / $55/mo | The unit of pricing differs in what it includes. A Linked API seat is a dedicated cloud browser running that account, with pacing, sequencing, and limit enforcement part of the price. Unipile's per-account fee covers transport ("no additional cost per request", per its pricing page), with sequencing, quota tracking, and checkpoint handling running in your application. Both sides offer a 7-day free trial, so a side-by-side pilot on a test account costs nothing but a week. One billing-model note: Unipile bills post-paid on the peak number of simultaneously connected accounts in each 30-day period, prices excluding VAT (per its pricing FAQ); Linked API is a flat prepaid subscription per seat – $49/mo billed annually, $69 month-to-month. ## Frequently Asked Questions (FAQ) #### Is Unipile an official LinkedIn API? No. Unipile's own FAQ states it "is not affiliated with, endorsed by, or sponsored by LinkedIn", and for LinkedIn it operates "through reverse engineering" (Unipile's site). Linked API is not affiliated with LinkedIn either – it automates your own account through a cloud browser. LinkedIn's official developer platform is a separate, partner-gated program that does not offer account-level automation of this kind. #### Can I use Linked API and Unipile together? Yes, split by channel: Linked API running the LinkedIn automation, and Unipile handling email, WhatsApp, or a unified inbox inside your product. What you should not do is point two automation tools at the same LinkedIn account at once – pacing only protects an account when a single tool controls all of its activity. #### What does migrating between Linked API and Unipile look like? It is a rewrite of the integration layer, not a search-and-replace, because the models differ: Linked API expects the logic expressed as a [workflow](/docs/building-workflows) – actions chained with `then` – that the platform executes for you; Unipile exposes per-action REST endpoints you orchestrate yourself. Accounts reconnect through each side's hosted connection flow (end users re-authenticate; sessions do not transfer). The practical path is piloting the target on one account while the incumbent keeps running the rest. #### Does Unipile enforce LinkedIn limits for me? On its GA v1 API, no – the documented numbers there are guidance your application implements. Its v2 beta changes this at the transport level: over-limit requests are rejected before they reach LinkedIn, with limits configurable in its dashboard (per Unipile's v2 documentation); scheduling and retries remain in your code. Linked API enforces limits at the execution level: you [set per-action limits once](/docs/admin-limits), the platform paces every workflow like a human, and any action that would exceed a limit returns `limitExceeded` instead of executing. --- Automating LinkedIn for your team, product, or AI agent? [Start with Linked API](/pricing) – flat per-seat pricing from $49/mo, billed annually, with a 7-day free trial – and use it both ways: build it into your product with the [REST API and Node/Python SDKs](/sdks/installation) and the shell [CLI](/cli/getting-started), or get automations out of the box through the [MCP server](/mcp/overview), the AI-agent-friendly CLI (Claude Code, Cursor, Codex), and ready-made [agent skills](/skills) (`npx @linkedapi/skills`). *Facts verified July 23, 2026 – Unipile prices, quotes, and feature coverage checked against Unipile's live pricing page and developer documentation (v1 and v2 beta) on that date.*