LinkedIn Ad Library API avatar

LinkedIn Ad Library API

Pricing

from $3.00 / 1,000 ad scrapeds

Go to Apify Store
LinkedIn Ad Library API

LinkedIn Ad Library API

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.

Pricing

from $3.00 / 1,000 ad scrapeds

Rating

0.0

(0)

Developer

Mahmudul Hasan

Mahmudul Hasan

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

Share

💼 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 FastAPI Python


✨ 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

ParameterTypeDefaultDescription
searchTypeString"advertiser"Required. One of "advertiser", "keyword", "payer", "ad_details".
accountOwnerString"Microsoft"Advertiser / company name. Required for "advertiser".
companyIdsArrayLinkedIn numeric company IDs. More precise than a name.
keywordStringFree-text term in the ad copy. Required for "keyword".
payerStringEntity that paid for the ads. Required for "payer".
adIdStringNumeric ad ID. Required for "ad_details".
countriesArrayTwo-letter country codes, e.g. ["US","GB"]. Empty = all.
dateOptionString"last-30-days"last-30-days, current-month, current-year, last-year, custom-date-range.
startDate / endDateStringYYYY-MM-DD. Setting either switches dateOption to custom.
impressionsMinValue / impressionsMaxValueIntegerImpression bounds, in the unit below.
impressionsUnitString"thousand"none, thousand, million.
paginationTokenStringContinue a previous run. From its PAGINATION record.
skipInteger0Ads to skip on the first page when continuing. From PAGINATION.
includedTargetingFacetCategoriesArrayOnly ads using these targeting categories.
excludedTargetingFacetCategoriesArrayExclude ads using these targeting categories.
fetchAdDetailBooleanfalseOpen each ad's detail page. Required for video files, full ad copy, CTA and payer. One extra request per ad.
maxResultsInteger100Stop after this many ads.
pageSizeInteger25Ads per page (the library serves 25).
maxPagesIntegerderivedHard page cap. Defaults to whatever reaches maxResults.
proxyConfigurationObjectApify ProxyRecommended for anything beyond a few hundred ads.

Example Input

1. Search by advertiser (most common)

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

2. Keyword search with impression floor and detail enrichment

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

3. Custom date range

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

4. Look up a single ad

{
"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.

{
"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:

{
"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 itselfvideo_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:

{
"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:

{
"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.

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.

EndpointPurpose
GET /searchMain search. Needs at least one of account_owner, company_ids, keyword, payer. Returns has_more, next_pagination_token and next_skip.
GET /advertiser_adsConvenience wrapper keyed on a single advertiser.
GET /detailsFull detail for one ad_id.
GET /advertiser_detailsIdentity and ad volume for an advertiser.
GET /filtersThe full filter vocabulary, for building UIs.
GET /countriesSupported country codes.
GET /healthLiveness probe. Public even when the RapidAPI gate is on.
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:

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.

VariableDefaultPurpose
REDIS_HOSTunsetEnables response caching. Unset = no cache, instant startup.
CACHE_TTL3600Cache lifetime in seconds.
PROXY_URLunsetOutbound proxy for LinkedIn requests.
REQUEST_TIMEOUT25Per-request timeout.
MAX_RETRIES3Attempts before giving up.
MIN_REQUEST_DELAY0.4Minimum gap between requests.
RATE_LIMIT50/secondInbound rate limit per client IP. Keep generous behind RapidAPI — all traffic shares a few proxy IPs.
PUBLIC_BASE_URLplaceholderWritten into the OpenAPI servers block.
RAPIDAPI_PROXY_SECRETunsetWhen set, rejects requests that did not come through RapidAPI.
WEB_CONCURRENCY2Uvicorn workers in the API image.

🧑‍💻 Programmatic usage

Apify client

$pip install apify-client
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

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

$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:

{
"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