# Facebook Ad Library Scraper — Facebook Ads by Keyword & Country (`steadyfetch/facebook-ad-library-scraper`) Actor

Facebook ads by keyword, advertiser page or country, from the Ad Library: advertiser, page ID, flight dates, live status, platforms, creative text, landing page, media URLs and EU spend bands. From $2.00 per 1,000 ads; transcripts are an opt-in add-on. A search that finds nothing is never charged.

- **URL**: https://apify.com/steadyfetch/facebook-ad-library-scraper.md
- **Developed by:** [Steadyfetch Team](https://apify.com/steadyfetch) (community)
- **Categories:** Social media, Lead generation, E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 ads

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Facebook Ad Library Scraper — Facebook Ads by Keyword & Country

**Type a keyword, an advertiser's name or paste an Ad Library link and get their Facebook and Instagram ads back as structured rows.** That link can be a single ad's own link (`…/ads/library/?id=123…`), a bare ad ID, or an advertiser's page link; a single ad is a first-class input here, not a workaround. One JSON row per ad: the advertiser and page ID, when the ad started and stopped, whether it is still running, the platforms it runs on, the ad's headline, caption and body copy, its landing page, its video or image URL, the collation it belongs to, and the EU spend and impressions bands where Meta publishes them. **From $2.00 per 1,000 ads** on the Business plan ($4.00 on the free plan), platform usage included, no start fee. You are charged only when an ad row lands in your dataset: a search that matches nothing, a walled or rate-limited fetch and an ad whose row cannot be read all cost $0.

**Using an AI agent?** Pin this actor in Apify's MCP server with one link: `https://mcp.apify.com?tools=steadyfetch/facebook-ad-library-scraper`

- **Actor id:** `steadyfetch/facebook-ad-library-scraper`
- **Input:** `{ "searchQueries": ["fitness app"] }` — the one field you have to set.
- **Cap the bill:** set `maxTotalChargeUsd` on the run (a run option, not Actor input), e.g. `0.50` — the run stops when it reaches it.
- **Your cap is the cap.** `maxAds` (default 100) caps the whole run and `searchMaxAds` (default 25) caps each keyword or page — the run never delivers or bills a row past either. If your input carries `maxItems`, `resultsLimit`, `limit` or `count` instead — the names other Ad Library scrapers use — the smallest of them is read as `maxAds` and one uncharged note row says so. Add `maxTotalChargeUsd` and the run stops at whichever comes first; rows already in hand ship, uncharged if they could not be billed.

**Just want to see it work?** Click **Start** with nothing set and the run is a 3-ad sample — currently running US ads matching "fitness app", charged like any run (about $0.012 on the free plan, $0.006 on Business). Set only the settings — a country, an ad format, a cap — and no keyword or link, and that same sample runs **under your settings**, with one uncharged note row saying which ones it used.

**Want the ad's words too?** Turn on **Add the ad's transcript** (`includeTranscript`) and every ad with a video creative is also transcribed — `transcript`, the first-3-seconds `hook3s`, `language`, `durationSeconds` and timestamped `segments` ride the same row. That is charged as one extra **Ad transcript**, and only when a transcript is actually delivered. If transcripts are the whole reason you are here, our [Facebook Ads Transcript Scraper](https://apify.com/steadyfetch/facebook-ads-transcript-scraper) is built around them and also reads image-ad text and on-screen text on silent ads.

### What you get

| Field | What it is |
|---|---|
| `adArchiveId` | The ad's Ad Library ID — the one stable key. |
| `adLibraryUrl` | A link straight back to the ad in Meta's Ad Library. |
| `pageName` · `pageId` · `pageProfileUri` | The advertiser: their page name, its numeric ID and their profile link. |
| `pageCategories` · `pageLikeCount` · `pageIsDeleted` · `byline` | How Meta files the advertiser, their follower count, and the disclaimer byline on political ads. |
| `isActive` · `startDate` · `endDate` · `totalActiveTimeSeconds` | The flight: whether it is still running, and when it ran. |
| `publisherPlatforms` · `countries` | Where the ad runs — Facebook, Instagram, Messenger, Audience Network, Threads — and the countries Meta names for it. |
| `displayFormat` · `videoUrl` · `imageUrl` | The creative: its format, and a direct link to the video or image. |
| `title` · `caption` · `linkDescription` · `linkUrl` | The ad's headline, its display URL and the page it sends people to. |
| `adText` · `adTextIsTemplate` | The ad's body copy — and a flag that is `true` when Meta serves a dynamic-product placeholder (`{{product.name}}`) instead of words, so you can filter template ads out instead of reading them as copy. |
| `ctaText` · `ctaType` | The call-to-action button. |
| `categories` · `gatedType` · `containsSensitiveContent` · `containsDigitalCreatedMedia` · `disclaimerLabel` | Meta's own classification of the ad, including its AI-generated-media flag. |
| `collationId` · `collationCount` | One creative running under several ad IDs: the group, and how many ads are in it. |
| `currency` · `spend` · `impressionsText` · `reachEstimate` | The EU spend, impressions and reach bands, where the law makes Meta publish them. |
| `transcript` · `hook3s` · `language` · `durationSeconds` · `segments` | Only with **Add the ad's transcript** on. |
| `searchQuery` · `sourceIndex` | Which keyword, page or link this row came from, so a multi-query run sorts itself. |
| `status` · `charged` · `statusReason` | What happened to this ad, whether it was billed, and why — on every row. |

**What is null, and why.** `spend`, `impressionsText` and `reachEstimate` are filled only where the law makes Meta publish them — EU-targeted and political ads. On an ordinary US ad they are `null`, and that is the Ad Library's answer, not a scrape miss. The advertiser's phone number, business address and page history live on the advertiser's *About* tab, not on the ad, and are not on this row. Every one of the 50 fields is present on every row: a value or `null`, never a column that comes and goes.

**Column names are ours and they stay put.** Meta's wire is read in one place and renamed once, so a Meta rename does not rename your columns, and a fixed-schema table or sheet never breaks on a new row. What a field means is in the table above, not in a Facebook internal name.

### What a row looks like

One delivered row from a real run — the keyword `fitness app`, 2026-09-12. One ad row is one **Ad** charge; the transcript fields are `null` because this run did not turn transcripts on. The advertiser's landing page and the creative's media link are shown here as placeholders — Facebook signs the media URL and expires it within days.

```json
{
  "status": "delivered",
  "charged": true,
  "statusReason": null,
  "adArchiveId": "1507887230933843",
  "adLibraryUrl": "https://www.facebook.com/ads/library/?id=1507887230933843",
  "searchQuery": "fitness app",
  "pageId": "102806245720688",
  "pageName": "Troponin Supplements",
  "pageProfileUri": "https://facebook.com/TroponinSupplements",
  "pageCategories": ["Vitamins/supplements"],
  "pageLikeCount": 1632,
  "pageIsDeleted": false,
  "byline": null,
  "isActive": true,
  "startDate": "2026-03-20T07:00:00.000Z",
  "endDate": "2026-09-12T07:00:00.000Z",
  "totalActiveTimeSeconds": null,
  "publisherPlatforms": ["FACEBOOK","INSTAGRAM","AUDIENCE_NETWORK"],
  "countries": null,
  "displayFormat": "DCO",
  "title": "{{product.name}}",
  "caption": "troponiniq.com",
  "linkUrl": "https://…",
  "linkDescription": null,
  "adText": "Always Intelligent, Never Artificial",
  "adTextIsTemplate": false,
  "ctaText": "Sign up",
  "ctaType": "SIGN_UP",
  "videoUrl": null,
  "imageUrl": "https://…",
  "categories": ["UNKNOWN"],
  "gatedType": "ELIGIBLE",
  "containsSensitiveContent": false,
  "containsDigitalCreatedMedia": false,
  "disclaimerLabel": null,
  "collationId": "2432929267140392",
  "collationCount": 1,
  "currency": null,
  "spend": null,
  "impressionsText": null,
  "reachEstimate": null,
  "transcriptStatus": null,
  "transcript": null,
  "hook3s": null,
  "hookStartSeconds": null,
  "language": null,
  "durationSeconds": null,
  "segments": null,
  "chargeEvents": {"adRow":1,"transcript":0,"surchargeMinutes":0},
  "sourceIndex": 1,
  "#k": "ad:1507887230933843",
  "#ce": {"ad-row":1}
}
```

### How often the data changes

Meta's Ad Library updates continuously: new ads appear within hours of going live, and an ad's `isActive` and `endDate` change the day an advertiser stops it. For competitor monitoring a **daily** run is the useful cadence; for a market survey, weekly. The `spend`, `impressions` and `reach` bands on EU political ads move slowly — monthly is enough. Video and image URLs are signed by Facebook's CDN and **expire within hours to days**, so fetch the creative soon after the run, or re-run to get fresh links.

### What one run can reach

Each keyword or advertiser page returns at most `searchMaxAds` ads (default 25; up to 500 per keyword — ask for more and the run continues at 500) and the whole run at most `maxAds` (default 100; up to 10,000 per run — ask for more and the run continues at 10,000, with one uncharged row saying so). Meta's own search stops serving deep pages a few thousand results in, whatever its counter says — so a broad keyword is better split by country or ad format than pushed to a huge cap. The run's status line and its `OUTPUT` record say exactly what happened: how many ads were listed, how many rows delivered and charged, how many your date filters dropped, and how many sat beyond your cap — never a silent stop. If one search stops short because its pages stopped carrying ads new to the run — usually because another of your keywords had already listed them — an uncharged note row says exactly that, names the run's own limit as the run's own, and never reports it as a verdict about the advertiser.

- **Newest ads first?** There is no sort control — the Ad Library serves its own order. To get the newest, set `startedAfter` to a recent date and leave `activeStatus` on *Currently running*; the search runs, older ads are dropped uncharged, and the rows that land are the recent ones.

### What is never charged

- A search that returns no ads — including a keyword that matches nothing and an advertiser with nothing running in that country.
- A search Facebook walled, rate-limited or refused: it comes back as one uncharged row that says so and can be retried — never as an empty run that looks like success.
- An Ad Library link that will not resolve, or names an ad Meta has removed.
- An ad whose row could not be read from Meta's response.
- Ads dropped by your own `startedAfter` / `startedBefore` window (they filter on the ad's start date, after the search) and ads beyond your `maxAds` cap.
- With **Add the ad's transcript** on: a silent or music-only ad, an image ad, an expired video link, and any transcription that failed. The ad row is still delivered; only the transcript goes unbilled.
- A time limit or a cost cap ends the **collecting**, never the **delivering**: rows already in hand ship in full, and any that could not be billed ship uncharged and say so.

### Pricing

| Event | What it is | Free plan | Business |
|---|---|---|---|
| **Ad** | One ad row delivered to your dataset. | $0.004 | $0.002 |
| **Ad transcript** | One video ad transcribed, only with `includeTranscript` on, only when a transcript is delivered. First 3 minutes included. | $0.020 | $0.010 |
| **Long-video minute (surcharge)** | One started minute beyond the included first 3 minutes of a transcribed video. | $0.005 | $0.005 |

Platform usage is included in those prices — there is no start fee and no compute bill on top.

### Notes

If it earned its keep, a rating helps other buyers find it, and saving the actor keeps it one click away.

Something wrong or missing? Open an issue on this actor's **Issues** tab and we will answer there.

*Unofficial. Not affiliated with, endorsed by, or sponsored by Meta Platforms, Inc.*

# Actor input Schema

## `searchQueries` (type: `array`):

Keywords or advertiser page names, e.g. \["fitness app", "Nike"] — each returns the ads currently running in the country below, as structured rows. Keywords that match nothing ship as clearly-labelled rows and are never charged. A single ad's ID or link belongs in "Ad Library links" (`adLibraryUrls`), not here; a website address (`nike.com`) is not a page name and finds nothing. What matches: an advertiser's page name, spelled as it appears on their Facebook page — `Nike`, `Acme Fitness` — or words that appear in the ads themselves, like `fitness app`. The country below is part of the match. Leave everything empty and click Start and the run is a 3-ad sample (currently running US ads matching "fitness app", charged like any run) — the fastest way to see real output. Change only the settings below and leave this empty and that sample runs under your settings, with one uncharged note row saying which ones it used.

## `adLibraryUrls` (type: `array`):

Paste links to individual ads — the ad's "Copy ad link" or your browser's address bar (https://www.facebook.com/ads/library/?id=123456789) — one row per ad, charged only when its row lands. A bare ad ID works too. An advertiser's Ad Library PAGE link (the address bar on their page — it has no `?id=` in it) works too: it is read as a search of that advertiser's ads, under the country, format and status you set, and each ad found is charged exactly as a keyword search would be. Links it can't resolve — removed ads, or a temporary block — ship as clearly-labelled rows and are never charged.

## `country` (type: `string`):

Which country's Ad Library to search — the two-letter code (US, GB, DE, SA …). Only used with search keywords and advertiser page links.

## `mediaType` (type: `string`):

"all" (default) returns every ad format. "video" returns only ads with a video creative — what you want when you also turn on the transcript. Only used with search keywords and advertiser page links.

## `activeStatus` (type: `string`):

"active" (default) returns ads running right now — what you usually want for competitor research. "all" also includes ads that have stopped running. Only used with search keywords and advertiser page links.

## `startedAfter` (type: `string`):

A date, YYYY-MM-DD, compared with the ad's Ad Library **start date** — the day it first ran, as the Library shows it — not with impressions or activity dates. Ads whose start date is earlier are dropped after the search and never charged. The Ad Library itself has no date filter on this query, so the search runs first and this filter is applied to what it returns — a narrow window may therefore return fewer ads than "Max ads per keyword". Leave empty for no lower bound.

## `startedBefore` (type: `string`):

A date, YYYY-MM-DD. Ads whose Ad Library start date is later are dropped after the search and never charged. Applied the same way as the filter above. Leave empty for no upper bound.

## `searchMaxAds` (type: `integer`):

How many ads each search keyword or advertiser page may return. Keeps a broad keyword from turning into a large bill. Up to 500 per keyword; ask for a bigger number and the run continues at 500, with one uncharged row saying so.

## `includeTranscript` (type: `boolean`):

OFF (default): you get the ad row only, charged as one "Ad". ON: every ad with a video creative is also transcribed and the row carries `transcript`, `hook3s`, `language`, `durationSeconds` and timestamped `segments` — charged as one extra "Ad transcript" on top of the ad row, and ONLY when a transcript is actually delivered. A silent or music-only ad, an image ad, an expired video link and a failed transcription all still ship their ad row and are never charged for the transcript. The first 3 minutes of each video are included in the transcript price; each started minute beyond that is charged as a "Long-video minute (surcharge)".

## `maxAds` (type: `integer`):

Hard ceiling — the run never delivers or bills more ad rows than this, across every keyword and link, whatever else is set. Other scrapers call this `resultsLimit` or `maxItems`; those names are read as this field too (`maxItems`, `resultsLimit`, `limit` and `count`, the smallest wins, with one uncharged note row saying what was read). Each delivered row is charged one "Ad". Up to 10,000 per run; ask for a bigger number and the run continues at 10,000, with one uncharged row saying so.

## Actor input object example

```json
{
  "country": "US",
  "mediaType": "all",
  "activeStatus": "active",
  "searchMaxAds": 25,
  "includeTranscript": false,
  "maxAds": 100
}
```

# Actor output Schema

## `ads` (type: `string`):

One row per ad found, with the keyword or link that found it. Every row carries the advertiser (page name, page ID, profile, categories, follower count), the flight (is it still running, when it started and stopped, how long it has run), where it runs (Facebook, Instagram, Messenger, Audience Network, Threads) and for which countries, the creative (format, video or image URL), the words (ad body text, headline, caption, link description, landing page) and the call-to-action, Meta's own classification (categories, gated type, sensitive-content and AI-media flags, disclaimer label), the collation a repeated creative belongs to, and the EU spend, impressions and reach bands where Meta publishes them. WHAT YOU ARE CHARGED FOR: `charged` says whether that row was billed, and one "Ad" is charged for each row whose `status` is `delivered` — and for no other row. Every other status ships uncharged and says why in `statusReason`: `search_no_results` (the search ran and the Ad Library had nothing for it), `skipped.all_repeat` (this run stopped a search whose pages held only ads it had already listed — a limit on this run, not a verdict about the advertiser; run that search on its own to walk it further), `search_blocked` (Facebook walled, rate-limited or refused it, or an ad link would not resolve — retryable, re-run), `unavailable_removed` (Meta has taken the ad down), `unreadable_row` (Meta's response carried no usable ad ID), `input_note` (the run saying what it read from your input), `input_error` (something in your input it could not use), `skipped_budget` (your maximum cost per run was reached) and `skipped_deadline` (the run timeout was reached). Ads dropped by your own date filters, and ads beyond your `maxAds` cap, are never fetched or charged and are counted in the run summary instead of shipping a row each. An ad already in hand when your maximum cost is reached still delivers in full, uncharged, and says so. WITH "Add the ad's transcript" ON, every row also carries `transcriptStatus`, and one "Ad transcript" is charged ONLY where it reads `transcribed` — plus one "Long-video minute (surcharge)" per started minute beyond the included first 3 minutes. Those rows carry `transcript`, `hook3s`, `language`, `durationSeconds` and timestamped `segments`. Nothing is charged for the transcript where `transcriptStatus` reads `no_speech` (a silent or music-only ad), `not_video` (an image ad), `too_long` (longer than this actor transcribes), `expired` (the video link had already expired), `failed`, `skipped_budget`, `vendor_budget` or `skipped_size_cap` — the ad row itself is delivered and charged exactly as it would have been. `hook3s` is the first 3 seconds of speech; an ad with no speech at all has it as null, never a blank string.

## `summary` (type: `string`):

Ad rows delivered, transcripts delivered, uncharged misses by kind, the ads your date window or your maxAds cap cut, and the honest status message.

## `errors` (type: `string`):

Present only when the opt-in transcript leg failed after all retries: the ad's video URL and the reason. The ad row itself was still delivered.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("steadyfetch/facebook-ad-library-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("steadyfetch/facebook-ad-library-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call steadyfetch/facebook-ad-library-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,steadyfetch/facebook-ad-library-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/afeluRePAha3PilOn/builds/YFH2Ck2biWX3L7yAg/openapi.json
