adlibrary.com Logoadlibrary.com
Share
Guides & Tutorials,  Platforms & Tools

How to Build a Meta Ads Cron Job (Node + Python)

Build a production-ready Meta ads cron job in Node.js or Python. Schedule API calls, monitor spend, detect creative fatigue, and trigger Slack alerts automatically.

Instagram ad campaign setup: three placements each with distinct creative layout

How to Build a Meta Ads Cron Job (Node + Python)

Most Meta ad monitoring setups fail the same way: someone checks the dashboard every morning, notices the CPM spiked overnight, and starts debugging 12 hours after the damage was done. A scheduled meta ads cron job fixes that. One small script, running on a $6 VPS, can poll the Meta Ads Insights API every hour, compare spend against your daily budget cap, and fire a Slack alert the moment something drifts out of range — while you sleep.

This guide builds two complete implementations: one in Node.js using node-cron + axios, one in Python using schedule + requests. Both are production-ready. Both cover token management, API polling, threshold logic, alerting, and deployment.

TL;DR: A meta ads cron job polls the Ads Insights API on a schedule, checks spend and fatigue metrics against thresholds, and fires alerts when something is wrong. Node.js (node-cron) and Python (schedule) both work. The critical production detail is token refresh — long-lived tokens expire in 60 days unless you use a System User. Deploy on a persistent process (VPS, Railway, or Fly.io), not a cold-start serverless function.

Prerequisites: App, Token, and Account ID

Before writing code, three things have to be in place.

Meta App setup. Go to developers.facebook.com, create a new app of type "Business," and add the Marketing API product. Under App Permissions, request ads_read. If your meta ads cron job will also pause or edit ads, request ads_management. Associate the app with your Meta Business Portfolio — apps not tied to a business account hit stricter rate limits and per Meta's Marketing API documentation, unverified apps have hard request caps.

Access token. From the Graph API Explorer, generate a user token with ads_read scope. This is a 1-hour token. Exchange it immediately for a long-lived 60-day token:

GET https://graph.facebook.com/oauth/access_token
  ?grant_type=fb_exchange_token
  &client_id={app-id}
  &client_secret={app-secret}
  &fb_exchange_token={short-lived-token}

Store the returned token in .env as META_ACCESS_TOKEN. For production without expiry, create a System User in Business Manager — System User tokens never expire.

Ad account ID. Your account ID is formatted act_XXXXXXXXXX. Find it in the Ads Manager URL. Store it as META_AD_ACCOUNT_ID.

The Insights API: What Your Meta Ads Cron Job Polls

All spend and performance data flows through the Insights edge. The base call for account-level meta ads cron job monitoring looks like this:

GET /act_{ad_account_id}/insights
  ?fields=spend,impressions,clicks,cpm,ctr,frequency
  &date_preset=today
  &access_token={token}

Meta returns a data array with one object per time range. The full field reference is at developers.facebook.com/docs/marketing-api/reference/ads-insights. Key monitoring fields:

FieldWhat it measuresAlert threshold
spendTotal spend in the windowExceeds daily budget × 0.9
frequencyAvg times each person saw your ad> 3.5 on 7d → creative fatigue risk
ctrClick-through rate< 0.5% for cold traffic
cpmCost per 1,000 impressions> 2× your baseline
impressionsTotal impressions servedFlat while spend rises

Rate limits: Meta returns a X-Business-Use-Case-Usage header on every Insights response. The value is a JSON object with a call_count percentage. Stay below 80% — at 100% the API returns 429 errors. One request per ad account per hour is conservative and safe. For a full breakdown of what these signals mean during a campaign, see how to analyze ad performance.

Node.js Implementation

Install dependencies:

bash
npm install node-cron axios dotenv

Create cron.js:

javascript
require('dotenv').config();
const cron = require('node-cron');
const axios = require('axios');

const ACCOUNT_ID = process.env.META_AD_ACCOUNT_ID;
const ACCESS_TOKEN = process.env.META_ACCESS_TOKEN;
const DAILY_BUDGET_USD = parseFloat(process.env.DAILY_BUDGET_USD || '200');
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL;

async function fetchInsights(datePreset = 'today') {
  const url = `https://graph.facebook.com/v20.0/${ACCOUNT_ID}/insights`;
  const { data } = await axios.get(url, {
    params: {
      fields: 'spend,impressions,clicks,cpm,ctr,frequency',
      date_preset: datePreset,
      access_token: ACCESS_TOKEN,
    },
  });
  return data.data[0] || null;
}

async function sendSlackAlert(message) {
  if (!SLACK_WEBHOOK) return;
  await axios.post(SLACK_WEBHOOK, { text: message });
}

async function checkSpendAndFatigue() {
  try {
    const today = await fetchInsights('today');
    const week = await fetchInsights('last_7d');
    if (!today) return console.log('[meta-cron] No data yet for today');

    const spend = parseFloat(today.spend || 0);
    const freq7d = parseFloat(week?.frequency || 0);
    const ctr = parseFloat(today.ctr || 0);

    if (spend >= DAILY_BUDGET_USD * 0.9)
      await sendSlackAlert(`:warning: Meta Ads — spend $${spend.toFixed(2)} is at ${((spend/DAILY_BUDGET_USD)*100).toFixed(0)}% of $${DAILY_BUDGET_USD} budget.`);

    if (freq7d > 3.5)
      await sendSlackAlert(`:repeat: Meta Ads — 7-day frequency ${freq7d.toFixed(2)} exceeds 3.5. Creative refresh needed.`);

    if (ctr > 0 && ctr < 0.5)
      await sendSlackAlert(`:chart_with_downwards_trend: Meta Ads — CTR ${ctr.toFixed(2)}% is below 0.5%. Review hook and creative angle.`);

  } catch (err) {
    console.error('[meta-cron] Error:', err.response?.data || err.message);
  }
}

// Run every hour at minute 0
cron.schedule('0 * * * *', () => {
  console.log('[meta-cron] Hourly check running...');
  checkSpendAndFatigue();
});

console.log('[meta-cron] Meta ads cron job started.');

Run it: node cron.js. The process stays alive, the scheduler fires on the hour, and Slack gets a message when any threshold trips.

A few notes: node-cron uses standard cron syntax — '*/15 * * * *' for 15-minute polling. date_preset=today resets at midnight in your ad account's timezone, not UTC. If the account is US/Pacific, "today" at 01:00 UTC is still yesterday by the API. For the secure Facebook Ads API connection pattern, this setup already follows it: tokens in environment variables, never hardcoded.

Python Implementation

Install dependencies:

bash
pip install schedule requests python-dotenv

Create meta_cron.py:

python
import os, time, requests
from schedule import every, run_pending
from dotenv import load_dotenv

load_dotenv()

ACCOUNT_ID = os.getenv('META_AD_ACCOUNT_ID')
ACCESS_TOKEN = os.getenv('META_ACCESS_TOKEN')
DAILY_BUDGET = float(os.getenv('DAILY_BUDGET_USD', '200'))
SLACK_WEBHOOK = os.getenv('SLACK_WEBHOOK_URL')
GRAPH_URL = f'https://graph.facebook.com/v20.0/{ACCOUNT_ID}/insights'

def fetch_insights(date_preset='today'):
    params = {
        'fields': 'spend,impressions,clicks,cpm,ctr,frequency',
        'date_preset': date_preset,
        'access_token': ACCESS_TOKEN,
    }
    resp = requests.get(GRAPH_URL, params=params, timeout=15)
    resp.raise_for_status()
    data = resp.json().get('data', [])
    return data[0] if data else None

def send_slack_alert(message):
    if SLACK_WEBHOOK:
        requests.post(SLACK_WEBHOOK, json={'text': message}, timeout=5)

def check_metrics():
    try:
        today = fetch_insights('today')
        week = fetch_insights('last_7d')
        if not today:
            return print('[meta-cron] No data yet for today')

        spend = float(today.get('spend', 0))
        freq_7d = float(week.get('frequency', 0) if week else 0)
        ctr = float(today.get('ctr', 0))

        if spend >= DAILY_BUDGET * 0.9:
            send_slack_alert(f':warning: Meta Ads — spend ${spend:.2f} at {spend/DAILY_BUDGET*100:.0f}% of ${DAILY_BUDGET:.0f} budget.')

        if freq_7d > 3.5:
            send_slack_alert(f':repeat: 7-day frequency {freq_7d:.2f} exceeds 3.5. Creative refresh needed.')

        if 0 < ctr < 0.5:
            send_slack_alert(f':chart: CTR {ctr:.2f}% is below 0.5%. Review hook-rate and angle.')

    except requests.HTTPError as exc:
        print(f'[meta-cron] HTTP {exc.response.status_code}: {exc.response.text[:200]}')
    except Exception as exc:
        print(f'[meta-cron] Error: {exc}')

every(1).hours.do(check_metrics)
print('[meta-cron] Meta ads cron job started (Python).')
while True:
    run_pending()
    time.sleep(30)

Run it: python meta_cron.py. The while True + sleep(30) loop is the standard schedule library pattern. The except requests.HTTPError branch logs the full HTTP response body — Meta's errors are descriptive. code: 190, error_subcode: 463 means your access token expired. code: 80000, error_subcode: 2446079 means you hit the hourly rate cap. Both are recoverable when you log them explicitly.

For facebook ads attribution tracking setup, the same token plumbing applies — share a System User token across both integrations.

Token Refresh: The Most Common Silent Failure

A meta ads cron job that runs for two months and then silently stops alerting is worse than no meta ads cron job at all. Token expiry is the most common silent failure point.

Long-lived user tokens last 60 days. Re-exchange them weekly — don't wait for the deadline:

javascript
// Node.js — weekly token refresh (runs every Monday at 09:00)
cron.schedule('0 9 * * MON', async () => {
  const resp = await axios.get('https://graph.facebook.com/oauth/access_token', {
    params: {
      grant_type: 'fb_exchange_token',
      client_id: process.env.META_APP_ID,
      client_secret: process.env.META_APP_SECRET,
      fb_exchange_token: process.env.META_ACCESS_TOKEN,
    },
  });
  console.log('[meta-cron] New token:', resp.data.access_token);
  // Write to .env or your secrets manager
});

The cleaner production solution: create a System User in Business Manager (Settings → System Users), assign Analyst role, and generate a token. System User tokens don't expire and aren't tied to any personal account — they survive employee offboarding. This is what every production meta ads cron job should use. See Meta's System Users documentation for the full setup steps.

Responding to Alerts: The Threshold Playbook

An alert without a response protocol is noise. Here's the minimal playbook for each threshold your meta ads cron job will trigger.

Spend at 90%+ of daily budget. Check the hour. If it's 09:00 and you've burned 90% of the day's budget, something is wrong with your bid strategy or campaign budget optimization settings. Open Ads Manager, find which ad set is consuming the spend. Legitimate performance: raise the budget. Runaway ad set: pause it.

7-day frequency above 3.5. Your audience has seen your ads too many times. Rotate in a new creative, expand the audience, or exclude recent converters. Our creative refresh cadence glossary entry covers the standard rotation intervals. For why frequency spikes before performance drops, see reading the Meta algorithm through competitor patterns.

CTR below 0.5%. The creative is not earning the click. Pull the specific ad with the lowest CTR and compare it against your swipe file. Nine times out of ten the hook is too broad. For a systematic process of finding angles that earn clicks, see competitor ad research strategy.

CPM above 2× baseline. Audience saturation or a platform-wide CPM spike. Check your audience overlap report first. Then check the date — CPM spikes on Meta cluster around Q4, major US holidays, and election cycles. IAB's Digital Advertising Spend reports show the seasonal pattern clearly. Use the Ad Budget Planner to model your expected CPM range against date and audience size.

Multi-Account and Multi-Platform Monitoring

The meta ads cron job implementations above poll a single ad account. Agencies running multiple accounts need a loop:

javascript
const ACCOUNTS = process.env.META_AD_ACCOUNTS.split(',');

cron.schedule('0 * * * *', async () => {
  for (const account of ACCOUNTS) {
    await checkAccountMetrics(account);
    await new Promise(r => setTimeout(r, 1000)); // 1s gap between requests
  }
});

The 1-second gap keeps you inside the per-account rate budget. With 10 accounts polling hourly, you won't approach Meta's 200-score limit.

For teams running ads across multiple platforms — TikTok, YouTube, LinkedIn alongside Meta — the single-platform polling model breaks down fast. Meta's free API is fine for one platform. The moment you add TikTok or LinkedIn data into the same query, you're maintaining separate auth flows, rate-limit budgets, and schema normalization for each. AdLibrary's API is the paid power-user layer built for exactly this: multi-platform ad intelligence (Facebook, Instagram, TikTok, YouTube, LinkedIn, Snapchat, Pinterest, Google Ads) under a single endpoint with a unified schema. For agencies at that scale, the Business tier at €329/mo eliminates two or three separate polling integrations. See pricing for the full spec.

For a concrete example of this kind of cross-platform automated workflow, the Claude Code AdLibrary API workflows post covers it end to end. For the use-case framing of why competitor monitoring belongs in the same pipeline, the automate competitor ad monitoring use case page is the reference.

Where to run the process. The meta ads cron job scheduler needs to run continuously — that eliminates cold-start serverless functions for the polling loop. A $6/month Hetzner CX11 or DigitalOcean Droplet runs the Node or Python process indefinitely (pm2 for Node, supervisor for Python). Railway and Fly.io both support always-on processes; Railway's free tier handles a process this size at no cost. For containerized deployment:

dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
CMD ["node", "cron.js"]

For the build your own AdLibrary MCP server pattern, you'd deploy a near-identical container.

Common errors. Every error your meta ads cron job encounters has a specific meaning:

Error codeMeaningFix
190 / subcode 463Access token expiredRe-exchange or switch to System User
190 / subcode 460Token permissions changedRegenerate token with correct scopes
80000 / subcode 2446079Hourly rate limit hitBack off, reduce polling frequency
100 / subcode 33Invalid ad account IDVerify act_ prefix is included
2635No data for date rangeNormal for new accounts or empty windows
1 / subcode 99Permission errorVerify app has ads_read + Business association

For 190 errors, log error.error_subcode — 463 means expiry, 460 means revocation. The conversions API setup encounters identical token errors; if you've debugged CAPI token issues, this is the same pattern. Nielsen's Digital Ad Ratings methodology provides broader context on how platform data pipelines handle access rate-limiting.

Historical trending and creative-level monitoring. A meta ads cron job that only alerts is reactive. Writing every poll to a database enables proactive trend spotting — "which creatives have been declining for more than 3 days" — rather than waiting for a threshold breach.

Add a lightweight write to PostgreSQL after every poll:

javascript
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function logMetrics(metrics) {
  await pool.query(
    `INSERT INTO ad_snapshots (account_id, recorded_at, spend, ctr, frequency, cpm)
     VALUES ($1, NOW(), $2, $3, $4, $5)`,
    [ACCOUNT_ID, metrics.spend, metrics.ctr, metrics.frequency, metrics.cpm]
  );
}

With a week of hourly snapshots, you can compute 7-day rolling averages, detect learning phase exit timing, and identify the exact hour a creative started declining. That granularity doesn't exist in the standard Ads Manager interface.

At the ad level, add a second endpoint:

python
def fetch_ad_level_insights(date_preset='last_7d'):
    url = f'https://graph.facebook.com/v20.0/{ACCOUNT_ID}/ads'
    params = {
        'fields': 'name,insights{spend,ctr,frequency,cpm}',
        'date_preset': date_preset,
        'access_token': ACCESS_TOKEN,
        'limit': 50,
    }
    resp = requests.get(url, params=params, timeout=15)
    resp.raise_for_status()
    return resp.json().get('data', [])

Loop over results, sort by CTR ascending, and alert on the bottom quartile that's still consuming meaningful spend. For A/B testing workflows, pairing this with a creative-level time-series database shows you exactly when a creative started declining — rather than merely that it's below average now. The facebook ads data analysis challenges and fixes post covers structuring that analysis.

For agencies managing multiple clients, this database becomes the foundation for automated weekly reporting. The meta ads automation for consultants guide covers the reporting layer on top of this data model. The fb ads reporting post covers the client-facing output format.

For performance marketing teams operating at scale, see facebook ad performance tracking platform for the commercial monitoring stack that sits above this cron layer.

AdLibrary image

Monitoring Competitor Ads With the Same Infrastructure

Your own account metrics are one half of the picture. The other half is what competitors are doing — when they launch new creatives, scale budgets, or pull back. The Meta Ad Library has a public API endpoint at developers.facebook.com/docs/graph-api/reference/ads_archive that returns active ads for any Page. You can poll it on the same hourly schedule — as part of your meta ads cron job infrastructure — to track when a competitor launches a new ad:

python
def fetch_competitor_ads(page_id):
    url = 'https://graph.facebook.com/v20.0/ads_archive'
    params = {
        'search_page_ids': page_id,
        'ad_type': 'ALL',
        'ad_reached_countries': '["US"]',
        'fields': 'id,ad_creation_time,ad_creative_bodies,page_name',
        'access_token': ACCESS_TOKEN,
        'limit': 20,
    }
    resp = requests.get(url, params=params, timeout=15)
    resp.raise_for_status()
    return resp.json().get('data', [])

Compare returned ad IDs against your previously stored set. New IDs = new creatives launched since the last poll. Fire a Slack alert with the creative copy. This turns a daily manual scan into a real-time signal without building a separate system.

Meta's free Ad Library API is adequate for single-platform monitoring. The moment you need TikTok creative tracking alongside it, you're maintaining separate auth flows for every platform. AdLibrary's API consolidates that: Facebook, Instagram, TikTok, YouTube, LinkedIn, Snapchat, Pinterest, and Google Ads creative data under a single endpoint. For agencies running competitive intelligence at scale, that's the difference between a 200-line meta ads cron job script and a 600-line one with four auth flows. See claude code for competitor research automation for a real example of what that pipeline looks like.

For the signals that actually matter in competitive monitoring, see competitor ad research strategy and pre-launch competitor scan: 30-minute checklist.

Extending the Meta Ads Cron Job: Automated Pausing

Once your meta ads cron job is monitoring spend and fatigue reliably, automated actions are a small step. When frequency exceeds 4.0, the meta ads cron job can pause the ad set directly:

python
def pause_ad_set(ad_set_id):
    url = f'https://graph.facebook.com/v20.0/{ad_set_id}'
    data = {'status': 'PAUSED', 'access_token': ACCESS_TOKEN}
    resp = requests.post(url, data=data, timeout=10)
    resp.raise_for_status()
    send_slack_alert(f':pause_button: Auto-paused ad set {ad_set_id} — frequency exceeded 4.0.')

This requires ads_management permission. Add a safeguard: only auto-pause if the ad set has been running at least 3 days and has spent at least $50. Ad sets in the learning phase should never be auto-paused on frequency alone — they're still establishing attribution baseline data.

According to HubSpot's State of Marketing research, automated campaign monitoring reduces wasted ad spend by 15–30% for teams that implement threshold-based pausing. That's not a hypothetical — it's the direct consequence of catching runaway ad sets in hours rather than days.

For the facebook ad automation 6 steps framework that includes decision trees beyond simple pausing, that guide covers the full action taxonomy.

Frequently Asked Questions

What permissions does my Meta app need to run a meta ads cron job against the Ads API?

At minimum you need ads_read for reading campaign, ad set, and ad-level insights. If your meta ads cron job also pauses or edits ads, you need ads_management. Both require a Business portfolio association and Meta app review approval. Read-only monitoring jobs are typically approved within a few days.

How do I stop my Meta access token from expiring and breaking the meta ads cron job?

Exchange your short-lived user token for a long-lived token (60 days) via GET /oauth/access_token?grant_type=fb_exchange_token. Schedule a weekly refresh job before the 60-day window closes. The production-grade option is a System User in Business Manager — those tokens do not expire and aren't tied to any personal account.

How often should I poll the Meta Insights API to stay inside rate limits?

One request per ad account per hour is conservative and safe. Meta returns a X-Business-Use-Case-Usage header with a usage score — back off with exponential delay when it exceeds 80. Polling every minute is unnecessary; account-level insights are typically aggregated on a 15–30 minute lag.

Can I run a meta ads cron job as a serverless function on AWS Lambda or Vercel?

You can, but serverless functions have no persistent state — you must store tokens and last-seen values externally (DynamoDB, Supabase, Redis). A long-running Node or Python process on a $6/month VPS is simpler and essentially free for a single-account monitor. Use serverless when you need to fan out across hundreds of accounts in parallel.

What is the difference between Meta's free Ads API and AdLibrary's paid API for a cron job?

Meta's Ads Insights API is free and covers your own campaign performance — it's the right tool for monitoring your own accounts. AdLibrary's API is a paid power-user upgrade adding competitor creative data, multi-platform coverage (TikTok, YouTube, LinkedIn, Snapchat, Pinterest, Google), and enriched ad metadata Meta doesn't return. Use Meta's free API for own-account monitoring; reach for AdLibrary's API when you need competitor signals or cross-platform data in a single query. See pricing for the Business tier that includes API access.

What to Build Next

A working meta ads cron job is the foundation. Once your meta ads cron job scheduler is running, the natural extensions in order of impact:

  1. Add a database layer — write every hourly snapshot to PostgreSQL. Two weeks in, you have trend data no Ads Manager report gives you.
  2. Multi-account loop — same polling logic, array of account IDs. Sequential with a 1-second gap stays inside rate limits for up to 20 accounts.
  3. Competitor ad polling — the Meta Ad Library endpoint for single-platform; AdLibrary's API for cross-platform signals with enriched metadata.
  4. Wire to a dashboard — the meta advertising decision intelligence approach turns this data stream into structured weekly decision inputs.

For teams at the Business tier, the AdLibrary API access documentation covers authentication and endpoint reference. The meta ads MCP AdLibrary workflows post shows how to pipe the same data into an AI agent rather than a scheduled job — the natural evolution once the meta ads cron job is stable.

If you're building this for a client or agency setup where the monitoring needs to scale across accounts and platforms, AdLibrary's Business plan at €329/mo is designed for exactly that workload: more data per ad, eight-platform coverage, no app review friction.

Related Articles

AdLibrary image
Platforms & Tools

Facebook Ad Automation: 6 Steps to Launch

Set up Facebook ad automation in 6 steps: workflow audit, AI creative, campaign templates, bulk variation testing, automated alerts, and a learning loop.