How to diff a competitor's ads week over week with ad library APIs
To see what a competitor changed in their advertising, pull their ads from each public ad library once a week, store the ad IDs, and diff the new list against the previous one: IDs that appear are new ads, IDs that disappear are candidates for stopped ads. The one thing that makes this unreliable is treating "not in this week's list" as "stopped" when you only fetched part of the list — check that every pull was complete before you report anything as stopped.
Below is a working version against the Captapi ad library endpoints, plus the failure modes that make naive diffs lie.
What each library gives you
All four libraries are public, but they are not the same product:
- Meta (Facebook, Instagram, Messenger, Audience Network, Threads) lists a page's ads with first/last shown dates, format, copy, landing URL and the platforms it ran on.
- Google Ads Transparency Center lists an advertiser's creatives with first/last shown timestamps and format.
- LinkedIn Ad Library lists a company's creatives; run dates, impressions and targeting appear only on a subset of ads.
- TikTok's Commercial Content Library only covers ads shown in the EU/EEA, the UK and Switzerland. For a US-only advertiser it will be empty, and that is not evidence of anything.
None of them publish spend or impressions for ordinary commercial ads. Google discloses ranges only for election ads; Meta returns spend: null and impressions: null on a normal brand's ads. Do not let a dashboard imply budget from ad counts.
Step 1: pin each competitor to a stable ID
Brand names are ambiguous ("Nike" and "Nike Strength" are separate Meta pages), so resolve once and store the ID:
- Meta:
/v1/ad-library/facebook/search-companiesreturnscompanies[].pageId. - Google:
/v1/ad-library/google/advertiser-searchreturnsadvertisers[].id(anAR…ID).
Keep those IDs in your snapshot file so later runs skip the lookup and never drift to a different page with a similar name.
Step 2: pull the current ads
import os
import requests
API = "https://api.captapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['CAPTAPI_API_KEY']}"}
def get(path, **params):
r = requests.get(API + path, headers=HEADERS, params=params, timeout=180)
r.raise_for_status()
return r.json()["data"]
def meta_ads(page_id, country="US"):
"""All active ads for one Meta page, following nextCursor."""
ads, cursor = [], None
while True:
params = {"url": page_id, "country": country, "status": "ACTIVE", "limit": 50}
if cursor:
params["cursor"] = cursor
data = get("/v1/ad-library/facebook/company-ads", **params)
ads.extend(data["ads"])
cursor = data["nextCursor"]
if not cursor:
complete = data["retrievableCount"] >= (data["searchResultsCount"] or 0)
return ads, complete
def google_ads(advertiser_id, country="US", max_pages=5):
"""Up to max_pages pages of creatives; complete only if the list ended."""
ads, cursor = [], None
for _ in range(max_pages):
params = {"advertiser": advertiser_id, "country": country}
if cursor:
params["cursor"] = cursor
data = get("/v1/ad-library/google/company-ads", **params)
ads.extend(data["ads"])
cursor = data["nextCursor"]
if not data["hasMore"]:
return ads, True
return ads, False
def snapshot_rows(ads):
return {
ad["id"]: {
"firstShown": ad.get("firstShown"),
"lastShown": ad.get("lastShown"),
"format": ad.get("adFormat"),
"headline": ad.get("headline"),
"landingUrl": ad.get("landingUrl"),
}
for ad in ads
}
LinkedIn and TikTok follow the same pattern with /v1/ad-library/linkedin/search-ads (company=) and /v1/ad-library/tiktok/search (q=, EU/UK/CH country codes only). Every ad row carries an id, which is the only thing the diff needs.
Step 3: diff against last week
import json
from pathlib import Path
SNAPSHOT = Path("ad-snapshot.json")
def diff(previous, current, complete):
prev_ids, cur_ids = set(previous), set(current)
return {
"new": sorted(cur_ids - prev_ids),
# Only trust "stopped" when this week's pull saw the whole list.
"stopped": sorted(prev_ids - cur_ids) if complete else [],
"running": len(cur_ids & prev_ids),
"complete": complete,
}
previous = json.loads(SNAPSHOT.read_text()) if SNAPSHOT.exists() else {}
ads, complete = meta_ads("15087023444")
current = snapshot_rows(ads)
report = diff(previous.get("meta", {}), current, complete)
print(report["new"][:5], len(report["stopped"]), report["complete"])
# Write the snapshot only after the report succeeded.
SNAPSHOT.write_text(json.dumps({**previous, "meta": current}, indent=2))
The same diff in JavaScript:
const API = "https://api.captapi.com";
const headers = { Authorization: `Bearer ${process.env.CAPTAPI_API_KEY}` };
async function get(path, params) {
const res = await fetch(`${API}${path}?${new URLSearchParams(params)}`, { headers });
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return (await res.json()).data;
}
function diff(previous, current, complete) {
const prev = new Set(Object.keys(previous));
const cur = new Set(Object.keys(current));
return {
new: [...cur].filter((id) => !prev.has(id)),
stopped: complete ? [...prev].filter((id) => !cur.has(id)) : [],
complete,
};
}
const data = await get("/v1/ad-library/google/company-ads", {
advertiser: "AR16735076323512287233",
country: "US",
});
const current = Object.fromEntries(data.ads.map((ad) => [ad.id, ad.lastShown]));
console.log(diff({}, current, !data.hasMore));
Keep ad IDs as strings in both languages. Meta and TikTok IDs are 15–16 digits and Google's are prefixed strings; parsing them as numbers will eventually round one and break the diff silently.
The gotcha: a missing ad is not a stopped ad
Three things make an ad vanish from this week's list without the advertiser stopping it:
- You did not get the whole list. Meta reports how many ads the library holds (
searchResultsCount) separately from how many the response could read (retrievableCount); when the second is lower, some live ads were simply not in this pull. Google advertisers with thousands of creatives page throughnextCursor, and you will rarely walk all of it. If last week's pull happened to include an ad this week's missed, a naive diff calls it "stopped". That is why the code only reports stopped ads when the pull was complete —retrievableCountcoveringsearchResultsCountfor Meta,hasMore: falsefor Google. For very large advertisers, skip presence-based "stopped" entirely and watchlastShownon the ads you already know. - Libraries are country-scoped. An ad that moved from US-only to UK-only disappears from a US pull. Diff per country, never across them.
- Libraries lag. A new ad can take a day to appear, so a Monday diff may report it the following week. Use
firstShownto date it rather than the date your job noticed it.
When "stopped" matters, confirm it: an ad that really stopped keeps its ID and gets a past lastShown. Pull it with status=ALL, or fetch it directly with /v1/ad-library/facebook/ad-details, and check isActive and lastShown before you put it in a report.
Store text, not media URLs
Meta's creative image and video URLs are signed and expire — each Meta row carries mediaUrlsExpireAt, usually a few days out. A snapshot that stores those URLs will be full of 404s by the time someone opens last month's report. Store the ad ID, headline, copy and landing URL, and re-fetch media when you need it. The mechanics of those expiring links are covered in why scraped Instagram and TikTok URLs 404.
What it costs
On Captapi each of these list calls is a flat 2 credits (Google advertiser search is 1, Meta page search is 2), and failed calls are not billed. Each nextCursor page is its own call. One competitor across Meta, Google, LinkedIn and TikTok in one country is about 8 credits per weekly run once the IDs are stored, plus 3 for the lookups the first week. Ad details on new ads are extra, so cap how many you enrich per run.
If you would rather hand the whole loop to an agent, the same flow is packaged as an agent skill that prints the credit estimate before it runs. The endpoint reference for the first step is on the Meta Ad Library company ads API page.