Meta AdsHow-to

Facebook Ad Library API in Python: export ads to CSV

A Python script searches the Facebook Ad Library by keyword and writes ads to CSV: 50 ads from 32 advertisers in 5 credits and 12 seconds.

5 min read

Short answer

  • Meta's official Ad Library API returns only political ads and ads delivered to the UK or EU, and needs a confirmed identity first.
  • ToolzerHub's Meta Ads API searches the Ad Library for any country; a Python script wrote 50 protein powder ads from 32 advertisers to CSV in 12 seconds.
  • A call returns up to 10 ads and costs 1 credit, so 50 ads cost 5. The data holds template text and repeat ad IDs, so clean both before you count.
On this page

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.

python
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.

bash
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 INSTAGRAM 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 where text contains {{ before you analyse copy.
  • On active ads end_date is the last day the ad was seen, not a scheduled stop: 48 of the 50 had the day of the run. Use is_active to 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_name and text to count creatives.
  • Another market: pass a different country code, such as GB or DE.
  • One advertiser instead of a keyword: call /v1/meta/page with page_id; /v1/meta/search-pages?q=<brand> finds the ID.
  • Stopped ads too: set active_status to all.

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.

Get this data from your own code

The Meta Ad Library API returns it as JSON with one x-api-key header. 100 free credits monthly.