# LinkedIn Ad Library API (`experthasan/linkedin-ad-library-api`) Actor

FastAPI-based scraper for the public LinkedIn Ad Library. Search ads by advertiser, keyword or payer, filter by country, date range, impressions and targeting facets, and pull per-ad detail including creatives, impressions and targeting parameters.

- **URL**: https://apify.com/experthasan/linkedin-ad-library-api.md
- **Developed by:** [Mahmudul Hasan](https://apify.com/experthasan) (community)
- **Categories:** Automation, Social media, E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 ad scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 💼 LinkedIn Ad Library API Scraper

> **Structured access to the public LinkedIn Ad Library.**
> Search B2B ads by advertiser, keyword or payer, filter by country, date range,
> impressions and targeting facets, and pull per-ad detail including creatives,
> impression ranges and the targeting criteria the advertiser actually used.

[![Apify](https://img.shields.io/badge/Apify-Actor-714EFF?style=flat-square\&logo=apify)](https://apify.com/store) [![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat-square\&logo=fastapi\&logoColor=white)](https://fastapi.tiangolo.com) [![Python](https://img.shields.io/badge/Python-3.12-3776AB?style=flat-square\&logo=python\&logoColor=white)](https://python.org)

***

### ✨ Features

- **🔍 Four search modes**: by **Advertiser**, **Keyword**, **Payer**, or direct **Ad ID** lookup.
- **🌍 Country filtering**: 256 codes, with fuzzy input (`usa`, `UK`, `Germany` all resolve) — including the non-ISO codes LinkedIn's own filter uses.
- **📅 Date ranges**: presets (last 30 days, current month, current/last year) or a custom range.
- **📊 Impression filters**: min/max bounds, each with its own `none` / `thousand` / `million` unit.
- **👤 Thought Leader Ads**: member-authored posts promoted by a company are detected, with the author kept separate from the advertiser.
- **🎯 Targeting facets**: include or exclude ads by `LANGUAGE`, `LOCATION`, `AUDIENCE`, `DEMOGRAPHIC`, `COMPANY`, `EDUCATION`, `JOB`, `INTERESTS_AND_TRAITS`.
- **🔬 Detail enrichment**: opt in to per-ad impressions, impressions-by-country, full targeting and video variants.
- **📄 Token pagination**: uses the Ad Library's own `paginationToken`, with three independent stop conditions so a run neither loops nor stops short.
- **🛡️ Resilience**: exponential backoff with jitter, polite request spacing, proxy support and optional Redis caching.
- **⚡ Dual runtime**: the same code runs as an Apify Actor *and* as a standalone FastAPI service.

***

### 🚀 Usage as an Apify Actor

#### Input Parameters

| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `searchType` | String | `"advertiser"` | **Required.** One of `"advertiser"`, `"keyword"`, `"payer"`, `"ad_details"`. |
| `accountOwner` | String | `"Microsoft"` | Advertiser / company name. Required for `"advertiser"`. |
| `companyIds` | Array | – | LinkedIn numeric company IDs. More precise than a name. |
| `keyword` | String | – | Free-text term in the ad copy. Required for `"keyword"`. |
| `payer` | String | – | Entity that paid for the ads. Required for `"payer"`. |
| `adId` | String | – | Numeric ad ID. Required for `"ad_details"`. |
| `countries` | Array | – | Two-letter country codes, e.g. `["US","GB"]`. Empty = all. |
| `dateOption` | String | `"last-30-days"` | `last-30-days`, `current-month`, `current-year`, `last-year`, `custom-date-range`. |
| `startDate` / `endDate` | String | – | `YYYY-MM-DD`. Setting either switches `dateOption` to custom. |
| `impressionsMinValue` / `impressionsMaxValue` | Integer | – | Impression bounds, in the unit below. |
| `impressionsUnit` | String | `"thousand"` | `none`, `thousand`, `million`. |
| `paginationToken` | String | – | Continue a previous run. From its `PAGINATION` record. |
| `skip` | Integer | `0` | Ads to skip on the first page when continuing. From `PAGINATION`. |
| `includedTargetingFacetCategories` | Array | – | Only ads using these targeting categories. |
| `excludedTargetingFacetCategories` | Array | – | Exclude ads using these targeting categories. |
| `fetchAdDetail` | Boolean | `false` | Open each ad's detail page. **Required for video files**, full ad copy, CTA and payer. One extra request per ad. |
| `maxResults` | Integer | `100` | Stop after this many ads. |
| `pageSize` | Integer | `25` | Ads per page (the library serves 25). |
| `maxPages` | Integer | derived | Hard page cap. Defaults to whatever reaches `maxResults`. |
| `proxyConfiguration` | Object | Apify Proxy | Recommended for anything beyond a few hundred ads. |

#### Example Input

##### 1. Search by advertiser (most common)

```json
{
    "searchType": "advertiser",
    "accountOwner": "Microsoft",
    "countries": ["US", "GB"],
    "dateOption": "last-30-days",
    "maxResults": 100
}
```

##### 2. Keyword search with impression floor and detail enrichment

```json
{
    "searchType": "keyword",
    "keyword": "cloud migration",
    "countries": ["US"],
    "impressionsMinValue": 50,
    "impressionsUnit": "thousand",
    "fetchAdDetail": true,
    "maxResults": 50
}
```

##### 3. Custom date range

```json
{
    "searchType": "advertiser",
    "accountOwner": "Salesforce",
    "startDate": "2025-01-01",
    "endDate": "2025-06-30",
    "maxResults": 250
}
```

##### 4. Look up a single ad

```json
{
    "searchType": "ad_details",
    "adId": "123456789012"
}
```

***

### 📦 Output Structure

Each dataset item is one ad. Fields that could not be found are `null` rather
than missing, so downstream schemas stay stable.

```json
{
    "ad_id": "1549195316",
    "detail_url": "https://www.linkedin.com/ad-library/detail/1549195316",
    "advertiser_name": "Microsoft",
    "advertiser_logo": "https://media.licdn.com/dms/image/v2/.../company-logo_100_100/...",
    "headline": "從 AI 策略到執行",
    "ad_text": "準備好將 AI 願景化為實際成果嗎？\n\n加入每日精華短講…",
    "cta_text": null,
    "destination_url": "https://lnkd.in/guEwNjqC",
    "image_url": "https://media.licdn.com/dms/image/v2/.../image-shrink_1280/...",
    "video_url": null,
    "video_urls": [],
    "video_poster_url": null,
    "video_on_detail_page_only": false,
    "carousel_images": [],
    "ad_type": "Single Image Ad",
    "creative_type": "SINGLE_IMAGE",
    "is_promoted": true,
    "is_thought_leader_ad": false,
    "author_name": null,
    "author_title": null,
    "promoted_by": null,
    "hashtags": ["#microsoftfrontier"],
    "external_links_in_text": ["https://lnkd.in/guEwNjqC"],
    "parsed_via": "dom"
}
```

A Thought Leader Ad — a member's post promoted by a company — keeps the two
identities apart, so the advertiser column stays usable:

```json
{
    "ad_id": "1548875996",
    "advertiser_name": "Adoptify | Embrace Microsoft 365",
    "is_thought_leader_ad": true,
    "author_name": "Katinka Kriek",
    "author_title": "Manager Business Development bij Adoptify | Microsoft 365 & Copilot",
    "headline": null
}
```

With `fetchAdDetail: true`, the detail page's data is **promoted onto the ad
itself** — `video_url`, `cta_text`, `destination_url`, `paid_for_by` and the
full untruncated `ad_text` all fill in at the top level, so a CSV export or a
spreadsheet view shows them. The complete `detail` object is kept alongside:

```json
{
    "detail": {
        "ad_run_dates_raw": "Jun 1, 2025 to Jul 15, 2025",
        "start_date": "2025-06-01",
        "end_date": "2025-07-15",
        "ad_status": "INACTIVE",
        "total_impressions": { "raw": "10,000 - 50,000", "min": 10000, "max": 50000 },
        "impressions_by_country": [
            { "country": "United States", "percentage": 62.5 },
            { "country": "United Kingdom", "percentage": 21.0 }
        ],
        "targeting": [
            { "category": "LANGUAGE", "label": "Language", "values": ["English"] },
            { "category": "JOB", "label": "Job", "values": ["Software Engineer", "Engineering Manager"] }
        ],
        "paid_for_by": "Contoso Ltd",
        "video_urls": [{ "url": "https://…/vid-hd.mp4", "bitrate": 2500000 }],
        "video_poster_url": null
    }
}
```

> **Why impressions are a range, not a number.** LinkedIn publishes bucketed
> impression counts (`"10,000 - 50,000"`, `"1M+"`). The scraper preserves the
> raw string *and* parses both bounds, so you never have to guess whether a
> single number was a floor, a ceiling or a midpoint. An open-ended bucket
> leaves the corresponding bound `null`.

#### Getting the next batch

The dataset holds ads and nothing else, so every run also writes a
**`PAGINATION`** record to the key-value store telling you how to continue:

```json
{
    "returned": 100,
    "total_ads_matching_search": 27167,
    "has_more": true,
    "stopped_because": "max_results",
    "next_pagination_token": "1548875996-1786699248153",
    "next_skip": 0,
    "continue_with_input": { "paginationToken": "1548875996-1786699248153", "skip": 0 },
    "how_to_continue": "Re-run this Actor with the same input plus …"
}
```

Paste `continue_with_input` into the next run's input alongside your original
filters. The token is also printed in the run log.

> **Check `has_more`, never the token.** A `null` token with `has_more: true`
> is normal and means "resume from the first page at `next_skip`" — it happens
> whenever `maxResults` is smaller than one page of 25. Treating a null token
> as "finished" is the one way to stop early and not notice.

> **Why there is a `skip` as well as a token.** LinkedIn's token is derived
> from the last ad on the page it came with, so it only ever points at page
> boundaries. If your `maxResults` cuts a page in half, advancing to that
> page's token would silently drop the ads you never read. Instead the run
> hands back the token that *fetched* the partial page plus how many of its
> ads you already have. Replaying that pair resumes at the exact ad — no gap,
> no repeats.

An advertiser summary is written to the key-value store under
`advertiser_details` whenever the run targeted a named advertiser.

***

### 🌐 Usage as a standalone API

Pushing to GitHub, deploying to Easypanel and listing on RapidAPI are all covered in **[DEPLOY.md](DEPLOY.md)**.

```bash
docker compose up --build     # http://localhost:8080/docs
## or
pip install -r requirements.txt && uvicorn main:app --reload --port 8080
```

The service ships an OpenAPI **3.0.3** document at `/openapi.json` and as a
committed `openapi.json` / `openapi.yaml`. That version is deliberate: FastAPI
generates 3.1, which RapidAPI cannot import.

Interactive docs are then at `http://127.0.0.1:8080/docs`.

| Endpoint | Purpose |
| :--- | :--- |
| `GET /search` | Main search. Needs at least one of `account_owner`, `company_ids`, `keyword`, `payer`. Returns `has_more`, `next_pagination_token` and `next_skip`. |
| `GET /advertiser_ads` | Convenience wrapper keyed on a single advertiser. |
| `GET /details` | Full detail for one `ad_id`. |
| `GET /advertiser_details` | Identity and ad volume for an advertiser. |
| `GET /filters` | The full filter vocabulary, for building UIs. |
| `GET /countries` | Supported country codes. |
| `GET /health` | Liveness probe. Public even when the RapidAPI gate is on. |

```bash
curl "http://127.0.0.1:8080/search?account_owner=Microsoft&countries=US,GB&limit=25"
curl "http://127.0.0.1:8080/details?ad_id=123456789012"
```

Paging through every result:

```python
token, skip, ads = None, 0, []
while True:
    r = requests.get(f"{BASE}/search", params={
        "account_owner": "Microsoft", "limit": 100,
        "pagination_token": token, "skip": skip,
    }).json()
    ads += r["ads"]
    if not r["has_more"]:          # not `if not token` — see the note above
        break
    token, skip = r["next_pagination_token"], r["next_skip"]
```

#### Configuration

All optional — see `.env.example`.

| Variable | Default | Purpose |
| :--- | :--- | :--- |
| `REDIS_HOST` | unset | Enables response caching. Unset = no cache, instant startup. |
| `CACHE_TTL` | `3600` | Cache lifetime in seconds. |
| `PROXY_URL` | unset | Outbound proxy for LinkedIn requests. |
| `REQUEST_TIMEOUT` | `25` | Per-request timeout. |
| `MAX_RETRIES` | `3` | Attempts before giving up. |
| `MIN_REQUEST_DELAY` | `0.4` | Minimum gap between requests. |
| `RATE_LIMIT` | `50/second` | Inbound rate limit per client IP. Keep generous behind RapidAPI — all traffic shares a few proxy IPs. |
| `PUBLIC_BASE_URL` | placeholder | Written into the OpenAPI `servers` block. |
| `RAPIDAPI_PROXY_SECRET` | unset | When set, rejects requests that did not come through RapidAPI. |
| `WEB_CONCURRENCY` | `2` | Uvicorn workers in the API image. |

***

### 🧑‍💻 Programmatic usage

#### Apify client

```bash
pip install apify-client
```

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run_input = {
    "searchType": "advertiser",
    "accountOwner": "Salesforce",
    "countries": ["US"],
    "maxResults": 100,
}

run = client.actor("your-username/linkedin-ad-library-api").call(run_input=run_input)

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["advertiser_name"], "—", item["headline"])
```

#### Python library, directly

```python
from LinkedInAds import LinkedInAds

with LinkedInAds() as li:
    params = li.build_search_params(
        account_owner="Salesforce",
        countries=["US", "GB"],
        date_option="last-30-days",
    )
    # iter_ads walks pages itself, so no token handling is needed here.
    for ad in li.iter_ads(params, max_pages=4, max_results=100):
        print(ad["ad_id"], ad["headline"])

    print(li.get_ad_detail("123456789012"))
```

***

### 🏗️ Architecture

```
main.py                 FastAPI app — HTTP surface, caching, rate limiting
src/main.py             Apify Actor wrapper — drives the app in-process over ASGI
api_models.py           Response schemas — documentation for the OpenAPI spec
openapi_compat.py       OpenAPI 3.1 -> 3.0.3 conversion (RapidAPI cannot read 3.1)
openapi.json/.yaml      Committed spec, ready to upload to RapidAPI
Dockerfile              Web service image — the default, for Easypanel etc.
Dockerfile.actor        Apify Actor image (referenced by .actor/actor.json)
docker-compose.yml      Local run + Easypanel Compose starting point
DEPLOY.md               Easypanel deploy and RapidAPI listing guide
scripts/
  generate_openapi.py   Regenerate the spec after changing endpoints
LinkedInAds/
  main.py               HTTP client — session, retries, proxy, pagination
  parsers.py            HTML → structured records
  constants.py          Headers, filter vocabulary, CSS selector candidates
  countries.py          ISO country codes + fuzzy normalisation
tests/
  test_offline.py       227 offline checks
  fixtures/
    search_real.html    saved live search page — image ads, the ground truth
    search_video.html   saved live search page — video + article ads
    search_page.html    synthetic page for the heuristic fallback path
    detail_page.html    US-served ad (no audience data published)
    detail_page_eu.html EU-served ad (impressions + targeting published)
    detail_video_real.html saved live detail page — a Video Ad with real sources
```

Two design choices are worth knowing about:

**The Actor never starts a web server.** It loads the FastAPI app and calls it
through `httpx`'s ASGI transport, so there is no port to bind and no readiness
window to race. The scrape runs on a daemon thread under a wall-clock budget
(`RUN_BUDGET_SECONDS`, default 210s), because the scraper's `requests` calls are
blocking and would otherwise make an `asyncio` timeout unenforceable. If the
budget expires, whatever has been collected is still saved.

**Extraction is two-layered.** The page mixes Tailwind utility classes, which
churn constantly, with a small set of stable semantic hooks that do not:
`li.search-result-item`, `p.commentary__content`, `.sponsored-content-headline`,
`img.ad-preview__dynamic-dimensions-image`, and the `data-creative-type` and
`aria-label` attributes on each card. Those verified hooks run first. If they
miss — because LinkedIn moved something — structural heuristics take over: an
ad is whatever surrounds a `/ad-library/detail/<id>` link, the CTA is the short
string matching LinkedIn's own button vocabulary, the headline sits beside it,
the body is the longest remaining block.

The ordering matters in one non-obvious way. When the verified selectors
*do* match, the heuristics are not allowed to fill a gap they left — because a
missing headline element means the format genuinely has no headline, and
guessing one from nearby text invents data. The heuristics only run when the
verified path found nothing at all, which is the signal that the markup moved.

Two traps are handled explicitly: creatives are lazy-loaded, so the real URL is
in `data-delayed-url` and reading `src` yields a base64 placeholder; and logos
and creatives are both `licdn.com` images, told apart by `company-logo` in the
URL.

***

### 🧪 Tests

```bash
python tests/test_offline.py
```

Runs 227 checks with no network access. The important ones run against
`tests/fixtures/search_real.html` — a saved copy of a live Ad Library search
page — so every expectation is a value you can verify by opening the same page.
An earlier version of this scraper passed a full synthetic suite while returning
nulls in production; that fixture is what makes that failure mode impossible to
repeat.

Also covered: query construction against the live form's real field names, the
heuristic fallback path, token pagination and its stop conditions, US vs EU
detail pages, malformed input, every API endpoint, and the Actor's input
normalisation.

***

### ⚠️ Notes and limits

These are the things that will bite you, all confirmed against a saved copy of
the live Ad Library rather than inferred from documentation.

**Impressions and targeting exist only for EU-served ads.** LinkedIn publishes
reach and targeting to satisfy the EU's Digital Services Act. A US-served ad's
detail page carries only the creative, the advertiser and the payer — so
`total_impressions` and `targeting` come back empty and `has_audience_data` is
`false`. That flag is the whole point: without it, "LinkedIn does not publish
this" is indistinguishable from "the scraper broke".

**Impressions are buckets, not counts.** `"10,000 - 50,000"`, `"1M+"`. The raw
string is preserved alongside parsed `min`/`max`, with `null` for an open end.

**Pagination is by opaque token.** Each page embeds a `paginationToken` and an
`isLastPage` flag. Requesting `?start=25` does not page — it silently re-serves
page one, which looks exactly like an advertiser having a single page of ads.

**Ad copy on search cards is truncated** by LinkedIn's own layout (trailing
`…`). Set `fetchAdDetail: true` for the full text.

**Video files are not on the search page — set `fetchAdDetail: true`.** A Video
Ad's search card carries only the cover frame; there is no `<video>` element,
no `.mp4`, nothing. The playable file exists only on the detail page. On a plain
search, `video_url` is null, `video_poster_url` holds the `videocover` image,
and `video_on_detail_page_only` is `true` on exactly those ads — so a null
`video_url` is never ambiguous. With enrichment on you get every rendition:

```json
{
    "video_url": "https://dms.licdn.com/playlist/vid/v2/D5605AQ…/mp4-720p-30fp-crf28/…",
    "video_urls": [
        { "url": "…/mp4-720p-30fp-crf28/…", "bitrate": 862794 },
        { "url": "…/mp4-640p-30fp-crf28/…", "bitrate": 629270 }
    ],
    "video_poster_url": "https://media.licdn.com/dms/image/v2/…/videocover-high/…",
    "video_asset_urn": "urn:li:digitalmediaAsset:D5605AQGbdLjWR9Ij4A"
}
```

`video_url` is the highest-bitrate **progressive MP4**, chosen over any HLS
manifest even at a higher advertised bitrate — an `.m3u8` is a playlist, not a
file you can download. Every rendition stays in `video_urls`.

**Links inside ad copy are @mentions, not the landing page.** An ad by
"Microsoft 365" that tags Box contains a `linkedin.com/company/box` link. The
parser ignores every LinkedIn-internal URL when picking `advertiser_name` and
`destination_url` — an earlier version reported that ad as *an ad by Box*.

**Some formats have no headline.** Thought Leader Ads are the common case. The
parser reports `null` rather than promoting a nearby line — an invented
headline is worse than an absent one.

**The date filter fields are lowercase** (`startdate`, `enddate`) and there is
no "last month" preset, despite what third-party docs claim. There is also no
sort control; `sortOrder` is accepted for compatibility and not sent.

**Impression bounds carry their own units.** The form sends the raw number plus
`impressionsMinUnit` / `impressionsMaxUnit`. Pre-multiplying and dropping the
unit turns a floor of "10 thousand" into a floor of 10.

**Only ads from the last ~12 months are searchable.** The library itself states
it covers ads created on or after a rolling cutoff date.

**Other limits.** The library is guest-accessible (no login or API key); the
client warms a session first to pick up guest cookies. A search needs at least
one of advertiser / company IDs / keyword / payer — there is no "everything"
query. Use Apify Proxy (or `PROXY_URL`) for large runs; LinkedIn rate-limits
hard from a single IP and the client surfaces that as HTTP 429.

***

### 🔗 Resources

- **LinkedIn Ad Library**: <https://www.linkedin.com/ad-library/home>
- **Apify SDK**: <https://docs.apify.com/sdk/python>
- **FastAPI**: <https://fastapi.tiangolo.com/>

# Actor input Schema

## `searchType` (type: `string`):

What to search the Ad Library by

## `accountOwner` (type: `string`):

Company or advertiser name, e.g. 'Microsoft'. Required when searchType is 'advertiser'.

## `companyIds` (type: `array`):

LinkedIn numeric company IDs to restrict the search to. Optional; more precise than a name.

## `keyword` (type: `string`):

Free-text term to match in ad copy. Required when searchType is 'keyword'.

## `payer` (type: `string`):

The entity that paid for the ads. Required when searchType is 'payer'.

## `adId` (type: `string`):

Numeric ad ID from a linkedin.com/ad-library/detail/<id> URL. Required when searchType is 'ad\_details'.

## `countries` (type: `array`):

Two-letter country codes to filter by, e.g. US, GB, DE. Leave empty for all countries.

## `dateOption` (type: `string`):

Preset date range. Ignored if Start Date or End Date is set.

## `startDate` (type: `string`):

Custom range start (YYYY-MM-DD). Setting this switches Date Range to custom.

## `endDate` (type: `string`):

Custom range end (YYYY-MM-DD). Setting this switches Date Range to custom.

## `impressionsMinValue` (type: `integer`):

Only return ads with at least this many impressions, in the unit below (e.g. 10 + thousands = 10,000).

## `impressionsMaxValue` (type: `integer`):

Only return ads with at most this many impressions, in the unit below.

## `impressionsUnit` (type: `string`):

Unit applied to the impression bounds. LinkedIn's own filter defaults to thousands.

## `includedTargetingFacetCategories` (type: `array`):

Only return ads that used these targeting categories

## `excludedTargetingFacetCategories` (type: `array`):

Exclude ads that used these targeting categories

## `fetchAdDetail` (type: `boolean`):

Open each ad's detail page. Required to get video files (the search page has none), untruncated ad copy, CTA, landing page and payer. One extra request per ad.

## `maxResults` (type: `integer`):

Stop after this many ads

## `pageSize` (type: `integer`):

Ads requested per page (the Ad Library serves 25)

## `maxPages` (type: `integer`):

Hard cap on pages walked. Leave empty to derive it from Maximum Results.

## `proxyConfiguration` (type: `object`):

Route requests through Apify Proxy. Recommended for larger runs.

## `paginationToken` (type: `string`):

Continue a previous run. Copy next\_pagination\_token from that run's PAGINATION record in the key-value store. Pair it with Skip.

## `skip` (type: `integer`):

Ads to skip on the first page when continuing. Copy next\_skip from the previous run's PAGINATION record.

## Actor input object example

```json
{
  "searchType": "advertiser",
  "accountOwner": "Microsoft",
  "companyIds": [
    "1035"
  ],
  "keyword": "cloud migration",
  "payer": "Microsoft Corporation",
  "adId": "123456789",
  "countries": [
    "US"
  ],
  "dateOption": "last-30-days",
  "startDate": "2025-01-01",
  "endDate": "2025-12-31",
  "impressionsMinValue": 10,
  "impressionsMaxValue": 500,
  "impressionsUnit": "thousand",
  "fetchAdDetail": false,
  "maxResults": 25,
  "pageSize": 25,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "skip": 0
}
```

# Actor output Schema

## `ads` (type: `string`):

Every ad matched by the search.

## `adsCsv` (type: `string`):

The same records flattened for spreadsheets.

## `pagination` (type: `string`):

next\_pagination\_token, next\_skip and has\_more, for continuing the search.

## `advertiserDetails` (type: `string`):

Identity and total ad count for the advertiser the search resolved to.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchType": "advertiser",
    "accountOwner": "Microsoft",
    "dateOption": "last-30-days",
    "impressionsUnit": "thousand",
    "fetchAdDetail": false,
    "maxResults": 25,
    "pageSize": 25,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("experthasan/linkedin-ad-library-api").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchType": "advertiser",
    "accountOwner": "Microsoft",
    "dateOption": "last-30-days",
    "impressionsUnit": "thousand",
    "fetchAdDetail": False,
    "maxResults": 25,
    "pageSize": 25,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("experthasan/linkedin-ad-library-api").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchType": "advertiser",
  "accountOwner": "Microsoft",
  "dateOption": "last-30-days",
  "impressionsUnit": "thousand",
  "fetchAdDetail": false,
  "maxResults": 25,
  "pageSize": 25,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call experthasan/linkedin-ad-library-api --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,experthasan/linkedin-ad-library-api"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/aLPvm2XqRQz2bvo2s/builds/LpHxC8JzuD0DpLZGF/openapi.json
