adlibrary.com Logoadlibrary.com
Share
Guides & Tutorials,  Competitive Research

GitHub Actions Competitor Ad Monitoring: Scheduled Scans, No Server

Build scheduled competitor ad scans with GitHub Actions: cron YAML, API key secrets, diffable JSON history, auto-filed issues, and Slack alerts. No server.

GitHub Actions competitor ad monitoring workflow with cron scheduler and ad comparison dashboard

GitHub Actions competitor ad monitoring is the cheapest always-on competitive intelligence system you can own. No server. No SaaS subscription on top of your data subscription. A free GitHub repo, one cron-triggered workflow, and a scan script that commits JSON results into version control, where every change to a competitor's ad portfolio becomes a diff you can read six months later.

TL;DR: Put a scheduled workflow in a free GitHub repository, store your AdLibrary API key as a repository secret, and run a small script on a cron trigger. The script pulls every competitor's current ads, diffs them against the last committed JSON, commits the new state, opens a GitHub issue for each newly launched ad, and pings Slack. Total infrastructure cost: zero. Total data cost: about one credit per competitor per run.

One catch deserves naming up front. GitHub's cron is not a precision instrument, and a naive schedule can quietly burn your monthly credit budget. This guide covers the full build, from workflow YAML to schedule math, so the monitor runs for months without you touching it.

Why GitHub Actions Competitor Ad Monitoring Beats Renting a Server

Most teams that want automated competitor ad monitoring assume they need infrastructure. A VPS, a Lambda function, a scheduler, a database for history. Each piece is small, and together they become a system someone has to maintain.

A GitHub repository replaces all of it. That is the whole GitHub Actions competitor ad monitoring pitch.

The runner is the server. Every scheduled run gets a fresh Ubuntu machine with Node, Python, jq, curl, and the gh CLI preinstalled. Public repositories get Actions minutes for free, and private repositories on the free plan include 2,000 minutes per month, per GitHub's billing docs. A 40-second scan, run daily, uses about 20 minutes a month.

Git is the database. Committing each scan's JSON into the repo gives you something a cron-on-a-server setup never has by default: diffable history. git log -p data/gymshark.json shows you exactly which ads appeared and disappeared, on which date, with the full creative metadata at each point in time. When a client asks "when did they start running that UGC angle?", the answer is a git blame away.

Issues are the alert queue. Each new competitor ad becomes a GitHub issue with the ad copy in the body, triaged the same way your team triages bugs. That beats a Slack message that scrolls away by lunch.

Compared to n8n recipes, Zapier flows, or Make.com scenarios, the trade is simple. Those tools click together faster and suit non-developers better. The GitHub approach wins on cost, on history, and on ownership, since it is code in a repo you control rather than a workflow locked inside someone's visual editor.

Step 0: Build the Watchlist Before You Touch YAML

Skipping straight to YAML is the classic mistake. A scheduled scan is only as good as the list of advertisers it watches, so resolve that list first, while it costs nothing.

The AdLibrary API ships a free brand-resolution endpoint exactly for this. One call per brand name, zero credits:

bash
curl -G "https://adlibrary.com/api/advertisers/search" \
  -H "Authorization: Bearer adl_your_api_key" \
  --data-urlencode "q=gymshark" \
  --data-urlencode "country=US"

The response contains a best_match plus candidate lists for Meta, Google, and LinkedIn, each with the platform ID you need. Confidence is scored by cross-platform agreement on the normalized brand name.

Save each confirmed competitor once. Saving is free too:

bash
curl -X POST "https://adlibrary.com/api/advertisers" \
  -H "Authorization: Bearer adl_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Gymshark",
    "domain": "gymshark.com",
    "meta_page_ids": ["123456789"],
    "google_advertiser_ids": ["AR01234567890123456789"],
    "linkedin_company_ids": ["9876543"]
  }'

One brand is rarely one account. Nike alone runs Nike, Nike Football, and Nike Run Club as separate Meta pages, and a saved advertiser holds all of them behind a single ID. Your scan script will iterate over these saved advertisers, so curating this list is the highest-value ten minutes in the project. The competitor ads research playbook rule applies: five to ten direct competitors beats fifty loosely related brands, for signal and for credits.

A note on data source, since it determines what this monitor can see. Meta's free Ad Library API is the original transparency tool, covering political and social-issue ads worldwide plus all ad types in the EU and UK, on Meta platforms only, after an app review. The AdLibrary API is the paid power-user upgrade on top of that idea: every commercial ad, eleven platforms in one key, plus engagement, impression bands, estimated spend, and a heat score per ad. Meta's free API is fine for one platform. The moment you want TikTok, YouTube, or LinkedIn in the same nightly diff, you need something built for multi-platform coverage.

Repository Layout and Secrets

Create a fresh private repository. The whole monitor is four files:

competitor-ad-scan/
├── .github/
│   └── workflows/
│       └── scan.yml        # the cron workflow
├── scripts/
│   └── scan.mjs            # pull, diff, write JSON
├── data/                   # committed scan results (the history)
│   └── .gitkeep
└── README.md

Two secrets power it. Add them under Settings → Secrets and variables → Actions, following GitHub's secrets guide:

  • ADLIBRARY_API_KEY: your AdLibrary key. It starts with adl_, you create it in the dashboard under API access, and it is shown exactly once at creation. Keys require an active Business subscription, and you can hold up to ten per account, which is handy for separating a monitoring key from an ad-hoc research key.
  • SLACK_WEBHOOK_URL: an incoming webhook URL for the channel that should receive alerts. Optional until the Slack step near the end.

Secrets are encrypted at rest, masked in logs, and injected only into steps that declare them. Never commit or echo the key. The GITHUB_TOKEN used for commits and issues needs no setup, because every workflow run mints a scoped, short-lived token automatically.

The Workflow YAML, Line by Line

Here is the complete workflow. Save it as .github/workflows/scan.yml:

yaml
name: competitor-ad-scan

on:
  schedule:
    - cron: "23 6 * * *"   # 06:23 UTC daily — avoid :00, see below
  workflow_dispatch: {}     # manual "Run workflow" button

permissions:
  contents: write           # commit scan results
  issues: write             # open issues for new ads

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Run scan
        env:
          ADLIBRARY_API_KEY: ${{ secrets.ADLIBRARY_API_KEY }}
        run: node scripts/scan.mjs

      - name: Commit results
        run: |
          git config user.name "ad-scan-bot"
          git config user.email "[email protected]"
          git add data/
          git diff --cached --quiet || git commit -m "scan: $(date -u +%Y-%m-%d)"
          git push

      - name: Open issues for new ads
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          count=$(jq length data/new-ads.json)
          [ "$count" -eq 0 ] && exit 0
          jq -c '.[]' data/new-ads.json | while read -r ad; do
            title=$(echo "$ad" | jq -r '"New ad: \(._brand) — \(.ad_key)"')
            body=$(echo "$ad" | jq -r '"**Platform:** \(.platform)\n**First seen:** \(.first_seen)\n\n\(.message // "(no copy)")"')
            gh issue create --title "$title" --body "$body" --label competitor-ad
          done

      - name: Slack notify
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        run: |
          count=$(jq length data/new-ads.json)
          [ "$count" -eq 0 ] && exit 0
          curl -sS -X POST "$SLACK_WEBHOOK_URL" \
            -H "Content-Type: application/json" \
            -d "$(jq -n --arg text "🔎 $count new competitor ad(s) detected → $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/issues" '{text: $text}')"

Four decisions in that file deserve explanation.

The cron minute is 23, not 0. GitHub runs scheduled workflows from a shared queue, and the top of the hour is the most congested slot on the platform. The schedule event docs state plainly that runs can be delayed during periods of high load, and high load includes the start of every hour. An odd minute reduces queue time. All cron times are UTC, so 06:23 UTC is morning in Europe and overnight in the US.

workflow_dispatch is your test button. You should never wait for a cron tick to test changes. The manual trigger gives you a "Run workflow" button in the Actions tab, and your first three or four runs will all start there.

permissions is scoped, not default. The workflow asks for exactly contents: write and issues: write. If someone later compromises a dependency in the scan script, the blast radius is one repository's contents and issues, not your whole account.

The commit step is diff-guarded. git diff --cached --quiet || git commit only commits when scan output actually changed, so quiet days produce no noise in history. A side effect worth knowing: commits made with the default GITHUB_TOKEN do not trigger other workflows, which prevents the scan from accidentally recursing into itself.

GitHub Actions competitor ad scan script with JSON diff output for automated ad monitoring

The Scan Script: Pull, Diff, Commit

The script does the real work: pull every saved advertiser's current ads, compare against the last committed state, and write both the merged history and the delta. Node 20's built-in fetch means zero dependencies, so there is no package.json and no npm install step at all.

js
// scripts/scan.mjs — zero-dependency competitor ad scanner
import { readFile, writeFile, mkdir } from "node:fs/promises";

const BASE = "https://adlibrary.com";
const headers = {
  Authorization: `Bearer ${process.env.ADLIBRARY_API_KEY}`,
  "Content-Type": "application/json",
};

// 1. List saved advertisers (free call)
const res = await fetch(`${BASE}/api/advertisers`, { headers });
if (!res.ok) throw new Error(`advertisers list failed: ${res.status}`);
const { advertisers } = await res.json();

await mkdir("data", { recursive: true });
const newAds = [];

for (const adv of advertisers) {
  // 2. Curate: all platforms, one call — 1 credit per 30-min session
  const r = await fetch(`${BASE}/api/advertisers/${adv.id}/curate`, {
    method: "POST",
    headers,
    body: "{}",
  });
  if (!r.ok) {
    console.error(`${adv.name}: HTTP ${r.status}`, await r.text());
    continue; // one failing brand must not kill the whole scan
  }
  const data = await r.json();
  const ads = [
    ...(data.meta?.ads ?? []),
    ...(data.google?.ads ?? []),
    ...(data.linkedin?.ads ?? []),
  ];

  // 3. Diff against last committed state, keyed on ad_key
  const file = `data/${adv.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.json`;
  let history = [];
  try { history = JSON.parse(await readFile(file, "utf8")); } catch {}
  const known = new Set(history.map((a) => a.ad_key));

  const fresh = ads.filter((a) => !known.has(a.ad_key));
  newAds.push(...fresh.map((a) => ({ ...a, _brand: adv.name })));

  // 4. Append-only history: new ads on top, old ones preserved
  await writeFile(file, JSON.stringify([...fresh, ...history], null, 2));

  if (data.credits) {
    console.log(`${adv.name}: ${fresh.length} new — credits remaining: ${data.credits.remaining}`);
  }
}

await writeFile("data/new-ads.json", JSON.stringify(newAds, null, 2));
console.log(`total new ads this run: ${newAds.length}`);

Three design choices matter here.

The curate endpoint is the right primitive for a watchlist. One POST fans out to every platform ID stored on the saved advertiser in parallel and returns the merged, deduplicated ads. The first call in a 30-minute session deducts one credit, and follow-up pagination calls inside that window are free when you pass the returned cursors back. A nightly scan is always a fresh session, so the budgeting rule of thumb is one credit per advertiser per run. Google results require a Pro or Business plan, which API users have by definition.

ad_key is the stable identity. Every ad carries a provider-prefixed key like meta_123456, and the API dedupes on it, so your diff should too. Anything fancier, like hashing creative URLs, breaks the moment a CDN path rotates.

History is append-only. The committed JSON never deletes an ad, because an ad disappearing is itself a signal. Pair the file diff with the ad spend estimator and you can reconstruct roughly when a competitor scaled a creative up or killed it. Two fields deserve careful reading in that file: impression values are bands rather than exact counts, and any spend figure is an estimate, never a number the advertiser reported. The full response shape is documented in the API documentation and implementation guide.

One thing this script deliberately avoids: scraping. Pulling the same data by scraping Meta's Ad Library violates Meta's terms, breaks on every markup change, and produces no engagement signals. An API contract is the part of this system you should pay for, precisely so the other parts can be free.

Open a GitHub Issue for Every New Ad

The issue step in the YAML above already works, and it is worth understanding why issues beat any other notification target for ad spy work.

An issue is durable and assignable. When your competitor launches a new video angle on a Tuesday night, Wednesday morning someone gets assigned New ad: Gymshark — meta_84629117, reads the hook copy in the issue body, and decides whether it goes into the swipe file or into a counter-brief. Closed issues become a searchable log of every launch you have ever triaged.

The gh CLI ships on every GitHub-hosted runner, so creation needs no installs. The workflow loop reads data/new-ads.json, builds a title from brand and ad_key, and drops platform, first-seen date, and ad copy into the body. Add --assignee to route everything to your creative strategist, or use per-brand labels so filtering by competitor takes one click.

Volume control matters more than people expect. The first run against a freshly saved advertiser reports that brand's entire recent portfolio as "new," which could be sixty issues at once. Two clean fixes: run the script once locally and commit the baseline JSON before enabling the schedule, or add a guard that posts one summary issue whenever a single brand returns more than fifteen fresh ads.

Add a Slack Notification Step

GitHub issues are the system of record. Slack is how anyone finds out the system of record changed.

Create an incoming webhook per Slack's webhook documentation: create a Slack app, enable incoming webhooks, pick the channel, and copy the generated URL into the SLACK_WEBHOOK_URL secret. The workflow step then posts a one-line summary with a link to the issues list whenever a run found anything new.

Resist the urge to post every ad into Slack. A channel that receives nine messages at 6 a.m. trains everyone to mute it within a week. One message per run keeps the channel useful for months. The detail lives in the issues and in git history, where it belongs.

Schedule Strategy: GitHub Actions Competitor Ad Monitoring on a Credit Budget

This is where most setups go wrong, so do the arithmetic before you pick a cron expression.

The cost model is simple. Each advertiser curated in a run costs one credit, since each scheduled run starts a fresh 30-minute session. Keyword sweeps through /api/search cost one credit per page. Enrichment of a single ad into a full creative teardown costs one credit for text and standard-length video. Brand resolution and the balance check are free.

Now map schedules to monthly spend for a watchlist of eight competitors:

ScheduleCronCredits/month (8 brands)
Weekly (Mon morning)23 6 * * 1~35
Daily23 6 * * *~240
Twice daily23 6,18 * * *~480
Hourly23 * * * *~5,760

The Business plan at €329/mo includes 1,000+ credits and is the tier that carries API access, so daily scanning of eight brands consumes about a quarter of the allowance and leaves the rest for enrichment and ad-hoc research. Hourly scanning of a big watchlist blows past any sane budget, and it buys you almost nothing, because ad platforms surface new creatives on a daily cadence anyway. Google's Ads Transparency Center, for comparison, also updates on a roughly daily rhythm rather than in real time.

Three refinements stretch the budget further. Tier your watchlist, scanning the two competitors who actually move your CAC daily and the long tail weekly with a second cron entry. Add a keyword sweep only where it earns its credit, for instance one nightly /api/search with sortField: "-first_seen" on your core category term to catch new entrants your brand watchlist misses. And reserve enrichment for ads that survive a threshold, like a fresh ad still running after seven days, rather than tearing down every launch. The ad budget planner mindset applies to credits exactly as it does to spend: fixed allowance, allocated by expected information value.

Worth restating from the monitoring guide: frequency is not the goal, consistency is. A daily diff you keep for a year beats an hourly feed everyone stopped reading in March.

Where GitHub Actions Cron Falls Short (and the Workarounds)

GitHub Actions competitor ad monitoring trades scheduling precision for price, and you should know the exact terms of that trade.

Runs are delayed, sometimes dropped. During high-load windows a scheduled run can start 10 to 30 minutes late or occasionally not at all. For competitive intelligence that is harmless. A skipped run just means the next day's diff contains two days of changes, which the append-only design absorbs by construction.

Five-minute minimum interval. * * * * * is not honored, and the shortest valid schedule is every five minutes. Irrelevant for this use case, but it rules out GitHub cron for anything truly real-time.

Schedules attach to the default branch. The cron definition that runs is whatever scan.yml says on your default branch. Edits on a feature branch do nothing until merged, which surprises people during testing. Use workflow_dispatch on the branch to test instead.

The 60-day inactivity freeze. GitHub automatically disables scheduled workflows in public repositories after 60 days without repository activity. Here the design saves you, since every productive scan commits to the repo and resets the clock. If your watchlist goes months without changes, keep an occasional manual run in your calendar.

No retries out of the box. A 429 from the API mid-run fails that brand and moves on, by design of the script. The AdLibrary API allows 10 requests per minute per key, which an eight-brand loop never approaches, but a fifty-brand watchlist should add a small sleep between curate calls.

If you outgrow these limits, the next step up is not a server either. The same script slots into an AI-agent workflow where Claude Code drives the AdLibrary API directly, or behind a self-built MCP server so your assistant can query the committed history conversationally. The repo you build today becomes the data layer for agentic marketing workflows later, which is a strong argument for starting with git as your storage now. The same data feeds AI agents without any migration.

Where the Ad Data Comes From (and Why Not Meta's Free API Alone)

A fair question before you wire money to anyone: could this whole workflow run on Meta's free Ad Library API instead?

For one narrow slice, yes. If your competitors advertise only on Meta and the EU/UK all-ads view covers your markets, Meta's ads_archive endpoint will serve a scheduled workflow, and credit math disappears. You pay in other currencies instead: app review before your first call, an access token that expires every 60 days, roughly 200 calls per hour, and no commercial-ad coverage outside the EU and UK. The export walkthrough shows how far that gets you in practice.

The paid API earns its fee on three axes. Per-ad depth: creative metadata, engagement counts, impression bands, estimated spend, and a heat score, where Meta's free tier returns spend and impressions for political ads only. Breadth: Facebook, Instagram, TikTok, YouTube, Google, LinkedIn, Twitter, Pinterest and more behind one key, which is the difference between monitoring a competitor's Meta presence and monitoring their actual media mix. And implementation: one adl_ key with no app review and no token babysitting, which for a side-project-sized monitor is the difference between shipping this afternoon and shipping next month. The full comparison lives in the API-access alternative roundup.

Both can coexist. Some teams run Meta's free API for EU creative-compliance checks and the AdLibrary API for cross-platform creative testing intelligence, in the same workflow file, as two steps.

Frequently Asked Questions

How much does GitHub Actions competitor ad monitoring cost to run? The infrastructure is free. Public repos get unlimited Actions minutes and private repos include 2,000 minutes monthly, while a daily scan uses about 20. The data costs credits on the AdLibrary side: one credit per competitor per run, so eight brands scanned daily is roughly 240 credits a month, comfortably inside the Business plan's 1,000+ monthly credits at €329/mo.

How often can a GitHub Actions cron workflow run? The minimum interval is five minutes, and all schedules run in UTC. In practice runs queue behind platform load and can start late, occasionally skipping entirely. Daily or twice-daily is the sweet spot for ad monitoring, since platforms surface new creatives on a daily cadence and an append-only diff absorbs any missed run automatically.

Why did my scheduled workflow stop running after a few weeks? GitHub disables scheduled workflows in public repositories after 60 days without repository activity, and it only runs the schedule defined on the default branch. This setup self-heals in an active repo because every scan that finds changes pushes a commit. If yours froze, check for the disabled banner in the Actions tab and re-enable, then confirm your cron edits were merged to the default branch.

Can I monitor TikTok and YouTube competitor ads with this setup, or only Facebook? The scan script pulls from the AdLibrary API, which covers Facebook, Instagram, TikTok, YouTube, Google, LinkedIn, Twitter, Pinterest and more through one key. Saved advertisers fan out across Meta, Google, and LinkedIn IDs in a single curate call, and keyword searches reach the full platform list. Meta's free Ad Library API, by contrast, covers Meta placements only.

Do I need a server or database to store the scan history? No. The workflow commits each scan's JSON into the repository, so git itself is the database. Every change to a competitor's portfolio is a commit you can diff, blame, and roll back through, and the history travels with the repo. A separate datastore only becomes worth it when multiple systems need concurrent queries against the same data.

From Empty Repo to First Scan in Twenty Minutes

The build order, condensed. Resolve and save your competitors with the free advertiser-search calls. Create the repo, add the two secrets, and commit scan.yml plus scan.mjs. Trigger workflow_dispatch once to write the baseline, commit it, then let the cron take over. From that point on, GitHub Actions competitor ad monitoring runs itself: every morning a fresh runner wakes up, pulls the watchlist, diffs the portfolio, files the issues, and pings the channel, for the price of a few credits and zero servers.

The honest gate is the API key. Scheduled, scripted automation like this lives on the Business plan at €329/mo with 1,000+ monthly credits and full API access, and that allowance covers a daily eight-brand watchlist four times over. If you want to see the data before committing, run a few searches on a lower tier first, then upgrade when you are ready to automate. Your competitors' next launch is going to happen either way. The only question is whether your repo catches it the same morning.

Related Articles

Meta Marketing API integration software: build vs buy decision framework — split diagram showing raw SDK and code on one side versus managed SaaS dashboard on the other
Guides & Tutorials,  Platforms & Tools

Zapier Meta Ads Automation Recipes 2026

8 ready-to-use Zapier Meta ads automation recipes: lead sync, budget alerts, creative notifications, Slack reporting, CRM handoffs, and API escalation paths.