If you sell sneakers, SaaS, or anything else that competes in paid social, the Meta Ad Library is the closest thing to a public feed of what other advertisers are running right now on Facebook and Instagram. You can search by keyword, open a brand's Page, and read creative text, CTAs, and which placements an ad used. Doing that once in the browser is easy. Doing it every day for a shortlist of competitors, and noticing which creatives appeared or vanished, is a scraping problem.
This post builds adtrack.py, one Python file with eight commands:
-
probe— checks whatfacebook.com/ads/libraryreturns from your IP -
companies— resolves an advertiser name to Page IDs -
search— keyword ad search with country and status filters -
company— every active ad for one Page -
ad/transcript— one archive record, plus speech text from video ads -
snapshot/report— SQLite history and a new / gone / creative-changed diff
I ran everything on November 6, 2026 from a cloud server with Python 3.13 and requests 2.34, against Nike's public Page (page_id=15087023444) and the keyword nike in the US. The outputs below are from those runs. That includes the part where Meta's own Ad Library UI refused every request from this IP with HTTP 403.
Read this first. The Meta Ad Library is a public transparency tool. Meta still publishes robots.txt and Automated Data Collection Terms that restrict automated collection from Facebook without permission. Unofficial scraping of
facebook.com/ads/librarycan violate those terms even when the same ads are visible in a browser. Prefer the official Ad Library API whenever it covers your use case. Review Meta's terms and your own legal risk before you automate anything. The DIY probe below is only there to show what a datacenter IP actually gets; I did not build a parser on top of a blocked HTML response.
Prefer the official Meta Ad Library API
Meta documents a Graph API edge called ads_archive. You call it like any other Graph endpoint:
curl -G \ -d "search_terms=nike" \ -d "ad_reached_countries=['US']" \ -d "ad_type=ALL" \ -d "ad_active_status=ACTIVE" \ -d "fields=id,page_id,page_name,ad_creative_bodies,ad_creative_link_titles,ad_delivery_start_time,ad_delivery_stop_time,ad_snapshot_url,publisher_platforms" \ -d "access_token=ACCESS_TOKEN" \ "https://graph.facebook.com/v21.0/ads_archive" How you get a token (high level):
- Confirm your identity with Meta (the same ID check used for people who run ads about social issues, elections, or politics). Meta says this can take a few days.
- Register as a developer at developers.facebook.com and create an app.
- Generate a user access token (Graph API Explorer is the usual starting point). Short-lived tokens expire in hours; exchange them for a long-lived token if you need days or weeks.
- Call
ads_archivewith at leastad_reached_countriesand eithersearch_termsorsearch_page_ids(up to 10 Page IDs).
Useful parameters and fields (from Meta's reference, checked November 2026):
| Piece | Notes |
|---|---|
search_terms / search_type | Up to 100 characters. KEYWORD_UNORDERED (default) or KEYWORD_EXACT_PHRASE. |
search_page_ids | Up to 10 Facebook Page IDs per call. |
ad_type | ALL, POLITICAL_AND_ISSUE_ADS, HOUSING_ADS, EMPLOYMENT_ADS, FINANCIAL_PRODUCTS_AND_SERVICES_ADS. |
ad_active_status | ACTIVE (default), INACTIVE, ALL. |
media_type | ALL, IMAGE, VIDEO, MEME, NONE. |
| Always-available fields | id, page_id, page_name, creative body/title/caption/description lists, delivery start/stop, ad_snapshot_url, publisher_platforms, languages. |
| Pagination | Follow paging.next / after. An empty data array means you are done. |
| Rate limits | Meta surfaces error 613 when you exceed the limit. Back off and resume; don't hammer. |
Coverage caveat that matters for competitor research: Meta's own docs note that ads which did not reach any location in the EU only come back from ads_archive when they are about social issues, elections, or politics. In practice that means ordinary commercial ads that only ran in the US often do not appear in the official API, even though they show up in the public Ad Library UI. Political and issue ads are available more broadly. EU/UK delivery unlocks commercial ads in the API.
I did not complete Meta's identity confirmation for this post, so there is no live ads_archive transcript below — only a deliberate bad-token call that returned the expected OAuth error (Invalid OAuth access token). If the official API covers your countries and ad types, stop here and use it. The rest of this post is for the cases it doesn't cover, and for the UI path when a server can't open the Library at all.
Step 1: Probe the public UI before you scrape it
facebook.com/ads/library is a JavaScript app. Lots of scrapers try to hit the same GraphQL or HTML routes the browser uses. Before you invest in a parser, ask what the machine you'll run on actually receives:
def classify(r): ctype = r.headers.get("content-type", "") body = r.text[:200].lower() if r.status_code == 403: return "blocked_403" if r.status_code == 429: return "rate_limited" if r.status_code == 200 and "text/html" in ctype and "challenge" in body: return "challenge_html" if r.status_code == 200 and "text/html" in ctype and len(r.content) > 50_000: return "html_ok" return f"other_{r.status_code}" probe hits the Library home, a keyword search URL, one ad-by-id URL, and robots.txt, with redirects off so a challenge page doesn't look like success:
$ python adtrack.py probe label status bytes verdict library home 403 481 blocked_403 keyword search 403 481 blocked_403 ad by id 403 481 blocked_403 robots.txt 200 43487 other_200 Every Ad Library HTML URL returned 403 with a 481-byte challenge shell (title: Ad Library, a fetch('/__rd_verify_...') then reload). That matches what this same box saw earlier on November 6. robots.txt itself loaded (43 KB) and opens with Meta's notice that automated collection is prohibited without express written permission, pointing at the Automated Data Collection Terms.
From a residential browser the Library usually loads. From this datacenter IP it does not. Waiting and retrying did not help. A headless Chrome on the same network is the same story: Meta's edge challenge fires before you get the app HTML. So I'm not printing a DIY HTML/GraphQL parser I haven't been able to test end-to-end from this server.
What the probe tells you:
-
html_ok: the UI loads from this IP for now. Keep volume tiny and expect it to stop. -
blocked_403/challenge_html: the IP is filtered. Backoff won't fix it. Use the official API, another network you control and are allowed to use, or a data API that already solved delivery. -
rate_limited: stop. Don't rotate throwaway accounts to get around it.
Step 2: A data source that works from a server
Since the UI was walled, the tracker gets its data from ScrapeAtlas*, using the Facebook Ad Library endpoints from its OpenAPI spec. All of them are plain GET requests on https://api.scrapeatlas.com, authenticated with an x-api-key header:
-
GET /v1/facebook/adLibrary/search/companies?query=...— advertiser name suggestions (page_id,name,category,likes,verification) -
GET /v1/facebook/adLibrary/search/ads?query=...&country=US&status=ACTIVE&trim=true— one page of keyword search hits, plussearchResultsCountand an opaquecursor -
GET /v1/facebook/adLibrary/company/ads?pageId=...&country=US&status=ACTIVE&trim=true— one page of ads for a Page (preferpageIdovercompanyNamewhen names collide) -
GET /v1/facebook/adLibrary/ad?id=...&trim=true— one archive record with creative snapshot, platforms, dates -
GET /v1/facebook/adLibrary/ad/transcript?id=...— speech text from the first available primary/carousel video (transcript_available=falsefor image-only ads)
Optional filters on search and company ads: ad_type, media_type, sort_by, language, start_date, end_date, search_type. POST variants exist for long cursors. There is also POST /v1/facebook/ad-library/companies/ads for a compact walk of company ads.
Each successful request costs 1 credit (including partial results). Failed requests cost 0. Responses carry success, coverage, and accounting.
The client waits at least a second between calls and backs off on 429 / 5xx:
API = "https://api.scrapeatlas.com" KEY = os.environ.get("SCRAPEATLAS_API_KEY", "") _last = 0.0 def sa(path, **params): global _last wait = 1.0 - (time.time() - _last) if wait > 0: time.sleep(wait) clean = {k: v for k, v in params.items() if v is not None and v != ""} for attempt in range(4): _last = time.time() r = requests.get(API + path, params=clean, headers={"x-api-key": KEY}, timeout=120) if r.status_code in (429, 500, 502, 503, 504): time.sleep(5 * 2 ** attempt) continue body = r.json() if not body.get("success"): raise RuntimeError(f"{path}: {body.get('failure') or body}") return body raise RuntimeError(f"{path}: gave up after retries") Setup
mkdir meta-ads-library && cd meta-ads-library python3 -m venv .venv && . .venv/bin/activate pip install requests export SCRAPEATLAS_API_KEY=your_key_here # never commit this Everything else is stdlib: argparse, sqlite3, hashlib, json, datetime.
Step 3: Resolve the advertiser, then search ads
Brand names are ambiguous. A search for Nike returns Nike, Nike Football, Nike Run Club, NikeWomen, a politician named Nikema Williams, BioNike, and more. Resolve the Page first:
$ python adtrack.py companies Nike 15 advertiser suggestion(s) for 'Nike' 15087023444 Nike Sportswear Store likes=39488771 BLUE_VERIFIED 51212153078 Nike Football Product/service likes=39997493 BLUE_VERIFIED 65604112975 Nike Run Club Sports & Recreation Venue likes=15261857 BLUE_VERIFIED ... The main Nike Page is 15087023444 (BLUE_VERIFIED, category Sportswear Store, Instagram handle nike). Keep that ID. Company-ad lookups by companyName need a unique exact match; pageId does not.
Keyword search is what you use when you care about a product term, not one brand:
$ python adtrack.py search nike --country US --status ACTIVE 30 ads on this page (searchResultsCount=20062, cursor=yes) 1016451784037642 Nike DPA Get the gear that goes hard on and off the field. 1120113070497516 Nike DPA Get the gear not afraid to put in the work. 962050096457659 Depop DPA Free Depop Shipping just dropped. ... 1420202216378107 Jordan DPA Shop all your Jordan essentials here. ... 1298488261783748 Whatnot VIDEO Take your sneaker game to the next level... ... About 20,000 active US results for nike on November 6. The first page mixed Nike's own DPA creatives with resellers (Depop, Whatnot, Shiekh), retailers (Nordstrom Rack), and unrelated theme apps that happen to match the keyword. That's expected for unordered keyword search — treat searchResultsCount as a library-wide estimate, and filter by page_name / page_id in your own code when you only want one advertiser.
Useful fields on each hit (trimmed shape):
ad_archive_id, page_id, page_name, is_active, start_date_string, end_date_string, publisher_platform, url, snapshot.{ body.text, title, cta_text, cta_type, display_format, link_url, cards, images, videos } display_format values I saw in these runs: DPA, DCO, VIDEO, IMAGE, CAROUSEL. Dynamic product ads (DPA) dominate Nike's active set.
Step 4: Company ads, one ad, and a video transcript
Pass the Page ID you resolved:
$ python adtrack.py company --page-id 15087023444 --country US 30 company ads (searchResultsCount=128, cursor=yes) 1016451784037642 DPA active=True Get the gear that goes hard on and off the field. 1120113070497516 DPA active=True Get the gear not afraid to put in the work. 1741150940324450 DCO active=True Performance-driven details meet elevated silhouettes in NikeSKIMS... ... 128 active US ads for Nike's main Page on that morning; the endpoint returns one page of 30 plus a cursor for the rest. Most were DPA catalog ads with a few DCO creatives mixed in.
One archive ID is enough for the detail call. I picked a VIDEO creative that showed up when filtering media_type=VIDEO:
$ python adtrack.py ad --id 4837786159822501 { "ad_archive_id": "4837786159822501", "page_id": "15087023444", "page_name": "Nike", "is_active": true, "start_date": "2026-11-02T08:00:00.000Z", "end_date": "2026-11-05T08:00:00.000Z", "url": "https://www.facebook.com/ads/library/?id=4837786159822501", "publisher_platforms": ["FACEBOOK", "INSTAGRAM"], "display_format": "VIDEO", "cta_text": "Shop now", "cta_type": "SHOP_NOW", "title": "Gifts at Nike", "body": "For all the ways they show up. Gift Nike Studio Fleece and Nike Zoom Vomero 5.", "link_url": "https://www.nike.com/w/shop-the-look-5-75ryi", "has_video": true, "creative_fp": "26f68927c2936480" } And the transcript endpoint pulls speech from that video:
$ python adtrack.py transcript --id 4837786159822501 ad_id=4837786159822501 transcript_available=True How do you think the person who helped you achieve your dreams? My career takes me all over the world for basketball, so I wanted to send the perfect gift home to my biggest supporter. My mom's been rocking Nike and riding with me since the day one... Thanks mom. Whisper-style ASR isn't perfect (Zoom Vomero 5 came back as "Zoom Vermeer of five", "facet" as "faucet"), but it's enough to search creatives by spoken product names and hooks. Image-only ads return transcript_available=false.
Step 5: A SQLite competitor tracker
The useful part for ongoing research is not one page of JSON — it's noticing what changed since yesterday. snapshot stores one page of ads per track key; report diffs the latest two runs.
Track keys look like q:nike:US:ACTIVE for keyword search and page:15087023444:US:ACTIVE for a Page. Pass @PAGE_ID to snapshot company ads:
SCHEMA = """ CREATE TABLE IF NOT EXISTS run ( id INTEGER PRIMARY KEY, track_key TEXT NOT NULL, fetched_at TEXT NOT NULL, result_count INTEGER, total_count INTEGER ); CREATE TABLE IF NOT EXISTS ad_snap ( run_id INTEGER NOT NULL, track_key TEXT NOT NULL, ad_archive_id TEXT NOT NULL, page_id TEXT, page_name TEXT, is_active INTEGER, display_format TEXT, cta_text TEXT, body TEXT, link_url TEXT, start_date TEXT, end_date TEXT, publisher_platforms TEXT, creative_fp TEXT, url TEXT, PRIMARY KEY (run_id, ad_archive_id) ); """ creative_fp is a short hash of body + title + CTA + format + link, so a silent rewrite of the copy shows up even when the archive ID stays the same:
def creative_fp(ad): snap = ad.get("snapshot") or {} parts = [ body_text(snap), snap.get("title") or "", snap.get("cta_text") or "", snap.get("cta_type") or "", snap.get("display_format") or "", snap.get("link_url") or "", ] raw = "\n".join(p.strip() for p in parts if p).encode() return hashlib.sha256(raw).hexdigest()[:16] First snapshot on November 6 (Asia/Saigon morning; UTC timestamps below):
$ python adtrack.py snapshot nike @15087023444 --country US nike: saved run #1 30 ads (library total≈20100) key=q:nike:US:ACTIVE @15087023444: saved run #2 30 ads (library total≈128) key=page:15087023444:US:ACTIVE $ python adtrack.py report q:nike:US:ACTIVE latest run #1 at 2026-11-06T04:06:14+00:00 30 ads (library total≈20100) first snapshot — page × format: Nike DPA n=10 Depop DPA n=4 Nordstrom Rack DCO n=2 Whatnot VIDEO n=2 Jordan DPA n=1 ... page:15087023444:US:ACTIVE latest run #2 at 2026-11-06T04:06:14+00:00 30 ads (library total≈128) first snapshot — page × format: Nike DPA n=27 Nike DCO n=3 About seventy seconds later I snapped the same two tracks again. The first page of 30 ads was identical — no new archive IDs, none gone, no creative fingerprint changes — but the keyword library total moved:
$ python adtrack.py snapshot nike @15087023444 --country US nike: saved run #3 30 ads (library total≈20042) key=q:nike:US:ACTIVE @15087023444: saved run #4 30 ads (library total≈128) key=page:15087023444:US:ACTIVE $ python adtrack.py report nike @15087023444 q:nike:US:ACTIVE latest run #3 at 2026-11-06T04:07:25+00:00 30 ads (library total≈20042) vs run #1 at 2026-11-06T04:06:14+00:00 new=0 gone=0 creative_changed=0 library total 20100 -> 20042 (-58) page:15087023444:US:ACTIVE latest run #4 at 2026-11-06T04:07:25+00:00 30 ads (library total≈128) vs run #2 at 2026-11-06T04:06:14+00:00 new=0 gone=0 creative_changed=0 library total 128 -> 128 (+0) That's the honest short-interval result: Nike's own active set was stable over a minute, while the broader nike keyword pool shed about 58 library hits. Run snapshot once a day (cron, systemd timer, GitHub Action) and the new / gone / creative_changed lines start earning their keep. Each snapshot is one credit per track; daily tracking of 20 competitors is about 20 credits a day if you only pull the first page, plus more if you walk cursors.
Pitfalls I ran into
- 403 from datacenter IPs is normal. Don't treat it as a parser bug. Probe first.
- Official API coverage ≠ UI coverage. US-only commercial ads often won't appear in
ads_archive. The public Library (and the endpoints above) still show them. - Keyword search is noisy.
nikereturns Depop listings, theme apps, and Jordan ads. Prefercompany --page-idwhen you care about one advertiser. - Names collide. Use
pageId.companyNameneeds a unique exact match. - One page is 30 ads. Walk
cursor(POST if the token is long) when you need the rest.searchResultsCountis the library estimate, not "rows in this response". - DPA/DCO creatives share copy across many archive IDs. Fingerprint the text, but expect many IDs with the same body.
- Transcripts are ASR. Product names get mangled. Keep the archive ID and re-check the video when it matters.
- Terms still apply. Prefer
ads_archivewhen it fits. Don't build contact lists or republish creatives at scale from unofficial collection.
Summary
- Start with Meta's Ad Library API (
ads_archive): identity check, app, token, country + search. It is the supported path for political/issue ads and for commercial ads that reached the EU/UK. - Probe the public UI from the machine you'll run on. This server got HTTP 403 on every Ad Library HTML URL on November 6, 2026.
- When DIY is blocked or the official API's coverage doesn't include your US commercial competitors, the Ad Library endpoints above return companies, search hits, company ads, ad detail, and video transcripts as plain JSON.
- SQLite snapshots turn a 30-ad page into a competitor history: new archive IDs, disappeared ads, and creative rewrites.
The full adtrack.py is about 520 lines with the CLI wiring. If Meta renames a snapshot field and a command starts failing, leave the error in the comments and I'll update the post.
*I build it.
