DEV Community

Devil Scrapes
Devil Scrapes

Posted on

LinkedIn Ad Library Caps at 24 Ads — Here's the Real Pagination Endpoint

Quick answer

LinkedIn's Ad Library shows exactly 24 ads per advertiser and ?page=2, ?page=5, ?page=99 all return the identical 24 — we re-checked this live today and the ad IDs on all three pages were byte-for-byte the same set. That's a genuine no-op, not a bug in whatever tool you're using. Real pagination exists, but it isn't a query parameter — it's a keyset cursor served from a second endpoint, searchPaginationFragment, and the cursor token that unlocks it is hidden inside an HTML comment in a <code id="paginationMetadata"> element that never renders on screen. No login, no browser, no JS execution required — plain HTTP with a browser-shaped TLS fingerprint gets you there. We built a scraper around it and walked real advertisers to hundreds of ads deep.

Why does the Ad Library look capped at 24 ads? 🧱

Search LinkedIn's Ad Library for any advertiser and the page renders 24 ad-preview blocks, a "24 ads match your search criteria"-style count somewhere above them (LinkedIn's copy actually reads "N ads match your search criteria," and N is often in the tens of thousands), and no visible next-page control. Append ?page=2 to the URL and nothing changes.

We confirmed this today, live, against microsoft's Ad Library page:

from curl_cffi.requests import Session

s = Session(impersonate="firefox133")
p1 = s.get("https://www.linkedin.com/ad-library/search", params={"accountOwner": "microsoft"})
p2 = s.get("https://www.linkedin.com/ad-library/search", params={"accountOwner": "microsoft", "page": "5"})

# same 24 ad IDs, same order, every time
assert {a for a in p1.text.split('"ad-library/detail/')[1::2]} == \
       {a for a in p2.text.split('"ad-library/detail/')[1::2]}
Enter fullscreen mode Exit fullscreen mode

It's a reasonable place to stop looking. The rest of the ad list clearly loads somehow when you scroll on linkedin.com in a real browser, the request that does it isn't in the page's initial HTML, and the obvious next step — open devtools, watch for a fetch — lands on a large, actively-obfuscated JS bundle. Concluding "client-side infinite scroll, not worth reverse-engineering" is the sane read of that evidence. It's also incomplete: the real mechanism is server-side, not client-side, and it's sitting in the HTML you already downloaded.

Where does the real pagination cursor actually live? 🔍

Every Ad Library search response — including page 1 — embeds a hidden element:

<code id="paginationMetadata" style="display:none">
<!--{"isLastPage":false,"paginationToken":"1550485814-1788934976256"}-->
</code>
Enter fullscreen mode Exit fullscreen mode

The interesting part isn't that it's hidden with CSS — that's normal and greppable. It's that the JSON payload sits inside an HTML comment, nested inside the element, rather than as the element's own text content or a data-* attribute. Our parser pulls it with:

PAGINATION_METADATA_SELECTOR = "#paginationMetadata"
PAGINATION_METADATA_COMMENT_PATTERN = re.compile(r"<!--(\{.*?\})-->", re.DOTALL)

code_node = tree.css_first(PAGINATION_METADATA_SELECTOR)
match = PAGINATION_METADATA_COMMENT_PATTERN.search(code_node.html or "")
payload = json.loads(match.group(1))  # {"isLastPage": ..., "paginationToken": "..."}
Enter fullscreen mode Exit fullscreen mode

paginationToken is a two-part cursor — an ad ID and an epoch-millisecond timestamp, dash-joined (1550485814-1788934976256 in the example above). Feed it back into a second endpoint and you get the next page as a server-rendered HTML fragment, not JSON:

GET https://www.linkedin.com/ad-library/searchPaginationFragment
    ?accountOwner=microsoft&paginationToken=1550485814-1788934976256
Enter fullscreen mode Exit fullscreen mode

That fragment contains 24 more ad-preview blocks and its own paginationMetadata comment with the next token. Chain it, and the 24-ad ceiling turns out to have been page 1 of an arbitrarily deep list the whole time.

This is why grepping the page source for paginationToken= — the natural first move, since that's the string you'd search for if you assumed pagination worked the way ?page=N pagination usually does — comes up empty. It only ever appears as a JSON value, inside a comment, never as a literal query-string fragment anywhere in the document. An implausibly clean negative like that is worth re-running before you trust it; the string existing in three different forms (query param, JSON key, comment payload) and only one of those forms being real is exactly the kind of thing a targeted grep misses and a full-text read of the response catches.

How deep does it actually go? 📊

We re-walked three advertisers live today, no proxy, single un-rotated session, to see how far the real cursor goes before LinkedIn says stop:

Advertiser First page Unique ads collected Pages walked How it ended
apify 24 182 9 ad-less page, isLastPage:true
typeform 24 201 10 ad-less page, isLastPage:true
microsoft 24 1,440+ 60 (our own cap) still going — didn't hit a natural end

apify and typeform terminate naturally well under 250 ads; microsoft is a large enough advertiser that we hit our own 60-page test cap before LinkedIn's list ran out — the page itself reports "27,240 ads match your search criteria" for that account, which we're not claiming is all walkable in one pass, but it tells you the 24-ad ceiling has nothing to do with how many ads actually exist. Either way: 24 was never the real number for any of these three.

How do you know when you've actually reached the end? 🏁

The instinct is to trust isLastPage and stop when it flips to true. On the two advertisers whose lists we walked to completion today, that's exactly what happened — but the page it appeared on wasn't a "you're done" marker page, it was a normal-shaped continuation page with zero ad blocks on it. If your parser's stop condition is "no ads found," you'll get the right answer, but if it's "no ads and no metadata," you'll misdiagnose a clean ending as a broken page. LinkedIn's own markup does document a second ending shape too — a plain HTTP 200 with a zero-length body — which our source doesn't rule out; we just didn't hit it on these three walks. Build for both.

One more thing a naive re-implementation trips on: hit this endpoint fast and unauthenticated from a single session and you will eventually draw an HTTP 429, mid-walk, with a normal-looking response body — not a hard block, a rate limit. We hit it ourselves live while probing microsoft today. Treat it as retriable, back off, and it clears on the next attempt.

What we handle for you 🛡️

This is the part where "we found the endpoint" turns into "you get clean rows without babysitting it":

  • 🛡️ Browser TLS fingerprint impersonation on every request via curl-cffi — no bare-Python handshake anywhere near this target.
  • 🌐 Apify Proxy exit rotation — every attempt mints a fresh proxy session, so a 429 or a challenge doesn't retry into the same exit IP that just got flagged.
  • 🔁 Retry with exponential backoff on 408 / 429 / 503 and network errors, honoring Retry-After — the exact class of response we hit live while writing this article.
  • 🧱 Per-advertiser fault isolation — one advertiser hitting a wall doesn't sink the other nineteen in your batch.
  • 🧊 Deduped, typed rows — a stable dedup key per ad (its ID, or a content fingerprint when there's no ID) so an overlapping page boundary never double-bills you for the same creative.
  • 🏁 Honest run status — every run's status message is prefixed [COMPLETE] or [TRUNCATED], so you always know whether you got the whole list or hit a cap you set.

Try it yourself ⚙️

from apify_client import ApifyClient

client = ApifyClient("<YOUR_API_TOKEN>")
run = client.actor("DevilScrapes/linkedin-ad-library-scraper").call(
    run_input={
        "advertiserNames": ["nike", "salesforce", "hubspot"],
        "maxAdsPerAdvertiser": 200,
    }
)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["advertiser_name"], "", item["creative_type"], "", item["page_number"])
Enter fullscreen mode Exit fullscreen mode

Sample output row:

{
  "advertiser_name": "salesforce",
  "ad_id": "1546052924",
  "creative_type": "SPONSORED_STATUS_UPDATE",
  "ad_copy": "Looking forward to this one tomorrow, we have some brilliant trailblazer speakers to share their #datamigration challenges…",
  "image_urls": ["https://media.licdn.com/dms/image/v2/D4E03AQFiUMm9kqlKMQ/..."],
  "destination_url": null,
  "linkedin_detail_url": "https://www.linkedin.com/ad-library/detail/1546052924",
  "page_number": 1,
  "scraped_at": "2026-09-10T07:48:06.525134Z"
}
Enter fullscreen mode Exit fullscreen mode

Pricing 💰

Pay-per-event: $0.20 flat per run, plus $0.003 per ad row written. A run collecting 1,000 ads across a batch of advertisers costs $3.20 total. An advertiser with zero ads still succeeds — you pay the start fee only, never a per-row charge for rows that don't exist.

FAQ ❓

Is this legal to scrape?
The Actor only reads what LinkedIn's Ad Library already serves to an anonymous, logged-out visitor — no login, no session cookies, no credential harvesting. Respect LinkedIn's terms of service for how you use the output commercially.

Will this break if LinkedIn changes the endpoint?
Any scraper keyed to undocumented markup can break on a redesign — that's true of searchPaginationFragment the same as it would be of a public API. We watch it and ship fixes fast; open an issue on the Actor's Console page the moment something looks off.

What happens if an advertiser genuinely has no ads?
The run succeeds with zero rows for that advertiser and a status message that says so — a real zero-match result is never treated as a failure, and never billed a per-row charge.

Why does my run say [TRUNCATED] instead of [COMPLETE]?
One of your caps (maxAdsPerAdvertiser, maxPagesPerAdvertiser, maxTotalResults, or maxRunSeconds) was hit before the advertiser's real list ran out. Raise the relevant cap and re-run for the rest — you already have everything collected so far.

Can I track an advertiser's ads over time?
Each run is a snapshot. Schedule it daily or weekly in Apify Console and diff successive exports to see what's new, changed, or pulled down.


Full field reference, input options, and limitations live on the LinkedIn Ad Library Scraper Store listing — free credit to try it, no card required.

Built by Devil Scrapes. We read the whole response before we trust a "there's nothing here," and we rotate fingerprints and proxies so you don't have to babysit the retry loop.

Top comments (0)