This script searches the Facebook Ad Library by keyword and writes each ad to a CSV row: advertiser, dates, platforms, format, ad text, landing page and a link back to the Library. For protein powder in the US it saved 50 active ads from 32 advertisers in 12 seconds.
What Meta's API covers
Meta's Ad Library API is the ads_archive endpoint of the Graph API. It returns ads about social issues, elections or politics, and ads of any type delivered to the UK or EU, and it needs a confirmed identity, a developer account and an app. A US-only commercial ad is not in it. Use it for political or EU ads; the setup steps are in our guide.
Export ads to CSV
We make ToolzerHub. Our Meta Ads API reads the public Ad Library for any country and returns the ads you see on the website as JSON. The script calls /v1/meta/search at 1 credit a call. Install requests (pip install requests) and get a key from the dashboard.
import csv
import os
import sys
import time
from datetime import datetime, timezone
import requests
URL = "https://api.toolzerhub.com/v1/meta/search"
HEADERS = {"x-api-key": os.environ["TOOLZERHUB_API_KEY"]}
FIELDS = ["ad_archive_id", "page_name", "is_active", "start_date", "end_date",
"platforms", "format", "text", "link_url", "cta", "library_url"]
def day(ts):
return datetime.fromtimestamp(ts, timezone.utc).date().isoformat() if ts else ""
def row(ad):
s = ad["snapshot"]
text = (s.get("body") or {}).get("text") or next(
(c.get("body") for c in s.get("cards") or [] if c.get("body")), "")
return {
"ad_archive_id": ad["ad_archive_id"],
"page_name": ad["page_name"],
"is_active": ad["is_active"],
"start_date": day(ad.get("start_date")),
"end_date": day(ad.get("end_date")),
"platforms": "|".join(ad.get("publisher_platform") or []),
"format": s.get("display_format"),
"text": text,
"link_url": s.get("link_url") or "",
"cta": s.get("cta_type") or "",
"library_url": f"https://www.facebook.com/ads/library/?id={ad['ad_archive_id']}",
}
def cell(value):
# Spreadsheets run text that starts with = + - or @ as a formula; an apostrophe keeps it text.
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + value
return value
def get(params, tries=5):
# 429 and 503 are retryable; the error body says how long to wait.
for attempt in range(tries):
r = requests.get(URL, headers=HEADERS, params=params, timeout=90)
if r.status_code not in (429, 503) or attempt == tries - 1:
r.raise_for_status()
return r.json()["data"]
wait = r.json().get("error", {}).get("details", {}).get("retryAfter", 5 * (attempt + 1))
time.sleep(min(wait, 60))
def search(query, country, pages):
cursor = None
for _ in range(pages):
params = {"q": query, "country": country, "active_status": "active"}
if cursor:
params["cursor"] = cursor
data = get(params)
yield from data["items"]
if not data["pageInfo"]["hasNextPage"]:
break
cursor = data["pageInfo"]["cursor"]
def main(query, country="US", pages=5, out="ads.csv"):
with open(out, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
count = 0
for ad in search(query, country, int(pages)):
writer.writerow({k: cell(v) for k, v in row(ad).items()})
count += 1
print(f"wrote {count} ads to {out}")
if __name__ == "__main__":
main(*sys.argv[1:])
Each call returns up to 10 ads and a pageInfo.cursor; the loop passes the cursor back until hasNextPage is false or it has read the pages you asked for. Dynamic and carousel ads sometimes keep their text in snapshot.cards instead of snapshot.body, so row() checks both. cell() puts an apostrophe in front of text that starts with =, +, - or @, so a spreadsheet shows it as text. If you open the CSV in Excel, import ad_archive_id as Text, because Excel keeps 15 digits and most of these IDs are longer.
export TOOLZERHUB_API_KEY=your_key
python ad_library_to_csv.py "protein powder" US 5 ads.csv
What came out
The run wrote 50 ads in 11.8 seconds. Three of the rows, with the text cut short (the file joins platforms with a pipe character; the table uses commas):
| page_name | format | platforms | text |
|---|---|---|---|
| ProDough Shop | VIDEO | FACEBOOK, INSTAGRAM, AUDIENCE_NETWORK, MESSENGER, THREADS | ProDough Protein lets you make the best high protein yogurt without th… |
| SAMBAZON | VIDEO | Sweet meets salty in the best way 💜✨ Açaí topped with crunchy, salty g… | |
| Paleovalley | DCO | FACEBOOK, INSTAGRAM, MESSENGER, THREADS | {{product.brand}} |
The 50 ads split into 22 video, 17 dynamic creative (DCO), 6 catalog (DPA) and 5 image ads, and some have been live for over two years. Three things to clean up:
- 14 of the 50 rows hold template text like
{{product.brand}}instead of copy, because catalog and dynamic ads fill those fields per viewer. Filter rows wheretextcontains{{before you analyse copy. - On active ads
end_dateis the last day the ad was seen, not a scheduled stop: 48 of the 50 had the day of the run. Useis_activeto tell running ads from stopped ones. - One creative can run under several ad IDs. SAMBAZON's açaí ad appears under four with the same text, and the 36 rows with real copy hold 27 distinct advertiser and text pairs. Group by
page_nameandtextto count creatives.
Change the search
- Another market: pass a different country code, such as
GBorDE. - One advertiser instead of a keyword: call
/v1/meta/pagewithpage_id;/v1/meta/search-pages?q=<brand>finds the ID. - Stopped ads too: set
active_statustoall.
Field-by-field details are in the Meta Ads API reference.
What it costs
Each search call costs 1 credit and returns up to 10 ads, so the 50-ad run cost 5 credits. You get 100 free credits a month; $19 buys 10,000 credits that never expire, and plans for higher volume cover more searches. Set your key and the first keyword is a CSV in under a minute.
To save the video files from the same response, see download videos from the Meta Ad Library. For tools that do this without code, see the best Facebook Ad Library scrapers.
We are not affiliated with Meta Platforms, Inc.