DEV Community

Cover image for Facebook Ads Library Scraper Charges per Result Instead of Compute Time
Crawler Bros
Crawler Bros

Posted on

Facebook Ads Library Scraper Charges per Result Instead of Compute Time

Why Traditional Compute Billing Fails for Social Media Scraping

Data engineers accustomed to traditional scraping infrastructure often expect to pay for the raw wall-clock time their scraper containers run. Under a standard time-based model, a scraper stuck behind a rate limit, waiting on proxy retries, or spinning on a slow network connection continues to accumulate charges. This misalignment makes budgeting for large-scale social media data extraction unpredictable.

The facebook-ads-library-scraper operates on a completely different pricing architecture. It uses a pay-per-event billing model where your expenses are tied directly to the volume of data successfully extracted and saved. Understanding this shift is critical when transitioning from self-hosted selenium instances to managed actors.

If your scraper runs slowly because Meta is throttling the connection or residential proxy routing is introduces latency, your bill does not increase. You are charged for the volume of actual results emitted. However, this means that your input configuration directly dictates your final bill. A single unconstrained search array can trigger an unexpected volume of billable events.

How Does the Pay Per Event Model Calculate Your Bill?

Every charge this Actor makes is one of the named events below, at the USD price shown. That list is exhaustive: there is no other cost, no separate platform-usage charge, and no subscription-plan rate on top.

The cost of running this scraper is driven by two specific event types:

  1. "Actor Start" (apify-actor-start): This is billed at a flat rate of $0.01 per GB of memory allocated to the run. It is charged once when the container initializes. Memory allocation determines the size of the container, but it is billed as a flat per-event fee upon starting.
  2. "result" (apify-default-dataset-item): This is billed at $0.002 per event. A result event is defined as a single ad record successfully written to the default dataset.

This per-result pricing is subject to Apify volume tiers. These are platform-wide volume tiers, not subscription plans. The price per "result" event scales down as your overall platform usage increases across the following tiers:

  • FREE: $0.002 per event
  • BRONZE: $0.00167 per event
  • SILVER: $0.00133 per event
  • GOLD: $0.001 per event
  • PLATINUM: $0.001 per event
  • DIAMOND: $0.001 per event

To calculate the cost of a run, use this formula:

run_cost = (allocated_memory_gb * 0.01) + (number_of_results * price_per_result_tier)

Because the run duration does not enter the equation, a run that takes 4 minutes to scrape 100 ads costs exactly the same as a run that takes 15 minutes to scrape those same 100 ads due to proxy rotation delays. Checked against the Actor's input schema and Apify docs on 2026-09-19.

Which Input Fields Directly Multiply the Number of Billable Events?

The number of results returned is the dominant multiplier of your total bill. To control this, you must understand how the input schema processes search arrays and limit fields.

The primary multiplier is the relationship between your search arrays and the resultsPerSearch integer. The actor provides four distinct ways to target ads: searchTerms, pageIds, adIds, and bylines.

When you populate searchTerms, pageIds, or bylines, the scraper executes each item in the array as an independent search query. The resultsPerSearch parameter (which defaults to 50) acts as a hard cap per item, not per run.

If you pass 10 keywords in searchTerms and set resultsPerSearch to 100, the scraper is authorized to return up to 1,000 results. If all searches are fully populated, this run will emit 1,000 "result" events.

{
    "searchTerms": ["nike", "adidas", "puma", "reebok", "under armour"],
    "country": "US",
    "resultsPerSearch": 100,
    "adActiveStatus": "active"
}
Enter fullscreen mode Exit fullscreen mode

In this configuration, five search terms with a limit of 100 results each can yield up to 500 results, which equates to 500 billable "result" events. If you leave resultsPerSearch at its default of 50, the potential volume is halved.

Why Do Unnamed Storages Expire on the Free Plan?

When running scrapers via the API, data persists in datasets. However, the retention policy of these datasets depends on your plan and how you initialize the run.

On the free plan, Apify enforces strict storage cleanup policies. Unnamed storages (datasets and request queues created automatically during an actor run) expire quickly. Specifically, only the 10 most recent runs are retained, and their associated data is deleted after 4 months.

If your data pipeline relies on querying old run datasets asynchronously, or if you run high-frequency jobs on the free tier, your older data will disappear. To prevent this, you must explicitly name your datasets when creating them via the API, or copy the data out to an external database immediately upon run completion. Named storages are permanently exempt from these automatic deletion policies.

How to Call the Scraper and Retrieve Dataset Results in Python?

To run this scraper programmatically, you can use the official Apify API client. Below is a complete Python script that configures the scraper to fetch ads for specific advertiser Page IDs and reads the resulting dataset.

import os
from apify_client import ApifyClient

# Initialize the client with your Apify API token
client = ApifyClient(os.getenv("APIFY_TOKEN"))

# Configure the actor input according to its schema
run_input = {
    "pageIds": ["15087023444"],
    "country": "US",
    "adActiveStatus": "active",
    "resultsPerSearch": 20
}

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

# Fetch results from the default dataset of the run
dataset_items = client.dataset(run["defaultDatasetId"]).list_items().items

for item in dataset_items:
    print(f"Ad ID: {item.get('ad_id')} | Page: {item.get('page_name')}")
    print(f"Text: {item.get('ad_text')[:100]}...")
    print(f"Link: {item.get('link_url')}\n")
Enter fullscreen mode Exit fullscreen mode

This script initiates a run, waits synchronously for completion, and prints the target fields. Because it targets a specific advertiser Page ID, it bypasses broader keyword matching.

When Does the 300 Second Synchronous Cap Force an Asynchronous Pattern?

If you initiate a run using a synchronous API call, the platform imposes a strict timeout. This is a common pitfall when extracting large volumes of ad data.

The synchronous run endpoint hard-caps at 300 seconds. If the actor takes longer than 5 minutes to complete its scraping tasks, the synchronous connection is severed, and the API returns an HTTP 408 (Request Timeout) error. The run itself is not aborted on the platform; it continues to run in the background, consuming resources and generating billable "result" events, but your calling application has lost the connection.

For searches with high resultsPerSearch limits or long arrays of searchTerms, you must use an asynchronous execution pattern. You initiate the run, receive a run ID, and then poll the run status or use a webhook to trigger downstream ingestion once the state changes to SUCCEEDED.

import os
import time
from apify_client import ApifyClient

client = ApifyClient(os.getenv("APIFY_TOKEN"))

run_input = {
    "searchTerms": ["software", "saas", "cloud"],
    "resultsPerSearch": 150,
    "country": "US"
}

# Start the actor asynchronously without waiting (no 300s timeout risk)
run = client.actor("crawlerbros/facebook-ads-library-scraper").start(global_input=run_input)
run_id = run["id"]
print(f"Run started with ID: {run_id}")

# Poll the run status until completion
while True:
    status_info = client.run(run_id).get()
    status = status_info.get("status")
    print(f"Current status: {status}")

    if status in ["SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"]:
        break
    time.sleep(15)

if status == "SUCCEEDED":
    results = client.dataset(status_info["defaultDatasetId"]).list_items().items
    print(f"Successfully scraped {len(results)} ads.")
Enter fullscreen mode Exit fullscreen mode

How to Set a Hard Cost Cap on Facebook Ads Library Scraper Runs?

Because pay-per-event billing is highly sensitive to the number of search terms and returned items, a runaway run can quickly consume your account balance. You can prevent this by configuring safety limits.

The platform provides a query parameter named maxTotalChargeUsd on its run endpoints. This parameter is exposed to the actor run environment as ACTOR_MAX_TOTAL_CHARGE_USD. When the accrued cost of the run reaches this threshold, the platform terminates the run.

It is important to note that this is not an instant kill. The container will stop processing new requests, but it may briefly continue to consume resources and finalize write operations as it winds down. Below is how you pass this parameter using the JavaScript client.

const { ApifyClient } = require('apify-client');

const client = new ApifyClient({
    token: process.env.APIFY_TOKEN,
});

(async () => {
    // Start the run with a strict spending cap of 50 cents
    const run = await client.actor('crawlerbros/facebook-ads-library-scraper').start({
        searchTerms: ['fitness', 'workout', 'gym'],
        resultsPerSearch: 200,
        country: 'GB'
    }, {
        // Query options pass system-level limits like maxTotalChargeUsd
        maxTotalChargeUsd: 0.50
    });

    console.log(`Run started: ${run.id}. A spending cap of $0.50 is enforced.`);
})();
Enter fullscreen mode Exit fullscreen mode

How Do Single-Country Constraints Limit Multi-Country Searches?

A common requirement for global brands is monitoring ad distribution across several regional markets simultaneously. The input schema lists both a country string and a countries array, which can lead to confusion.

The underlying Facebook Ad Library API does not support multi-country filtering server-side. Consequently, the scraper cannot fetch combined results for multiple countries in a single query. The actor's input schema notes that if you supply a list of countries in the countries array, only the very first entry is read and applied to the search. The remaining values are ignored.

If your pipeline needs to search for the keyword "retail" across the United States, the United Kingdom, and Germany, you cannot pass them all in a single run and expect them to merge. Doing so will only scrape the first country code. Instead, you must structure your integration to trigger separate, parallel runs for each target country.

import os
from apify_client import ApifyClient

client = ApifyClient(os.getenv("APIFY_TOKEN"))

target_countries = ["US", "GB", "DE"]
search_keyword = "retail"

# Fire parallel runs for each country to work around API limitations
for country in target_countries:
    run_input = {
        "searchTerms": [search_keyword],
        "country": country,
        "resultsPerSearch": 50
    }
    run = client.actor("crawlerbros/facebook-ads-library-scraper").start(global_input=run_input)
    print(f"Dispatched run {run['id']} targeting country code: {country}")
Enter fullscreen mode Exit fullscreen mode

What Are the Real Input Schema Limits and Validation Gaps?

When building automated integrations, relying solely on Console UI validations can break your production code. The Apify platform handles input defaults and schemas differently depending on how the run is triggered.

The input schema defines a prefill property for several fields, which is used to populate the fields when you interact with the Actor via the Console UI. However, this prefill is completely ignored when you trigger runs via API calls. If you trigger an actor run via a POST request or a client library without explicitly passing a required field, the platform will only fall back to the schema's default property. If a field has a prefill but no default, your API call will execute with an empty value.

To avoid validation issues, always structure your payload with explicit values for every parameter your pipeline depends on. Below is a robust JSON payload representing the fully qualified input options based on the actor's schema.

{
    "searchTerms": ["sneakers"],
    "pageIds": [],
    "adIds": [],
    "bylines": [],
    "country": "US",
    "countries": ["US"],
    "contentLanguages": ["en"],
    "adActiveStatus": "active",
    "adType": "all",
    "mediaType": "all",
    "startDate": "2026-01-01",
    "endDate": "2026-09-19",
    "sortBy": "relevance",
    "resultsPerSearch": 50,
    "cookies": ""
}
Enter fullscreen mode Exit fullscreen mode

What Are the Technical Limitations and Caveats of the Scraper?

While this scraper provides structured access to the Facebook Ads Library, there are architectural limits and platform boundaries you must account for when building downstream systems:

  • No Multi-Country Server-Side Filter: As established, only the first country in any passed array is processed.
  • Best-Effort Date Filtering: The input parameters startDate and endDate are documented as best-effort. The Facebook Ad Library API does not always respect date bounds on server-side queries, meaning your output may still contain records outside your requested range. You must handle date filtering inside your ingestion code.
  • Variable Resident Proxy Lifespans: The scraper automatically routes traffic through residential proxies because Facebook blocks datacenter IPs. Residential proxy sessions on Apify typically persist for around 30 minutes. If your run takes longer than this window, the IP session will rotate, which can occasionally trigger Facebook security gates and cause a sudden dip in success rates mid-run.
  • Gated Political and EU Spend Data: Fields like spend, impressions, and reach_estimate are only populated for political, social issue, or EU-regulated ads. For standard commercial ads, these fields are omitted entirely.
  • Empty Fields Are Omitted: The output schema is designed to omit keys with null values. Your downstream database parser must be flexible enough to handle JSON objects that lack key fields, rather than expecting a rigid schema where missing values are represented as null.

To handle the missing field behavior safely in Python, avoid direct key access (item["spend"]) and use safe getters with default fallbacks:

# Safe extraction from scraped ad output item
ad_id = item.get("ad_id")
page_name = item.get("page_name")

# Political metrics may not exist on commercial ads
spend_range = item.get("spend", "N/A")
impressions = item.get("impressions", "N/A")

# Always check if optional objects exist
regulation_data = item.get("regional_regulation_data", {})
is_limited = regulation_data.get("limited_delivery_status", False)
Enter fullscreen mode Exit fullscreen mode

The Actor's README is the source of truth for its inputs, outputs and limits. Need a hand wiring this into your stack? Email info@crawlerbros.com

Top comments (0)