LinkedIn Ad Library API
Pricing
from $3.00 / 1,000 ad scrapeds
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
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.
✨ Features
- 🔍 Four search modes: by Advertiser, Keyword, Payer, or direct Ad ID lookup.
- 🌍 Country filtering: 256 codes, with fuzzy input (
usa,UK,Germanyall 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/millionunit. - 👤 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)
{"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
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:
{"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 boundnull.
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. Anulltoken withhas_more: trueis normal and means "resume from the first page atnext_skip" — it happens whenevermaxResultsis 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
skipas 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 yourmaxResultscuts 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# orpip 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. |
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 abovebreaktoken, 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
$pip install apify-client
from apify_client import ApifyClientclient = 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 LinkedInAdswith 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 limitingsrc/main.py Apify Actor wrapper — drives the app in-process over ASGIapi_models.py Response schemas — documentation for the OpenAPI specopenapi_compat.py OpenAPI 3.1 -> 3.0.3 conversion (RapidAPI cannot read 3.1)openapi.json/.yaml Committed spec, ready to upload to RapidAPIDockerfile 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 pointDEPLOY.md Easypanel deploy and RapidAPI listing guidescripts/generate_openapi.py Regenerate the spec after changing endpointsLinkedInAds/main.py HTTP client — session, retries, proxy, paginationparsers.py HTML → structured recordsconstants.py Headers, filter vocabulary, CSS selector candidatescountries.py ISO country codes + fuzzy normalisationtests/test_offline.py 227 offline checksfixtures/search_real.html saved live search page — image ads, the ground truthsearch_video.html saved live search page — video + article adssearch_page.html synthetic page for the heuristic fallback pathdetail_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
- LinkedIn Ad Library: https://www.linkedin.com/ad-library/home
- Apify SDK: https://docs.apify.com/sdk/python
- FastAPI: https://fastapi.tiangolo.com/