# LinkedIn scraper

> Extract the people or companies of a LinkedIn or Sales Navigator search with the Emelia LinkedIn Scraper API, find their emails, download the CSV, and get them as an Emelia list.

Source: https://docs.emelia.io/docs/linkedin-scraper

The LinkedIn scraper extracts the people (or companies) of a LinkedIn search into a CSV, then optionally finds their professional emails. Every finished extraction also becomes an Emelia list, ready to attach to a campaign.

> **Emelia V3.** The V3 flow (scrape into a list) is being documented.

## The flow

1. **Connect a LinkedIn account** for scraping, with the cookies of a browser session ([Connect an account](https://docs.emelia.io/docs/linkedin-scraper/connect-account.md)). The Emelia browser extension does it for you.
2. **Start an extraction** with the search URL ([Start an extraction](https://docs.emelia.io/docs/linkedin-scraper/start-extraction.md)). It runs in the background.
3. **Follow it**: poll [Get a scraper](https://docs.emelia.io/docs/linkedin-scraper/get-scraper.md) every 30 to 60 seconds, or register a [webhook](#webhooks).
4. When `status` is `done`: optionally **find the emails** ([Find the emails](https://docs.emelia.io/docs/linkedin-scraper/find-emails.md)), then **download the CSV** ([Download the CSV](https://docs.emelia.io/docs/linkedin-scraper/download-csv.md)), or use the Emelia list in `convertedListId`.

## Start an extraction

[`POST /linkedin-scrappers`](https://docs.emelia.io/docs/linkedin-scraper/start-extraction.md)

**curl**

```bash
curl -X POST https://api.emelia.io/linkedin-scrappers \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CTOs SaaS France",
    "authes": ["66e0f1a2b3c4d5e6f7a8b9c0"],
    "url": "https://www.linkedin.com/sales/search/people?query=..."
  }'
# {"success":true,"scrapId":"66e0f1a2b3c4d5e6f7a8b9d0"}
```

**JavaScript**

```js
const res = await fetch('https://api.emelia.io/linkedin-scrappers', {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'CTOs SaaS France',
    authes: ['66e0f1a2b3c4d5e6f7a8b9c0'],
    url: 'https://www.linkedin.com/sales/search/people?query=...',
  }),
});
const { scrapId } = await res.json();
```

**Python**

```python
import os
import requests

session = requests.Session()
session.headers["Authorization"] = os.environ["EMELIA_API_KEY"]

res = session.post("https://api.emelia.io/linkedin-scrappers", json={
    "name": "CTOs SaaS France",
    "authes": ["66e0f1a2b3c4d5e6f7a8b9c0"],
    "url": "https://www.linkedin.com/sales/search/people?query=...",
})
scrap_id = res.json()["scrapId"]
```

### Accepted URLs and accounts

| Search                                     | URL                                                  | `authes`                                                                                                       |
| ------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Sales Navigator people search              | `https://www.linkedin.com/sales/search/people...`    | Accounts connected for scraping (the `authId` of [Connect an account](https://docs.emelia.io/docs/linkedin-scraper/connect-account.md)) |
| Sales Navigator lead list                  | `https://www.linkedin.com/sales/lists/people/...`    | Same                                                                                                           |
| Sales Navigator account search (companies) | `https://www.linkedin.com/sales/search/company...`   | Same                                                                                                           |
| Classic LinkedIn people search             | `https://www.linkedin.com/search/results/people/...` | LinkedIn accounts connected to your campaigns, set in the Emelia app                                           |

Every account must exist, belong to you and be valid. Several accounts share the chunks of a large extraction; otherwise one of them is picked. Another `https://linkedin.com/sales/...` URL is accepted at creation, then the scraper stops with `BAD_SALES_NAV_URL`. `plannedStart` schedules the start, at least one hour ahead.

### How many contacts

* A Sales Navigator search returns 2,500 results at most (100 pages of 25). Lead lists and saved searches: 2,500. Searches filtered on your network: 1,000. Account searches: 1,000 companies. Classic searches: 1,000 people.
* **Grow and Scale plans:** a search of 3,000 results or more that has filters is split automatically into chunks of 2,500 (`segmented: true`), extracted at 2,500 contacts per day and per LinkedIn account (5,000 with a Sales Navigator Advanced seat). Give several accounts to go faster. Above 80,000 results the scraper stops with `TOO_MUCH_RESULT`.
* **Start plan:** capped at 2,500, no automatic split.
* `max_contacts` stops the extraction early (on Sales Navigator, up to 25 more; ignored for split searches).

### Plans and credits

Extraction is included in the Emelia subscriptions. Without a subscription, a workspace gets one free extraction, then extracts on credits: about 1 credit per 2 contacts, and the scraper pauses when the credits run out.

## Follow an extraction

[`GET /linkedin-scrappers/{id}`](https://docs.emelia.io/docs/linkedin-scraper/get-scraper.md)

**curl**

```bash
curl https://api.emelia.io/linkedin-scrappers/66e0f1a2b3c4d5e6f7a8b9d0 \
  -H "Authorization: $EMELIA_API_KEY"
```

**JavaScript**

```js
const res = await fetch(`https://api.emelia.io/linkedin-scrappers/${scrapId}`, {
  headers: { Authorization: process.env.EMELIA_API_KEY },
});
const { scrap } = await res.json();
console.log(scrap.status, `${scrap.scrapIndex}/${scrap.totalContact}`, scrap.convertedListId);
```

**Python**

```python
scrap = session.get(f"https://api.emelia.io/linkedin-scrappers/{scrap_id}").json()["scrap"]
print(scrap["status"], scrap["scrapIndex"], "/", scrap["totalContact"], scrap.get("convertedListId"))
```

| `status`    | Meaning                                                                                                                                                                                               |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `planified` | Waits for its `plannedStart`.                                                                                                                                                                         |
| `running`   | Extracting. `scrapIndex` contacts out of `totalContact` (0 until LinkedIn gives the count).                                                                                                           |
| `paused`    | Paused by you (split searches only) or out of credits. [Resume an extraction](https://docs.emelia.io/docs/linkedin-scraper/resume-extraction.md).                                                                              |
| `done`      | Finished. The CSV is ready, and the Emelia list follows within a few minutes (`convertedListId`).                                                                                                     |
| `enriching` | Finding emails, then `done` again.                                                                                                                                                                    |
| `error`     | Stopped. `error` gives the code: `INVALID_COOKIE`, `2FA_REQUIRED`, `BAD_SALES_NAV_URL`, `NEED_SUBSCRIPTION`, `TOO_MUCH_RESULT`, `RATE_LIMIT`, `SEGMENTATION_ERROR`, `PROCESS_ERROR`, `NO_AUTH_FOUND`. |

After `INVALID_COOKIE` or `RATE_LIMIT`, refresh the account with [Refresh an account](https://docs.emelia.io/docs/linkedin-scraper/refresh-account.md), then resume the scraper. A Sales Navigator `RATE_LIMIT` is retried on its own about 12 hours later. The other codes are final: start a new extraction.

## Find the emails

[`POST /linkedin-scrappers/{id}/enrich`](https://docs.emelia.io/docs/linkedin-scraper/find-emails.md)

**curl**

```bash
curl -X POST https://api.emelia.io/linkedin-scrappers/66e0f1a2b3c4d5e6f7a8b9d0/enrich \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"country": "FR"}'
```

**JavaScript**

```js
await fetch(`https://api.emelia.io/linkedin-scrappers/${scrapId}/enrich`, {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ country: 'FR' }),
});
```

**Python**

```python
session.post(f"https://api.emelia.io/linkedin-scrappers/{scrap_id}/enrich", json={"country": "FR"})
```

* Only on a finished people extraction that was never enriched.
* You need at least as many credits as `totalContact`. That amount is taken at the start, and the credits of the contacts without email come back at the end: **1 credit per email found**.
* An enrichment ends within about an hour. There is no webhook at the end: poll the scraper until `status` is `done` again; `enrichFound` gives the number of emails found.
* To pay less, first [remove the contacts already extracted](https://docs.emelia.io/docs/linkedin-scraper/remove-known-contacts.md) by your 10 last scrapers.

## Download the CSV

[`GET /linkedin-scrappers/{id}/download`](https://docs.emelia.io/docs/linkedin-scraper/download-csv.md)

**curl**

```bash
curl "https://api.emelia.io/linkedin-scrappers/66e0f1a2b3c4d5e6f7a8b9d0/download?format=3" \
  -H "Authorization: $EMELIA_API_KEY" | jq -r .data > contacts.csv
```

**JavaScript**

```js
import { writeFile } from 'node:fs/promises';

const res = await fetch(`https://api.emelia.io/linkedin-scrappers/${scrapId}/download?format=3`, {
  headers: { Authorization: process.env.EMELIA_API_KEY },
});
const { data } = await res.json(); // the CSV, as a string
await writeFile('contacts.csv', data);
```

**Python**

```python
csv = session.get(
    f"https://api.emelia.io/linkedin-scrappers/{scrap_id}/download",
    params={"format": 3},
).json()["data"]
open("contacts.csv", "w").write(csv)
```

The CSV comes as a string in `data`, with `;` as separator. `format` is `1` for the contacts with an email (after an enrichment), `2` for the contacts without, `3` for all. It works while the extraction runs: you get what was collected so far. After an enrichment, the email is in `email_emelia` and its verdict in `qualification_emelia`.

## Webhooks

[`POST /linkedin-scrappers/{id}/webhook`](https://docs.emelia.io/docs/linkedin-scraper/add-webhook.md)

Register a URL right after starting the extraction, with `{"webhookUrl": "https://hooks.yourcompany.com/emelia/scraper", "events": ["FINISHED", "ERROR"]}`. Emelia then sends `{ "event", "id", "name", "error" }`:

* `FINISHED` when the extraction ends (every chunk included);
* `ERROR` when it stops on an error code, at most once per hour for the same code.

Every registered URL receives every event, whatever its `events`. One attempt only, no signature: check the scraper with `GET /linkedin-scrappers/{id}` when you receive a call. The success response of this route is an empty object `{}`.

## Scraping accounts

[`POST /linkedin-scrappers/authes`](https://docs.emelia.io/docs/linkedin-scraper/connect-account.md)

A scraping account is a LinkedIn session, sent as the cookies of a browser where the user is logged in to LinkedIn and Sales Navigator. `li_at` is required; `li_a`, `ua`, `additionnalsCookie` (objects `{ name, value, domain }`), `viewport` and `country` are strongly recommended. The response gives the `authId` to use in `authes`.

> **Session cookies are secrets.** These cookies open the LinkedIn account. Send them only to Emelia, never log them, and protect your API key accordingly. There is no API route to remove a scraping account yet.

When a session expires, update the same account with [Refresh an account](https://docs.emelia.io/docs/linkedin-scraper/refresh-account.md) rather than creating a new one, and send the complete set of cookies every time: omitted fields are cleared.
