
Metaads
- 275 installs
- 50 repo stars
- Updated February 8, 2026
- mfwarren/entrepreneur-claude-skills
Publish, pause, and read performance for Facebook and Instagram campaigns through Meta’s Marketing API without living in Ads Manager.
About
Metaads is an agent skill that acts as a Meta media buyer for Claude Code and similar assistants. It wires conversational intent—“publish ads,” “ROAS,” “pause campaign,” “set up meta ads”—into three documented workflows: API setup, campaign publishing, and performance analysis. Solo and indie builders use it when they already have creative and landing pages but want the agent to create campaigns, upload assets, and pull metrics instead of clicking through Business Manager. The skill explicitly routes setup before publish when tokens or .env are missing, which reduces failed launches on first run. It targets founders running their own paid social for SaaS, ecommerce, or content offers who need repeatable API-backed operations rather than one-off chat advice. It is not a strategy or copywriting replacement; it executes and reports against campaigns you define.
- Intent routing across Setup, PublishCampaign, and performance workflows from natural-language triggers
- First-time guided setup: Business Manager, app, token, and .env when credentials fail
- Campaign publish flow: images, ad sets, and live launch via Marketing API
- Performance questions: ROAS, spend, creative and ad-set winners, pause/resume controls
- Credential recovery path when tokens expire or --check reports errors
Metaads by the numbers
- 275 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #869 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Security screen: CRITICAL risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mfwarren/entrepreneur-claude-skills --skill metaadsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 275 |
|---|---|
| repo stars | ★ 50 |
| Security audit | 1 / 3 scanners passed |
| Last updated | February 8, 2026 |
| Repository | mfwarren/entrepreneur-claude-skills ↗ |
What it does
Publish, pause, and read performance for Facebook and Instagram campaigns through Meta’s Marketing API without living in Ads Manager.
Files
Workflow Routing (SYSTEM PROMPT)
CRITICAL: Route to the correct workflow based on user intent.
When user needs to set up or configure Meta Ads API access: Examples: "set up meta ads", "connect meta ads", "configure facebook ads", "my token expired", "meta ads setup", credential errors from --check -> READ: ~/.claude/skills/MetaAds/workflows/Setup.md -> EXECUTE: Walk user through complete setup (Business Manager, App, token, .env)
When user wants to publish, create, or launch a campaign: Examples: "publish ads", "create campaign", "launch campaign", "upload ad images", "publish meta ads", "push this campaign to Meta", "create ad set" -> READ: ~/.claude/skills/MetaAds/workflows/PublishCampaign.md -> EXECUTE: Campaign publishing workflow -> If credentials fail, route to Setup.md first
When user wants to check performance, metrics, or analyze results: Examples: "check meta ad performance", "ROAS", "which ad is winning", "ad spend", "campaign metrics", "how are my ads doing", "audience breakdown", "creative performance" -> READ: ~/.claude/skills/MetaAds/workflows/AnalyzePerformance.md -> EXECUTE: Performance analysis workflow -> If credentials fail, route to Setup.md first
When user wants to manage campaigns (pause/resume/status): Examples: "pause campaign", "resume campaign", "campaign status", "list campaigns" -> Use uv run ~/.claude/skills/MetaAds/tools/Publish.py directly with --pause, --resume, --status, or --list
---
When to Activate This Skill
Setup & Configuration (Category 0)
- "set up meta ads", "connect meta ads API", "configure facebook ads"
- "meta ads setup", "how do I connect my ad account"
- "my token expired", "token error", "can't access meta ads"
- "refresh my meta token", "renew access token"
- Any credential error from --check
Campaign Publishing (Category 1)
- "publish ads", "create campaign", "launch campaign"
- "push this to Meta", "set up the campaign", "upload ad images"
- "publish meta ads", "create ad set", "build the campaign"
- "publish this campaign config", "go live with the ads"
Performance Analysis (Category 2)
- "check meta ad performance", "how are my ads doing"
- "ROAS", "return on ad spend", "ad spend", "ad metrics"
- "which ad is winning", "best performing ad"
- "campaign metrics", "ad set performance", "creative performance"
- "audience breakdown", "age/gender breakdown"
- "CTR", "CPC", "CPM", "CPA", "cost per acquisition"
Campaign Management (Category 3)
- "pause campaign", "resume campaign", "activate campaign"
- "campaign status", "list campaigns", "show my campaigns"
---
Core Capabilities
Guided Setup:
- Complete walkthrough: Business Manager -> Developer App -> Token -> .env
- Two token paths: System User (never expires) or User Token (60-day, with automated refresh)
- Token exchange automation (agent runs the curl command)
- Pixel setup guidance for conversion tracking
- Error diagnosis with specific fixes for every common failure
Campaign Publishing:
- Upload images from local directories to Meta
- Create full campaign hierarchy (campaign -> ad set -> creative -> ad)
- Advantage+ flexible creative with multiple images/texts/headlines
- All objects created PAUSED for manual review
- JSON config-driven for reproducible campaigns
Performance Analytics:
- Account-level summary with DTC benchmarks (CTR, CPM, CPA, ROAS)
- Campaign, ad set, and ad-level metrics
- Daily trend analysis for campaign deep dives
- Audience breakdowns (age, gender, platform, device)
- Data sync to Analytics/meta-ads/ for cross-channel analysis
- CSV export for spreadsheet analysis
Campaign Management:
- List all campaigns with status
- View campaign hierarchy (campaign -> ad sets -> ads)
- Pause/resume campaigns
---
Scripts
| Script | Purpose |
|---|---|
tools/Publish.py | Campaign creation, image upload, pause/resume |
tools/Analytics.py | Performance metrics, breakdowns, sync, export |
Both scripts use PEP 723 inline dependencies — just run with uv run directly from the skill directory. No install or copy step needed.
Usage
Run scripts directly from the skill directory. They load .env from the current working directory (Path.cwd()), so run them from your project root:
# From your project root (where .env lives):
uv run ~/.claude/skills/MetaAds/tools/Publish.py --check
uv run ~/.claude/skills/MetaAds/tools/Analytics.py --summaryPrerequisites: uv must be installed. If not: curl -LsSf https://astral.sh/uv/install.sh | sh
Environment Variables (`.env`):
| Variable | Required | Description |
|---|---|---|
META_ADS_ACCESS_TOKEN | Yes | System User or long-lived User token |
META_ADS_ACCOUNT_ID | Yes | Ad account ID with act_ prefix |
FACEBOOK_APP_ID | For refresh | App ID for token exchange |
FACEBOOK_APP_SECRET | For refresh | App Secret for token exchange |
---
Campaign Objective Reference
Use these objectives in campaign configs:
| Objective | When to Use | Optimization Goal |
|---|---|---|
OUTCOME_SALES | DTC purchases, Shopify conversions | OFFSITE_CONVERSIONS |
OUTCOME_LEADS | Lead gen, email signups, quiz funnels | LEAD_GENERATION |
OUTCOME_TRAFFIC | Landing page visits, blog traffic | LINK_CLICKS |
OUTCOME_AWARENESS | Brand awareness, video views | REACH or IMPRESSIONS |
OUTCOME_ENGAGEMENT | Post engagement, page likes | POST_ENGAGEMENT |
---
Campaign Config Template
Generic template for any DTC brand:
{
"campaign": {
"name": "campaign-name-month-year",
"objective": "OUTCOME_SALES"
},
"adsets": [
{
"name": "Broad-25-54-US",
"daily_budget": 5000,
"targeting": {
"age_min": 25,
"age_max": 54,
"genders": [0],
"geo_locations": {"countries": ["US"]},
"publisher_platforms": ["facebook", "instagram"],
"facebook_positions": ["feed", "video_feeds", "story", "reels"],
"instagram_positions": ["stream", "story", "reels", "explore"]
},
"optimization_goal": "OFFSITE_CONVERSIONS",
"bid_strategy": "LOWEST_COST_WITHOUT_CAP"
}
],
"creative": {
"images_directory": "./path/to/ad-images/",
"primary_texts": [
"Primary text variant 1 — lead with the hook or pain point",
"Primary text variant 2 — lead with the benefit or social proof",
"Primary text variant 3 — lead with a comparison or price anchor"
],
"headlines": [
"Short Punchy Headline",
"Benefit-Driven Headline",
"Price/Value Headline"
],
"descriptions": [
"Supporting description with key differentiator"
],
"link_url": "https://yoursite.com/landing-page",
"url_parameters": "utm_source=meta&utm_medium=paid-social&utm_campaign=campaign-name&utm_content={{ad.name}}",
"call_to_action_type": "SHOP_NOW"
}
}Config notes:
daily_budgetis in cents (5000 = $50.00/day)genders: 0 = all, 1 = male, 2 = female{{ad.name}}is a Meta dynamic parameter — auto-fills with the ad namecall_to_action_typeoptions:SHOP_NOW,LEARN_MORE,SIGN_UP,GET_OFFER,ORDER_NOW,SUBSCRIBE,BOOK_NOW
---
DTC Benchmark Reference
| Metric | Good | OK | Needs Work | What It Means |
|---|---|---|---|---|
| CTR | >1.5% | 1-1.5% | <1% | % of impressions that click. Low = creative isn't resonating |
| CPM | <$15 | $15-25 | >$25 | Cost per 1000 impressions. High = competitive auction or narrow audience |
| CPC | <$1 | $1-2 | >$2 | Cost per click. High = low CTR driving up costs |
| CPA | <$30 | $30-50 | >$50 | Cost per purchase. The ultimate efficiency metric |
| ROAS | >3x | 2-3x | <2x | Revenue per $1 spent. Below 2x usually means losing money after COGS |
| Frequency | 1-3 | 3-5 | >5 | Avg times each person saw the ad. High = ad fatigue |
| Hook Rate | >25% | 15-25% | <15% | % who watch first 3 sec of video. Low = weak opening |
| Hold Rate | >10% | 5-10% | <5% | % who watch 15+ sec. Low = content doesn't sustain interest |
---
Optimization Playbook
Scaling winners:
- Increase budget by 20-30% every 2-3 days (not all at once — resets learning)
- Duplicate winning ad sets with new audiences
- Create lookalike audiences from purchasers
Fixing underperformers:
- CTR < 1%: Test new hooks/images — the creative isn't stopping thumbs
- CTR > 2% but no purchases: Landing page problem, not ad problem
- CPA too high: Narrow targeting or test lower-funnel audiences
- Frequency > 4: Rotate in fresh creative
- No spend after 48 hours: Switch optimization to Link Clicks temporarily, then back to Conversions
Meta learning phase:
- Each ad set needs ~50 conversions in 7 days to exit learning
- Don't edit during learning (resets the clock)
- If budget is too low for 50 conversions/week, optimize for a higher-funnel event (ATC instead of Purchase)
---
Examples
Example 1: First-time Setup
User: "I want to set up Meta ads for my store"
Skill Response: 1. Routes to Setup.md workflow 2. Checks if .env exists, if Business Manager is set up 3. Walks through Developer App creation 4. Helps generate and exchange token 5. Configures .env 6. Verifies with --check
Example 2: Publish a Campaign
User: "Create a campaign for our new product launch"
Skill Response: 1. Routes to PublishCampaign.md workflow 2. Checks credentials (routes to Setup if missing) 3. Asks about images, copy, targeting, budget 4. Builds config JSON 5. Publishes (all PAUSED) 6. Reports campaign ID and review checklist
Example 3: Check Performance
User: "How are my Meta ads doing?"
Skill Response: 1. Routes to AnalyzePerformance.md workflow 2. Runs --summary for account overview 3. Compares against DTC benchmarks 4. Identifies winners and losers 5. Provides specific optimization recommendations
Example 4: Token Expired
User: "My meta ads token isn't working" / agent sees error code 190
Skill Response: 1. Routes to Setup.md Part 6 (Token Refresh) 2. Checks for FACEBOOK_APP_ID and FACEBOOK_APP_SECRET in .env 3. Tells user to generate new short-lived token in Graph API Explorer 4. Runs the exchange curl command 5. Updates .env with new long-lived token 6. Verifies with --check
Example 5: Manage Campaign
User: "Pause campaign 12345678"
Skill Response: 1. Runs uv run ~/.claude/skills/MetaAds/tools/Publish.py --pause 12345678 2. Confirms campaign is paused
---
Last Updated: 2026-02-07
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests",
# "python-dotenv",
# ]
# ///
"""
Meta Ads Performance Analytics (Marketing API)
Fetches campaign, ad set, ad, and creative performance metrics from Meta Ads.
Saves data to Analytics/meta-ads/ for cross-channel analysis.
Usage:
uv run scripts/meta-ads-analytics.py --setup # Credential setup guide
uv run scripts/meta-ads-analytics.py --summary # Account overview (last 7 days)
uv run scripts/meta-ads-analytics.py --campaigns # All campaigns with metrics
uv run scripts/meta-ads-analytics.py --campaign CAMPAIGN_ID # Deep dive (daily + ad sets + ads)
uv run scripts/meta-ads-analytics.py --adsets # Ad set performance
uv run scripts/meta-ads-analytics.py --ads # Ad-level performance
uv run scripts/meta-ads-analytics.py --creative # Asset-level breakdowns
uv run scripts/meta-ads-analytics.py --audience # Age/gender/placement/device
uv run scripts/meta-ads-analytics.py --period 30 # Custom lookback (default 7)
uv run scripts/meta-ads-analytics.py --sync # Save all data to Analytics/
uv run scripts/meta-ads-analytics.py --export results.csv # Export to CSV
Environment variables (or .env file):
META_ADS_ACCESS_TOKEN - System User token with ads_read permission
META_ADS_ACCOUNT_ID - Ad account ID (format: act_XXXXXXXXX)
"""
import argparse
import csv
import json
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
from dotenv import load_dotenv
# Load environment variables from current working directory
PROJECT_ROOT = Path.cwd()
load_dotenv(PROJECT_ROOT / ".env")
ACCESS_TOKEN = os.getenv("META_ADS_ACCESS_TOKEN", "")
ACCOUNT_ID = os.getenv("META_ADS_ACCOUNT_ID", "")
DATA_DIR = PROJECT_ROOT / "Analytics" / "meta-ads"
API_BASE = "https://graph.facebook.com/v21.0"
# Standard insight fields
INSIGHT_FIELDS = (
"impressions,reach,clicks,ctr,cpc,cpm,cpp,spend,"
"actions,cost_per_action_type,purchase_roas,"
"frequency,unique_clicks,cost_per_unique_click"
)
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def api_request(
endpoint: str,
params: dict | None = None,
max_retries: int = 5,
) -> dict | None:
"""Make authenticated GET request with rate limiting and exponential backoff."""
params = params or {}
params["access_token"] = ACCESS_TOKEN
url = f"{API_BASE}/{endpoint}"
for attempt in range(max_retries):
try:
response = requests.get(url, params=params, timeout=60)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
print(f" Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
continue
if response.status_code != 200:
error = response.json().get("error", {})
error_msg = error.get("message", response.text[:300])
error_code = error.get("code", "")
# Transient errors — retry
if error_code in (1, 2, 4, 17):
wait = 2 ** attempt
print(f" Transient API error (code {error_code}). Retrying in {wait}s...")
time.sleep(wait)
continue
print(f" API Error [{error_code}]: {error_msg}")
return None
return response.json()
except requests.RequestException as e:
if attempt < max_retries - 1:
wait = 2 ** attempt
print(f" Request error: {e}. Retrying in {wait}s...")
time.sleep(wait)
else:
print(f" Request failed after {max_retries} attempts: {e}")
return None
return None
# ---------------------------------------------------------------------------
# Data helpers
# ---------------------------------------------------------------------------
def save_data(filename: str, data: dict) -> None:
"""Save data to JSON file in Analytics/meta-ads/."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
filepath = DATA_DIR / filename
with open(filepath, "w") as f:
json.dump(data, f, indent=2, default=str)
print(f" Saved to {filepath.relative_to(PROJECT_ROOT)}")
def load_data(filename: str) -> dict | None:
"""Load data from JSON file."""
filepath = DATA_DIR / filename
if filepath.exists():
with open(filepath) as f:
return json.load(f)
return None
def save_sync_state(key: str) -> None:
"""Update sync state timestamp."""
state_file = DATA_DIR / "sync-meta.json"
state = {}
if state_file.exists():
with open(state_file) as f:
state = json.load(f)
state[key] = datetime.now(timezone.utc).isoformat()
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(state_file, "w") as f:
json.dump(state, f, indent=2)
def date_range(days: int) -> tuple[str, str]:
"""Return (since, until) date strings for the given lookback period."""
until = datetime.now(timezone.utc).date()
since = until - timedelta(days=days)
return str(since), str(until)
def extract_action_value(actions: list | None, action_type: str) -> float:
"""Extract a specific action value from the actions list."""
if not actions:
return 0
for a in actions:
if a.get("action_type") == action_type:
return float(a.get("value", 0))
return 0
def extract_roas(roas_list: list | None) -> float:
"""Extract purchase ROAS value."""
if not roas_list:
return 0
for r in roas_list:
if r.get("action_type") == "omni_purchase":
return float(r.get("value", 0))
return 0
def extract_cost_per_action(cost_list: list | None, action_type: str) -> float:
"""Extract cost per specific action."""
if not cost_list:
return 0
for c in cost_list:
if c.get("action_type") == action_type:
return float(c.get("value", 0))
return 0
# ---------------------------------------------------------------------------
# Insight fetching
# ---------------------------------------------------------------------------
def get_account_insights(days: int = 7) -> dict | None:
"""Account-level insights for the given period."""
since, until = date_range(days)
print(f"Fetching account insights ({since} to {until})...")
result = api_request(
f"{ACCOUNT_ID}/insights",
params={
"fields": INSIGHT_FIELDS,
"time_range": json.dumps({"since": since, "until": until}),
"level": "account",
},
)
if result and result.get("data"):
return result["data"][0]
return None
def get_campaigns_insights(days: int = 7) -> list:
"""Campaign-level insights."""
since, until = date_range(days)
print(f"Fetching campaign insights ({since} to {until})...")
result = api_request(
f"{ACCOUNT_ID}/insights",
params={
"fields": f"campaign_id,campaign_name,{INSIGHT_FIELDS}",
"time_range": json.dumps({"since": since, "until": until}),
"level": "campaign",
"limit": 100,
},
)
return result.get("data", []) if result else []
def get_adset_insights(days: int = 7) -> list:
"""Ad set-level insights."""
since, until = date_range(days)
print(f"Fetching ad set insights ({since} to {until})...")
result = api_request(
f"{ACCOUNT_ID}/insights",
params={
"fields": f"adset_id,adset_name,campaign_name,{INSIGHT_FIELDS}",
"time_range": json.dumps({"since": since, "until": until}),
"level": "adset",
"limit": 200,
},
)
return result.get("data", []) if result else []
def get_ad_insights(days: int = 7) -> list:
"""Ad-level insights."""
since, until = date_range(days)
print(f"Fetching ad-level insights ({since} to {until})...")
result = api_request(
f"{ACCOUNT_ID}/insights",
params={
"fields": f"ad_id,ad_name,adset_name,campaign_name,{INSIGHT_FIELDS}",
"time_range": json.dumps({"since": since, "until": until}),
"level": "ad",
"limit": 500,
},
)
return result.get("data", []) if result else []
def get_campaign_deep_dive(campaign_id: str, days: int = 7) -> dict:
"""Single campaign with daily time series + breakdowns."""
since, until = date_range(days)
print(f"Fetching deep dive for campaign {campaign_id}...")
# Daily trend
daily = api_request(
f"{campaign_id}/insights",
params={
"fields": INSIGHT_FIELDS,
"time_range": json.dumps({"since": since, "until": until}),
"time_increment": 1,
},
)
# Campaign info
info = api_request(
campaign_id,
params={"fields": "id,name,status,effective_status,objective,daily_budget"},
)
# Ad set breakdown
adsets = api_request(
f"{campaign_id}/insights",
params={
"fields": f"adset_id,adset_name,{INSIGHT_FIELDS}",
"time_range": json.dumps({"since": since, "until": until}),
"level": "adset",
"limit": 50,
},
)
# Ad breakdown
ads = api_request(
f"{campaign_id}/insights",
params={
"fields": f"ad_id,ad_name,{INSIGHT_FIELDS}",
"time_range": json.dumps({"since": since, "until": until}),
"level": "ad",
"limit": 100,
},
)
return {
"campaign": info or {},
"daily": daily.get("data", []) if daily else [],
"adsets": adsets.get("data", []) if adsets else [],
"ads": ads.get("data", []) if ads else [],
}
def get_audience_breakdowns(days: int = 7) -> dict:
"""Age, gender, placement, device breakdowns."""
since, until = date_range(days)
print(f"Fetching audience breakdowns ({since} to {until})...")
breakdowns = {}
for breakdown_name in ["age", "gender", "publisher_platform", "device_platform"]:
result = api_request(
f"{ACCOUNT_ID}/insights",
params={
"fields": INSIGHT_FIELDS,
"time_range": json.dumps({"since": since, "until": until}),
"breakdowns": breakdown_name,
"level": "account",
"limit": 100,
},
)
breakdowns[breakdown_name] = result.get("data", []) if result else []
return breakdowns
# ---------------------------------------------------------------------------
# Display functions
# ---------------------------------------------------------------------------
def print_summary(insights: dict, days: int) -> None:
"""Print account summary."""
print(f"\n{'='*70}")
print(f"META ADS ACCOUNT SUMMARY (last {days} days)")
print(f"{'='*70}")
spend = float(insights.get("spend", 0))
impressions = int(insights.get("impressions", 0))
reach = int(insights.get("reach", 0))
clicks = int(insights.get("clicks", 0))
ctr = float(insights.get("ctr", 0))
cpc = float(insights.get("cpc", 0))
cpm = float(insights.get("cpm", 0))
frequency = float(insights.get("frequency", 0))
purchases = extract_action_value(insights.get("actions"), "omni_purchase")
add_to_cart = extract_action_value(insights.get("actions"), "omni_add_to_cart")
roas = extract_roas(insights.get("purchase_roas"))
cpa = extract_cost_per_action(insights.get("cost_per_action_type"), "omni_purchase")
print(f"\n Spend: ${spend:,.2f}")
print(f" Impressions: {impressions:,}")
print(f" Reach: {reach:,}")
print(f" Frequency: {frequency:.2f}")
print(f" Clicks: {clicks:,}")
print(f" CTR: {ctr:.2f}%")
print(f" CPC: ${cpc:.2f}")
print(f" CPM: ${cpm:.2f}")
print(f"\n Conversions:")
print(f" Purchases: {purchases:.0f}")
print(f" Add to Cart: {add_to_cart:.0f}")
print(f" CPA: ${cpa:.2f}" if cpa else " CPA: N/A")
print(f" ROAS: {roas:.2f}x" if roas else " ROAS: N/A")
# DTC benchmark comparison
print(f"\n DTC Benchmarks:")
ctr_status = "GOOD" if ctr >= 1.0 else "LOW" if ctr < 0.5 else "OK"
cpm_status = "GOOD" if cpm <= 20 else "HIGH" if cpm > 30 else "OK"
cpa_status = "GOOD" if 0 < cpa <= 30 else "HIGH" if cpa > 60 else "OK" if cpa > 0 else "N/A"
roas_status = "GOOD" if roas >= 3.0 else "LOW" if 0 < roas < 2.0 else "OK" if roas > 0 else "N/A"
print(f" CTR {ctr:.2f}% [{ctr_status}] (target: 1-2.5%)")
print(f" CPM ${cpm:.2f} [{cpm_status}] (target: $10-30)")
print(f" CPA ${cpa:.2f} [{cpa_status}] (target: $20-60)" if cpa else f" CPA N/A (target: $20-60)")
print(f" ROAS {roas:.2f}x [{roas_status}] (target: 2-4x)" if roas else f" ROAS N/A (target: 2-4x)")
print(f"\n{'='*70}")
def print_campaign_table(campaigns: list) -> None:
"""Print campaign metrics table."""
print(f"\n{'='*100}")
print("CAMPAIGN PERFORMANCE")
print(f"{'='*100}")
print(f"\n{'Campaign':<30} {'Spend':>10} {'Impr':>10} {'Clicks':>8} {'CTR':>7} {'CPC':>7} {'Purch':>7} {'ROAS':>7}")
print(f"{'-'*30} {'-'*10} {'-'*10} {'-'*8} {'-'*7} {'-'*7} {'-'*7} {'-'*7}")
for c in campaigns:
name = c.get("campaign_name", "")[:29]
spend = float(c.get("spend", 0))
impressions = int(c.get("impressions", 0))
clicks = int(c.get("clicks", 0))
ctr = float(c.get("ctr", 0))
cpc = float(c.get("cpc", 0))
purchases = extract_action_value(c.get("actions"), "omni_purchase")
roas = extract_roas(c.get("purchase_roas"))
roas_str = f"{roas:.1f}x" if roas else "—"
purch_str = f"{purchases:.0f}" if purchases else "—"
print(f"{name:<30} ${spend:>9,.2f} {impressions:>10,} {clicks:>8,} {ctr:>6.2f}% ${cpc:>5.2f} {purch_str:>7} {roas_str:>7}")
print(f"\n{'='*100}")
def print_adset_table(adsets: list) -> None:
"""Print ad set metrics table."""
print(f"\n{'='*100}")
print("AD SET PERFORMANCE")
print(f"{'='*100}")
print(f"\n{'Ad Set':<30} {'Campaign':<20} {'Spend':>10} {'Impr':>9} {'CTR':>7} {'CPC':>7} {'ROAS':>7}")
print(f"{'-'*30} {'-'*20} {'-'*10} {'-'*9} {'-'*7} {'-'*7} {'-'*7}")
for a in adsets:
name = a.get("adset_name", "")[:29]
campaign = a.get("campaign_name", "")[:19]
spend = float(a.get("spend", 0))
impressions = int(a.get("impressions", 0))
ctr = float(a.get("ctr", 0))
cpc = float(a.get("cpc", 0))
roas = extract_roas(a.get("purchase_roas"))
roas_str = f"{roas:.1f}x" if roas else "—"
print(f"{name:<30} {campaign:<20} ${spend:>9,.2f} {impressions:>9,} {ctr:>6.2f}% ${cpc:>5.2f} {roas_str:>7}")
print(f"\n{'='*100}")
def print_ad_table(ads: list) -> None:
"""Print ad-level metrics table."""
print(f"\n{'='*100}")
print("AD PERFORMANCE")
print(f"{'='*100}")
print(f"\n{'Ad Name':<35} {'Spend':>10} {'Impr':>9} {'CTR':>7} {'CPC':>7} {'Purch':>7} {'ROAS':>7}")
print(f"{'-'*35} {'-'*10} {'-'*9} {'-'*7} {'-'*7} {'-'*7} {'-'*7}")
for a in ads:
name = a.get("ad_name", "")[:34]
spend = float(a.get("spend", 0))
impressions = int(a.get("impressions", 0))
ctr = float(a.get("ctr", 0))
cpc = float(a.get("cpc", 0))
purchases = extract_action_value(a.get("actions"), "omni_purchase")
roas = extract_roas(a.get("purchase_roas"))
roas_str = f"{roas:.1f}x" if roas else "—"
purch_str = f"{purchases:.0f}" if purchases else "—"
print(f"{name:<35} ${spend:>9,.2f} {impressions:>9,} {ctr:>6.2f}% ${cpc:>5.2f} {purch_str:>7} {roas_str:>7}")
print(f"\n{'='*100}")
def print_campaign_deep_dive(data: dict, days: int) -> None:
"""Print campaign deep dive with daily trend."""
campaign = data.get("campaign", {})
daily = data.get("daily", [])
adsets = data.get("adsets", [])
ads = data.get("ads", [])
print(f"\n{'='*70}")
print(f"CAMPAIGN DEEP DIVE (last {days} days)")
print(f"{'='*70}")
print(f" Name: {campaign.get('name', 'N/A')}")
print(f" Status: {campaign.get('effective_status', campaign.get('status', 'N/A'))}")
print(f" Objective: {campaign.get('objective', 'N/A')}")
if daily:
print(f"\n Daily Trend:")
print(f" {'Date':<12} {'Spend':>10} {'Impr':>9} {'Clicks':>8} {'CTR':>7} {'Purch':>7}")
print(f" {'-'*12} {'-'*10} {'-'*9} {'-'*8} {'-'*7} {'-'*7}")
for day in daily:
date = day.get("date_start", "")
spend = float(day.get("spend", 0))
impressions = int(day.get("impressions", 0))
clicks = int(day.get("clicks", 0))
ctr = float(day.get("ctr", 0))
purchases = extract_action_value(day.get("actions"), "omni_purchase")
purch_str = f"{purchases:.0f}" if purchases else "—"
print(f" {date:<12} ${spend:>9,.2f} {impressions:>9,} {clicks:>8,} {ctr:>6.2f}% {purch_str:>7}")
if adsets:
print(f"\n Ad Set Breakdown:")
for a in adsets:
name = a.get("adset_name", "")
spend = float(a.get("spend", 0))
ctr = float(a.get("ctr", 0))
roas = extract_roas(a.get("purchase_roas"))
roas_str = f"{roas:.1f}x" if roas else "—"
print(f" {name}: ${spend:,.2f} spend, {ctr:.2f}% CTR, {roas_str} ROAS")
if ads:
print(f"\n Top Ads:")
sorted_ads = sorted(ads, key=lambda x: float(x.get("spend", 0)), reverse=True)
for a in sorted_ads[:10]:
name = a.get("ad_name", "")[:40]
spend = float(a.get("spend", 0))
ctr = float(a.get("ctr", 0))
roas = extract_roas(a.get("purchase_roas"))
roas_str = f"{roas:.1f}x" if roas else "—"
print(f" {name}: ${spend:,.2f} spend, {ctr:.2f}% CTR, {roas_str} ROAS")
print(f"\n{'='*70}")
def print_audience_breakdowns(breakdowns: dict) -> None:
"""Print audience breakdown tables."""
print(f"\n{'='*70}")
print("AUDIENCE BREAKDOWNS")
print(f"{'='*70}")
for breakdown_name, data in breakdowns.items():
if not data:
continue
label = breakdown_name.replace("_", " ").title()
print(f"\n {label}:")
print(f" {'Segment':<25} {'Spend':>10} {'Impr':>10} {'CTR':>7} {'CPC':>7}")
print(f" {'-'*25} {'-'*10} {'-'*10} {'-'*7} {'-'*7}")
for row in data:
segment = row.get(breakdown_name, "unknown")
spend = float(row.get("spend", 0))
impressions = int(row.get("impressions", 0))
ctr = float(row.get("ctr", 0))
cpc = float(row.get("cpc", 0))
print(f" {segment:<25} ${spend:>9,.2f} {impressions:>10,} {ctr:>6.2f}% ${cpc:>5.2f}")
print(f"\n{'='*70}")
# ---------------------------------------------------------------------------
# Export & sync
# ---------------------------------------------------------------------------
def export_to_csv(data: list, filepath: Path) -> None:
"""Export insight rows to CSV."""
if not data:
print(" No data to export")
return
# Flatten actions into columns
rows = []
for row in data:
flat = {}
for k, v in row.items():
if k in ("actions", "cost_per_action_type", "purchase_roas"):
continue
flat[k] = v
flat["purchases"] = extract_action_value(row.get("actions"), "omni_purchase")
flat["add_to_cart"] = extract_action_value(row.get("actions"), "omni_add_to_cart")
flat["roas"] = extract_roas(row.get("purchase_roas"))
flat["cost_per_purchase"] = extract_cost_per_action(
row.get("cost_per_action_type"), "omni_purchase"
)
rows.append(flat)
if not rows:
return
fieldnames = list(rows[0].keys())
with open(filepath, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
print(f" Exported {len(rows)} rows to {filepath}")
def sync_all_data(days: int) -> None:
"""Full sync of all insight data to Analytics/meta-ads/."""
print(f"\nSyncing all Meta Ads data (last {days} days)...\n")
# Account insights
account = get_account_insights(days)
if account:
account["_fetched_at"] = datetime.now(timezone.utc).isoformat()
account["_days"] = days
save_data("insights-account.json", account)
save_sync_state("insights-account")
# Campaign insights
campaigns = get_campaigns_insights(days)
if campaigns:
save_data("insights-campaigns.json", {
"data": campaigns,
"_fetched_at": datetime.now(timezone.utc).isoformat(),
"_days": days,
})
save_sync_state("insights-campaigns")
# Ad set insights
adsets = get_adset_insights(days)
if adsets:
save_data("adsets.json", {
"data": adsets,
"_fetched_at": datetime.now(timezone.utc).isoformat(),
"_days": days,
})
save_sync_state("adsets")
# Campaign list
campaigns_list = api_request(
f"{ACCOUNT_ID}/campaigns",
params={
"fields": "id,name,status,effective_status,objective,daily_budget,created_time",
"limit": 100,
},
)
if campaigns_list and campaigns_list.get("data"):
save_data("campaigns.json", {
"data": campaigns_list["data"],
"_fetched_at": datetime.now(timezone.utc).isoformat(),
})
save_sync_state("campaigns")
# Audience breakdowns
audience = get_audience_breakdowns(days)
if audience:
audience["_fetched_at"] = datetime.now(timezone.utc).isoformat()
audience["_days"] = days
save_data("insights-audience.json", audience)
save_sync_state("insights-audience")
print(f"\nSync complete. Data saved to {DATA_DIR.relative_to(PROJECT_ROOT)}/")
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
def print_setup_guide() -> None:
"""Print credential setup instructions."""
print("""
META ADS ANALYTICS SETUP GUIDE
===============================
To use this script, you need:
1. META_ADS_ACCESS_TOKEN - System User token with ads_read permission
2. META_ADS_ACCOUNT_ID - Ad account ID (format: act_XXXXXXXXX)
If you already set up meta-ads-publish.py, the same credentials work here.
STEP 1: Create a System User in Business Manager
-------------------------------------------------
1. Go to business.facebook.com/settings
2. Navigate to Users > System Users
3. Click "Add" to create a new System User
4. Set role to "Admin"
STEP 2: Generate an Access Token
---------------------------------
1. Click on the System User you created
2. Click "Generate New Token"
3. Select your app
4. Select permissions:
- ads_read (required - read campaign metrics)
- ads_management (optional - if also using publish script)
5. Click "Generate Token"
STEP 3: Get Your Ad Account ID
-------------------------------
1. Go to business.facebook.com/settings
2. Navigate to Accounts > Ad Accounts
3. Copy the account ID (format: act_XXXXXXXXX)
STEP 4: Add to .env
--------------------
META_ADS_ACCESS_TOKEN=your_system_user_token
META_ADS_ACCOUNT_ID=act_XXXXXXXXX
STEP 5: Verify
--------------
uv run scripts/meta-ads-analytics.py --summary
AVAILABLE METRICS
=================
--summary Account overview with DTC benchmarks
--campaigns Campaign-level metrics table
--campaign Single campaign deep dive (daily trend + ad sets)
--adsets Ad set performance
--ads Ad-level performance
--audience Age, gender, platform, device breakdowns
--sync Save all data to Analytics/meta-ads/
--export Export to CSV for spreadsheet analysis
--period N Change lookback period (default: 7 days)
""")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Meta Ads Performance Analytics",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run scripts/meta-ads-analytics.py --summary
uv run scripts/meta-ads-analytics.py --campaigns --period 30
uv run scripts/meta-ads-analytics.py --campaign 12345678
uv run scripts/meta-ads-analytics.py --audience
uv run scripts/meta-ads-analytics.py --sync
uv run scripts/meta-ads-analytics.py --export results.csv
""",
)
parser.add_argument("--setup", action="store_true", help="Show setup guide")
parser.add_argument("--summary", action="store_true", help="Account overview")
parser.add_argument("--campaigns", action="store_true", help="All campaigns with metrics")
parser.add_argument("--campaign", metavar="ID", help="Deep dive into specific campaign")
parser.add_argument("--adsets", action="store_true", help="Ad set performance")
parser.add_argument("--ads", action="store_true", help="Ad-level performance")
parser.add_argument("--creative", action="store_true", help="Creative/asset breakdowns")
parser.add_argument("--audience", action="store_true", help="Audience breakdowns")
parser.add_argument("--period", type=int, default=7, help="Lookback period in days (default: 7)")
parser.add_argument("--sync", action="store_true", help="Save all data to Analytics/meta-ads/")
parser.add_argument("--export", metavar="FILE", help="Export campaign data to CSV")
args = parser.parse_args()
if args.setup:
print_setup_guide()
return
# All other commands need credentials
if not ACCESS_TOKEN or not ACCOUNT_ID:
print("Error: Missing Meta Ads credentials")
print("\nRequired environment variables:")
print(" META_ADS_ACCESS_TOKEN - System User token")
print(" META_ADS_ACCOUNT_ID - Ad account ID (act_XXXXXXXXX)")
print("\nRun with --setup for configuration instructions:")
print(" uv run scripts/meta-ads-analytics.py --setup")
sys.exit(1)
days = args.period
# Default to summary if no action specified
has_action = any([
args.summary, args.campaigns, args.campaign, args.adsets,
args.ads, args.creative, args.audience, args.sync, args.export,
])
if not has_action:
args.summary = True
if args.summary:
insights = get_account_insights(days)
if insights:
print_summary(insights, days)
else:
print(" No data available for this period")
if args.campaigns:
campaigns = get_campaigns_insights(days)
if campaigns:
print_campaign_table(campaigns)
else:
print(" No campaign data available")
if args.campaign:
data = get_campaign_deep_dive(args.campaign, days)
print_campaign_deep_dive(data, days)
if args.adsets:
adsets = get_adset_insights(days)
if adsets:
print_adset_table(adsets)
else:
print(" No ad set data available")
if args.ads or args.creative:
ads = get_ad_insights(days)
if ads:
print_ad_table(ads)
else:
print(" No ad data available")
if args.audience:
breakdowns = get_audience_breakdowns(days)
print_audience_breakdowns(breakdowns)
if args.sync:
sync_all_data(days)
if args.export:
campaigns = get_campaigns_insights(days)
export_path = Path(args.export)
export_to_csv(campaigns, export_path)
if __name__ == "__main__":
main()
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests",
# "python-dotenv",
# ]
# ///
"""
Meta Ads Campaign Publisher (Marketing API)
Publishes ad campaigns to Meta Ads Manager. Creates campaigns, ad sets,
creatives, and ads — all in PAUSED state for manual review before activation.
Usage:
uv run scripts/meta-ads-publish.py --setup # Credential setup guide
uv run scripts/meta-ads-publish.py --check # Verify API access
uv run scripts/meta-ads-publish.py --upload-images DIR # Upload images, get hashes
uv run scripts/meta-ads-publish.py --publish CONFIG.json # Publish full campaign (PAUSED)
uv run scripts/meta-ads-publish.py --list # List campaigns
uv run scripts/meta-ads-publish.py --status CAMPAIGN_ID # Campaign hierarchy status
uv run scripts/meta-ads-publish.py --pause CAMPAIGN_ID # Pause campaign
uv run scripts/meta-ads-publish.py --resume CAMPAIGN_ID # Resume (activate) campaign
Environment variables (or .env file):
META_ADS_ACCESS_TOKEN - System User token with ads_management permission
META_ADS_ACCOUNT_ID - Ad account ID (format: act_XXXXXXXXX)
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import requests
from dotenv import load_dotenv
# Load environment variables from current working directory
PROJECT_ROOT = Path.cwd()
load_dotenv(PROJECT_ROOT / ".env")
ACCESS_TOKEN = os.getenv("META_ADS_ACCESS_TOKEN", "")
ACCOUNT_ID = os.getenv("META_ADS_ACCOUNT_ID", "")
API_BASE = "https://graph.facebook.com/v21.0"
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def api_request(
method: str,
endpoint: str,
params: dict | None = None,
data: dict | None = None,
files: dict | None = None,
max_retries: int = 5,
) -> dict | None:
"""Unified API handler with rate limiting, Meta error parsing, exponential backoff."""
params = params or {}
params["access_token"] = ACCESS_TOKEN
url = f"{API_BASE}/{endpoint}"
for attempt in range(max_retries):
try:
response = requests.request(
method, url, params=params, data=data, files=files, timeout=60
)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
print(f" Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
continue
if response.status_code not in (200, 201):
error = response.json().get("error", {})
error_msg = error.get("message", response.text[:300])
error_code = error.get("code", "")
error_subcode = error.get("error_subcode", "")
# Transient errors — retry
if error_code in (1, 2, 4, 17):
wait = 2 ** attempt
print(f" Transient API error (code {error_code}). Retrying in {wait}s...")
time.sleep(wait)
continue
print(f" API Error [{error_code}]: {error_msg}")
if error_subcode:
print(f" Subcode: {error_subcode}")
return None
return response.json()
except requests.RequestException as e:
if attempt < max_retries - 1:
wait = 2 ** attempt
print(f" Request error: {e}. Retrying in {wait}s...")
time.sleep(wait)
else:
print(f" Request failed after {max_retries} attempts: {e}")
return None
return None
# ---------------------------------------------------------------------------
# Image upload
# ---------------------------------------------------------------------------
def upload_image(image_path: Path) -> str | None:
"""Upload a single image and return its hash."""
print(f" Uploading {image_path.name}...")
with open(image_path, "rb") as f:
result = api_request(
"POST",
f"{ACCOUNT_ID}/adimages",
files={"filename": (image_path.name, f)},
)
if result and "images" in result:
# Response: {"images": {"filename": {"hash": "...", "url": "..."}}}
for _name, img_data in result["images"].items():
image_hash = img_data.get("hash")
print(f" Hash: {image_hash}")
return image_hash
print(f" Failed to upload {image_path.name}")
return None
def upload_images_from_directory(directory: Path) -> dict:
"""Upload all images from a directory. Returns {filename: image_hash}."""
image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
image_files = sorted(
p for p in directory.iterdir()
if p.suffix.lower() in image_extensions and p.is_file()
)
if not image_files:
print(f" No images found in {directory}")
return {}
print(f"Uploading {len(image_files)} images from {directory}...")
hashes = {}
for img_path in image_files:
h = upload_image(img_path)
if h:
hashes[img_path.name] = h
print(f" Uploaded {len(hashes)}/{len(image_files)} images")
return hashes
# ---------------------------------------------------------------------------
# Campaign creation
# ---------------------------------------------------------------------------
def create_campaign(name: str, objective: str = "OUTCOME_SALES") -> str | None:
"""Create a campaign in PAUSED state. Returns campaign ID."""
print(f"Creating campaign: {name}")
result = api_request(
"POST",
f"{ACCOUNT_ID}/campaigns",
data={
"name": name,
"objective": objective,
"status": "PAUSED",
"special_ad_categories": "[]",
},
)
if result and "id" in result:
campaign_id = result["id"]
print(f" Campaign ID: {campaign_id}")
return campaign_id
return None
def create_adset(
campaign_id: str,
name: str,
daily_budget: int,
targeting: dict,
optimization_goal: str = "OFFSITE_CONVERSIONS",
billing_event: str = "IMPRESSIONS",
bid_strategy: str = "LOWEST_COST_WITHOUT_CAP",
pixel_id: str | None = None,
) -> str | None:
"""Create an ad set in PAUSED state. daily_budget is in cents."""
print(f"Creating ad set: {name} (${daily_budget / 100:.2f}/day)")
data = {
"campaign_id": campaign_id,
"name": name,
"daily_budget": str(daily_budget),
"optimization_goal": optimization_goal,
"billing_event": billing_event,
"bid_strategy": bid_strategy,
"targeting": json.dumps(targeting),
"status": "PAUSED",
}
if pixel_id:
data["promoted_object"] = json.dumps({"pixel_id": pixel_id})
result = api_request("POST", f"{ACCOUNT_ID}/adsets", data=data)
if result and "id" in result:
adset_id = result["id"]
print(f" Ad Set ID: {adset_id}")
return adset_id
return None
def create_creative(
name: str,
image_hashes: list[str],
primary_texts: list[str],
headlines: list[str],
descriptions: list[str],
link_url: str,
call_to_action_type: str = "SHOP_NOW",
url_parameters: str = "",
page_id: str | None = None,
) -> str | None:
"""Create an Advantage+ creative with asset_feed_spec for flexible format."""
print(f"Creating creative: {name}")
# Build asset_feed_spec for Advantage+ Creative
images = [{"hash": h} for h in image_hashes]
bodies = [{"text": t} for t in primary_texts]
titles = [{"text": h} for h in headlines]
descs = [{"text": d} for d in descriptions]
link_urls = [{"website_url": link_url}]
cta = [{"type": call_to_action_type}]
asset_feed_spec = {
"images": images,
"bodies": bodies,
"titles": titles,
"descriptions": descs,
"link_urls": link_urls,
"call_to_action_types": cta,
"ad_formats": ["SINGLE_IMAGE"],
}
if url_parameters:
asset_feed_spec["link_urls"] = [
{"website_url": link_url, "display_url": link_url}
]
data = {
"name": name,
"asset_feed_spec": json.dumps(asset_feed_spec),
"degrees_of_freedom_spec": json.dumps({
"creative_features_spec": {
"standard_enhancements": {"enroll_status": "OPT_IN"}
}
}),
}
if url_parameters:
data["url_tags"] = url_parameters
if page_id:
data["object_story_spec"] = json.dumps({
"page_id": page_id,
})
result = api_request("POST", f"{ACCOUNT_ID}/adcreatives", data=data)
if result and "id" in result:
creative_id = result["id"]
print(f" Creative ID: {creative_id}")
return creative_id
return None
def create_ad(name: str, adset_id: str, creative_id: str) -> str | None:
"""Create an ad linking creative to ad set. PAUSED state."""
print(f"Creating ad: {name}")
result = api_request(
"POST",
f"{ACCOUNT_ID}/ads",
data={
"name": name,
"adset_id": adset_id,
"creative": json.dumps({"creative_id": creative_id}),
"status": "PAUSED",
},
)
if result and "id" in result:
ad_id = result["id"]
print(f" Ad ID: {ad_id}")
return ad_id
return None
# ---------------------------------------------------------------------------
# Config validation & orchestration
# ---------------------------------------------------------------------------
def validate_config(config: dict) -> list[str]:
"""Pre-publish validation. Returns list of error messages (empty = valid)."""
errors = []
# Campaign
campaign = config.get("campaign", {})
if not campaign.get("name"):
errors.append("campaign.name is required")
# Ad sets
adsets = config.get("adsets", [])
if not adsets:
errors.append("At least one ad set is required")
for i, adset in enumerate(adsets):
if not adset.get("name"):
errors.append(f"adsets[{i}].name is required")
budget = adset.get("daily_budget", 0)
if budget < 100:
errors.append(f"adsets[{i}].daily_budget must be >= 100 cents ($1.00)")
if not adset.get("targeting"):
errors.append(f"adsets[{i}].targeting is required")
# Creative
creative = config.get("creative", {})
if not creative.get("primary_texts"):
errors.append("creative.primary_texts is required (at least 1)")
if not creative.get("headlines"):
errors.append("creative.headlines is required (at least 1)")
if not creative.get("link_url"):
errors.append("creative.link_url is required")
if not creative.get("images_directory") and not creative.get("image_hashes"):
errors.append("creative.images_directory or creative.image_hashes is required")
return errors
def publish_from_config(config_path: Path) -> bool:
"""Orchestrate full campaign publish from JSON config."""
print(f"Loading config: {config_path}")
with open(config_path) as f:
config = json.load(f)
# Validate
errors = validate_config(config)
if errors:
print("\nConfig validation failed:")
for err in errors:
print(f" - {err}")
return False
campaign_cfg = config["campaign"]
adsets_cfg = config["adsets"]
creative_cfg = config["creative"]
print(f"\n{'='*60}")
print("PUBLISHING CAMPAIGN")
print(f"{'='*60}")
print(f" Campaign: {campaign_cfg['name']}")
print(f" Ad Sets: {len(adsets_cfg)}")
print(f" Status: All created PAUSED")
print(f"{'='*60}\n")
# Step 1: Upload images
image_hashes = creative_cfg.get("image_hashes", [])
if not image_hashes and creative_cfg.get("images_directory"):
img_dir = Path(creative_cfg["images_directory"])
if not img_dir.is_absolute():
img_dir = config_path.parent / img_dir
hash_map = upload_images_from_directory(img_dir)
image_hashes = list(hash_map.values())
if not image_hashes:
print("Error: No images available for creative")
return False
# Step 2: Create campaign
campaign_id = create_campaign(
name=campaign_cfg["name"],
objective=campaign_cfg.get("objective", "OUTCOME_SALES"),
)
if not campaign_id:
return False
# Step 3: Create creative
creative_id = create_creative(
name=f"{campaign_cfg['name']}-creative",
image_hashes=image_hashes,
primary_texts=creative_cfg["primary_texts"],
headlines=creative_cfg["headlines"],
descriptions=creative_cfg.get("descriptions", []),
link_url=creative_cfg["link_url"],
call_to_action_type=creative_cfg.get("call_to_action_type", "SHOP_NOW"),
url_parameters=creative_cfg.get("url_parameters", ""),
page_id=creative_cfg.get("page_id"),
)
if not creative_id:
return False
# Step 4: Create ad sets + ads
for adset_cfg in adsets_cfg:
adset_id = create_adset(
campaign_id=campaign_id,
name=adset_cfg["name"],
daily_budget=adset_cfg["daily_budget"],
targeting=adset_cfg["targeting"],
optimization_goal=adset_cfg.get("optimization_goal", "OFFSITE_CONVERSIONS"),
billing_event=adset_cfg.get("billing_event", "IMPRESSIONS"),
bid_strategy=adset_cfg.get("bid_strategy", "LOWEST_COST_WITHOUT_CAP"),
pixel_id=adset_cfg.get("pixel_id"),
)
if not adset_id:
print(f" Warning: Failed to create ad set '{adset_cfg['name']}'")
continue
ad_id = create_ad(
name=f"{adset_cfg['name']}-ad",
adset_id=adset_id,
creative_id=creative_id,
)
if not ad_id:
print(f" Warning: Failed to create ad for '{adset_cfg['name']}'")
# Summary
print(f"\n{'='*60}")
print("PUBLISH COMPLETE")
print(f"{'='*60}")
print(f" Campaign ID: {campaign_id}")
print(f" Status: PAUSED (review in Ads Manager before activating)")
print(f"\n To activate:")
print(f" uv run scripts/meta-ads-publish.py --resume {campaign_id}")
print(f"{'='*60}")
return True
# ---------------------------------------------------------------------------
# Campaign management
# ---------------------------------------------------------------------------
def list_campaigns() -> None:
"""List all campaigns with status, spend, dates."""
print("Fetching campaigns...")
result = api_request(
"GET",
f"{ACCOUNT_ID}/campaigns",
params={
"fields": "id,name,status,effective_status,objective,daily_budget,lifetime_budget,start_time,created_time",
"limit": 50,
},
)
if not result or not result.get("data"):
print(" No campaigns found")
return
campaigns = result["data"]
print(f"\n{'='*90}")
print("META ADS CAMPAIGNS")
print(f"{'='*90}")
print(f"\n{'Name':<35} {'Status':<12} {'Objective':<20} {'ID':<20}")
print(f"{'-'*35} {'-'*12} {'-'*20} {'-'*20}")
for c in campaigns:
name = c.get("name", "")[:34]
status = c.get("effective_status", c.get("status", ""))
objective = c.get("objective", "")
cid = c.get("id", "")
print(f"{name:<35} {status:<12} {objective:<20} {cid:<20}")
print(f"\n Total: {len(campaigns)} campaigns")
print(f"{'='*90}")
def campaign_status(campaign_id: str) -> None:
"""Show campaign hierarchy: campaign → ad sets → ads."""
print(f"Fetching status for campaign {campaign_id}...")
# Campaign
campaign = api_request(
"GET",
campaign_id,
params={"fields": "id,name,status,effective_status,objective,daily_budget,created_time"},
)
if not campaign:
print(" Campaign not found")
return
print(f"\n{'='*70}")
print(f"CAMPAIGN: {campaign.get('name')}")
print(f"{'='*70}")
print(f" ID: {campaign.get('id')}")
print(f" Status: {campaign.get('effective_status', campaign.get('status'))}")
print(f" Objective: {campaign.get('objective')}")
print(f" Created: {campaign.get('created_time', 'N/A')}")
# Ad sets
adsets = api_request(
"GET",
f"{campaign_id}/adsets",
params={
"fields": "id,name,status,effective_status,daily_budget,optimization_goal,targeting",
"limit": 50,
},
)
if adsets and adsets.get("data"):
print(f"\n Ad Sets ({len(adsets['data'])}):")
for adset in adsets["data"]:
budget = int(adset.get("daily_budget", 0)) / 100
print(f" - {adset['name']} [{adset.get('effective_status', adset.get('status'))}] ${budget:.2f}/day")
print(f" ID: {adset['id']}")
# Ads under this ad set
ads = api_request(
"GET",
f"{adset['id']}/ads",
params={
"fields": "id,name,status,effective_status,creative",
"limit": 50,
},
)
if ads and ads.get("data"):
for ad in ads["data"]:
print(f" Ad: {ad['name']} [{ad.get('effective_status', ad.get('status'))}] ID: {ad['id']}")
print(f"\n{'='*70}")
def toggle_campaign_status(campaign_id: str, status: str) -> None:
"""Pause or resume a campaign."""
action = "Pausing" if status == "PAUSED" else "Activating"
print(f"{action} campaign {campaign_id}...")
result = api_request(
"POST",
campaign_id,
data={"status": status},
)
if result and result.get("success"):
print(f" Campaign {campaign_id} is now {status}")
else:
print(f" Failed to update campaign status")
# ---------------------------------------------------------------------------
# Check & setup
# ---------------------------------------------------------------------------
def check_credentials() -> None:
"""Verify API access and show account info."""
print("Checking Meta Ads API access...\n")
result = api_request(
"GET",
ACCOUNT_ID,
params={"fields": "id,name,account_status,currency,timezone_name,amount_spent,balance"},
)
if not result:
print("Failed to access ad account. Check your credentials.")
print("\nRun with --setup for configuration instructions:")
print(" uv run scripts/meta-ads-publish.py --setup")
sys.exit(1)
status_map = {
1: "ACTIVE",
2: "DISABLED",
3: "UNSETTLED",
7: "PENDING_RISK_REVIEW",
8: "PENDING_SETTLEMENT",
9: "IN_GRACE_PERIOD",
100: "PENDING_CLOSURE",
101: "CLOSED",
}
account_status = status_map.get(result.get("account_status", 0), "UNKNOWN")
spent = int(result.get("amount_spent", 0)) / 100
print(f" Account: {result.get('name', 'N/A')}")
print(f" ID: {result.get('id')}")
print(f" Status: {account_status}")
print(f" Currency: {result.get('currency', 'N/A')}")
print(f" Timezone: {result.get('timezone_name', 'N/A')}")
print(f" Spent: ${spent:,.2f}")
print(f"\n API access verified successfully.")
def print_setup_guide() -> None:
"""Print credential setup instructions."""
print("""
META ADS PUBLISH SETUP GUIDE
=============================
To use this script, you need:
1. META_ADS_ACCESS_TOKEN - System User token with ads_management permission
2. META_ADS_ACCOUNT_ID - Ad account ID (format: act_XXXXXXXXX)
STEP 1: Create a System User in Business Manager
-------------------------------------------------
1. Go to business.facebook.com/settings
2. Navigate to Users > System Users
3. Click "Add" to create a new System User
4. Set role to "Admin" (needed for ad management)
STEP 2: Generate an Access Token
---------------------------------
1. Click on the System User you created
2. Click "Generate New Token"
3. Select your app
4. Select these permissions:
- ads_management (required - create/edit campaigns)
- ads_read (required - read campaign data)
- pages_read_engagement (for page-linked ads)
5. Click "Generate Token"
6. Copy and save the token securely
Note: System User tokens don't expire (unlike user tokens).
STEP 3: Get Your Ad Account ID
-------------------------------
1. Go to business.facebook.com/settings
2. Navigate to Accounts > Ad Accounts
3. Your account ID is shown (format: act_XXXXXXXXX)
4. Make sure the System User is assigned to this ad account
STEP 4: Add to .env
--------------------
META_ADS_ACCESS_TOKEN=your_system_user_token
META_ADS_ACCOUNT_ID=act_XXXXXXXXX
STEP 5: Verify
--------------
uv run scripts/meta-ads-publish.py --check
CONFIG FILE FORMAT
==================
Create a JSON config to publish a full campaign:
{
"campaign": {
"name": "my-campaign-name",
"objective": "OUTCOME_SALES"
},
"adsets": [
{
"name": "Broad-25-54",
"daily_budget": 7500,
"targeting": {
"age_min": 25,
"age_max": 54,
"genders": [0],
"geo_locations": {"countries": ["US"]},
"publisher_platforms": ["facebook", "instagram"]
},
"optimization_goal": "OFFSITE_CONVERSIONS"
}
],
"creative": {
"images_directory": "./path/to/images/",
"primary_texts": ["Your primary text 1", "Your primary text 2"],
"headlines": ["Headline 1", "Headline 2"],
"descriptions": ["Description 1"],
"link_url": "https://yoursite.com/landing-page",
"url_parameters": "utm_source=meta&utm_medium=paid-social&utm_campaign=my-campaign",
"call_to_action_type": "SHOP_NOW"
}
}
Budget is in CENTS (7500 = $75.00/day).
All objects are created PAUSED. Review in Ads Manager before activating.
""")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Meta Ads Campaign Publisher",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run scripts/meta-ads-publish.py --setup
uv run scripts/meta-ads-publish.py --check
uv run scripts/meta-ads-publish.py --upload-images ./ads/images/
uv run scripts/meta-ads-publish.py --publish campaign-config.json
uv run scripts/meta-ads-publish.py --list
uv run scripts/meta-ads-publish.py --status 12345678
uv run scripts/meta-ads-publish.py --pause 12345678
uv run scripts/meta-ads-publish.py --resume 12345678
""",
)
parser.add_argument("--setup", action="store_true", help="Show setup guide")
parser.add_argument("--check", action="store_true", help="Verify API access")
parser.add_argument("--upload-images", metavar="DIR", help="Upload images from directory")
parser.add_argument("--publish", metavar="CONFIG", help="Publish campaign from JSON config")
parser.add_argument("--list", action="store_true", help="List campaigns")
parser.add_argument("--status", metavar="CAMPAIGN_ID", help="Show campaign hierarchy status")
parser.add_argument("--pause", metavar="CAMPAIGN_ID", help="Pause a campaign")
parser.add_argument("--resume", metavar="CAMPAIGN_ID", help="Resume (activate) a campaign")
args = parser.parse_args()
if args.setup:
print_setup_guide()
return
# All other commands need credentials
if not ACCESS_TOKEN or not ACCOUNT_ID:
print("Error: Missing Meta Ads credentials")
print("\nRequired environment variables:")
print(" META_ADS_ACCESS_TOKEN - System User token")
print(" META_ADS_ACCOUNT_ID - Ad account ID (act_XXXXXXXXX)")
print("\nRun with --setup for configuration instructions:")
print(" uv run scripts/meta-ads-publish.py --setup")
sys.exit(1)
if args.check:
check_credentials()
elif args.upload_images:
img_dir = Path(args.upload_images)
if not img_dir.is_dir():
print(f"Error: {img_dir} is not a directory")
sys.exit(1)
hashes = upload_images_from_directory(img_dir)
if hashes:
print(f"\nImage hashes (for use in config):")
print(json.dumps(hashes, indent=2))
elif args.publish:
config_path = Path(args.publish)
if not config_path.exists():
print(f"Error: Config file not found: {config_path}")
sys.exit(1)
success = publish_from_config(config_path)
if not success:
sys.exit(1)
elif args.list:
list_campaigns()
elif args.status:
campaign_status(args.status)
elif args.pause:
toggle_campaign_status(args.pause, "PAUSED")
elif args.resume:
toggle_campaign_status(args.resume, "ACTIVE")
else:
# Default: list campaigns
if ACCESS_TOKEN and ACCOUNT_ID:
list_campaigns()
else:
parser.print_help()
if __name__ == "__main__":
main()
Analyze Meta Ads Performance
Purpose: Retrieve and analyze Meta ad campaign performance metrics with DTC benchmark comparisons and actionable recommendations.
When to Use:
- User asks about ad performance, ROAS, ad spend, or campaign metrics
- User wants to know which ad or creative is winning
- User needs audience or creative breakdowns
- Routine performance check-ins
Prerequisites:
META_ADS_ACCESS_TOKENandMETA_ADS_ACCOUNT_IDin.env- Active or recently active campaigns in the ad account
---
Workflow Steps
Step 1: Account Summary
Description: Get the high-level account overview with DTC benchmarks.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Analytics.py --summary --period 7What you see:
- Spend, impressions, reach, frequency
- Clicks, CTR, CPC, CPM
- Purchases, add-to-cart, CPA, ROAS
- DTC benchmark comparison (GOOD/OK/LOW/HIGH ratings)
DTC Benchmark Ranges:
| Metric | Good | OK | Needs Work |
|---|---|---|---|
| CTR | >1.5% | 1-1.5% | <1% |
| CPM | <$15 | $15-25 | >$25 |
| CPA | <$30 | $30-50 | >$50 |
| ROAS | >3x | 2-3x | <2x |
| Frequency | 1-3 | 3-5 | >5 (ad fatigue) |
---
Step 2: Campaign Comparison
Description: Compare campaigns to find top/bottom performers.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Analytics.py --campaigns --period 7Analysis points:
- Which campaign has the highest ROAS?
- Which campaign has the lowest CPA?
- Are any campaigns spending without converting?
- Is budget allocated to the best performers?
---
Step 3: Campaign Deep Dive
Description: For the most important campaign(s), get daily trends and breakdowns.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Analytics.py --campaign CAMPAIGN_ID --period 14Analysis points:
- Is performance trending up or down?
- Are we past Meta's learning phase (~50 conversions / 7 days)?
- Which ad sets are outperforming?
- Which individual ads are winning?
---
Step 4: Creative Analysis
Description: Understand which creative elements are performing best.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Analytics.py --ads --period 7Analysis points:
- Which image variants get the highest CTR?
- Which primary text drives the most conversions?
- Which headline has the best engagement?
- Are there clear winners to scale or losers to cut?
---
Step 5: Audience Insights
Description: Check demographic and placement performance.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Analytics.py --audience --period 7Analysis points:
- Age: Which age groups convert best? Any wasted spend on non-converting ages?
- Gender: Performance difference between male/female?
- Platform: Facebook vs Instagram — where does the audience engage?
- Device: Mobile vs desktop conversion rates?
- Placement: Feed vs Stories vs Reels effectiveness?
---
Step 6: Recommendations
Description: Based on all data, provide actionable next steps.
Framework for recommendations:
If ROAS > 3x: Scale budget 20-30%. Test new audiences. If ROAS 2-3x: Hold budget. Optimize creative (test new images/copy). If ROAS < 2x: Audit targeting. Pause worst performers. Test new angles.
If CTR < 1%: Creative isn't resonating. Test new hooks/images. If CTR > 2% but low ROAS: Traffic quality issue. Check landing page conversion rate.
If Frequency > 4: Ad fatigue setting in. Refresh creative or expand audience. If CPM rising: Competition increasing. Try new placements or audiences.
Common actions:
- Kill ads with spend but zero conversions after 3+ days
- Increase budget on ad sets with CPA below target
- Duplicate winning ad sets with new audiences
- Refresh creative when frequency exceeds 4
---
Step 7: Sync Data (Optional)
Description: Save all data locally for cross-channel analysis.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Analytics.py --sync --period 30Data saved to Analytics/meta-ads/ for use with growth dashboard and content analysis scripts.
---
Outputs
Primary Output:
- Performance summary with benchmark comparisons
- Campaign/ad set/ad level metrics
- Audience and creative breakdowns
- Actionable recommendations
Where outputs are stored:
- Console output for immediate review
Analytics/meta-ads/when --sync is used- CSV export available via --export
---
Error Handling
No data available:
- Campaigns may be too new (need 24+ hours for data)
- Check that campaigns are ACTIVE (not PAUSED)
- Try a longer --period
API errors:
- Verify credentials with
uv run ~/.claude/skills/MetaAds/tools/Publish.py --check - Rate limiting is handled automatically with exponential backoff
- If persistent, wait 15 minutes and retry
---
Last Updated: 2026-02-07
Publish Meta Ads Campaign
Purpose: Create and publish a full Meta ad campaign (campaign -> ad sets -> creative -> ads) from local assets and copy.
When to Use:
- User wants to publish, create, or launch a Meta/Facebook/Instagram ad campaign
- User has ad images and copy ready to push to Meta Ads Manager
- User wants to upload images and get image hashes
Prerequisites:
META_ADS_ACCESS_TOKENandMETA_ADS_ACCOUNT_IDin.env- If not configured, route to Setup.md first
- Ad images (PNG/JPG) in a local directory
- Ad copy (primary texts, headlines, descriptions)
- Landing page URL
---
Workflow Steps
Step 1: Check Credentials
Description: Verify Meta Ads API access before attempting publish.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Publish.py --checkExpected Outcomes:
- Success: Account name and status displayed -> Proceed to Step 2
- Failure: Token expired (error 190) -> Route to Setup.md Part 6 (Token Refresh)
- Failure: Missing credentials -> Route to Setup.md for full setup
---
Step 2: Upload Images
Description: Upload ad images from local directory to Meta and get image hashes.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Publish.py --upload-images ./path/to/images/Notes:
- Supported formats: PNG, JPG, JPEG, WEBP
- Max file size: 30MB per image
- Max 10 images per Advantage+ creative
- Returns
{filename: image_hash}map - Image hashes are used in the creative config
Expected Outcomes:
- Success: Image hashes returned -> Use in config JSON
- Failure: Upload errors -> Check image format/size, retry
---
Step 3: Create Campaign Config
Description: Build the JSON config file for the campaign. Ask the user for the details, or gather from context in the conversation.
Information needed from user: 1. Campaign name — short descriptive slug (e.g., "summer-sale-june") 2. Objective — what are they optimizing for? (see objective table below) 3. Daily budget — how much per day? (converted to cents in config) 4. Target audience — age range, gender, countries, interests 5. Ad images — directory path to images 6. Primary texts — 2-5 variants of the main ad copy 7. Headlines — 2-5 short headlines (40 chars or less ideal) 8. Descriptions — 1-2 supporting descriptions 9. Landing page URL — where clicks go 10. CTA button — SHOP_NOW, LEARN_MORE, SIGN_UP, etc.
Objective selection guide:
| User Goal | Objective | Optimization Goal |
|---|---|---|
| "I want sales/purchases" | OUTCOME_SALES | OFFSITE_CONVERSIONS |
| "I want leads/signups" | OUTCOME_LEADS | LEAD_GENERATION |
| "I want website traffic" | OUTCOME_TRAFFIC | LINK_CLICKS |
| "I want brand awareness" | OUTCOME_AWARENESS | REACH |
Template:
{
"campaign": {
"name": "campaign-name-month-year",
"objective": "OUTCOME_SALES"
},
"adsets": [
{
"name": "Broad-25-54-US",
"daily_budget": 5000,
"targeting": {
"age_min": 25,
"age_max": 54,
"genders": [0],
"geo_locations": {
"countries": ["US"]
},
"publisher_platforms": ["facebook", "instagram"],
"facebook_positions": ["feed", "video_feeds", "story", "reels"],
"instagram_positions": ["stream", "story", "reels", "explore"]
},
"optimization_goal": "OFFSITE_CONVERSIONS",
"bid_strategy": "LOWEST_COST_WITHOUT_CAP"
}
],
"creative": {
"images_directory": "./path/to/images/",
"primary_texts": [
"Hook with the pain point or desire",
"Lead with social proof or a stat",
"Comparison or price-anchoring angle"
],
"headlines": [
"Short Punchy Headline",
"Benefit-Driven Headline"
],
"descriptions": [
"Supporting description with key differentiator"
],
"link_url": "https://yoursite.com/landing-page",
"url_parameters": "utm_source=meta&utm_medium=paid-social&utm_campaign=campaign-name&utm_content={{ad.name}}",
"call_to_action_type": "SHOP_NOW"
}
}Config rules:
daily_budgetis in cents (5000 = $50.00/day)genders: 0 = all, 1 = male, 2 = female{{ad.name}}is a Meta dynamic parameter — auto-fills with the ad name in UTMs- Advantage+ creative tests all image/text/headline combinations automatically
- Save config as JSON file for reproducibility
Targeting options reference:
Geo locations:
{"countries": ["US"]}
{"countries": ["US", "CA"]}
{"regions": [{"key": "4081"}]}
{"cities": [{"key": "2421836", "radius": 25, "distance_unit": "mile"}]}Interest targeting (optional — broad often outperforms):
{
"flexible_spec": [
{
"interests": [
{"id": "6003139266461", "name": "Yoga"},
{"id": "6003384145981", "name": "Meditation"}
]
}
]
}---
Step 4: Publish Campaign
Description: Run the full publish flow.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Publish.py --publish path/to/config.jsonWhat happens: 1. Config is validated (budget, targeting, required fields) 2. Images are uploaded from images_directory 3. Campaign is created (PAUSED) 4. Advantage+ creative is created with all text/image variants 5. Ad set is created with targeting and budget 6. Ad is created linking creative to ad set 7. Campaign ID is returned
All objects are created PAUSED. Nothing spends money until explicitly activated.
---
Step 5: Review in Ads Manager
Description: Verify everything looks right before activating.
Checklist:
- [ ] Campaign name and objective correct
- [ ] Ad set targeting matches intended audience
- [ ] Budget is correct (daily vs lifetime)
- [ ] Creative images display correctly
- [ ] All text variants are present
- [ ] Landing page URL and UTM parameters correct
- [ ] Pixel/conversion tracking is configured (for OUTCOME_SALES)
- [ ] Page identity is correct
Actions:
uv run ~/.claude/skills/MetaAds/tools/Publish.py --status CAMPAIGN_ID---
Step 6: Activate Campaign
Description: Once reviewed, activate the campaign.
Actions:
uv run ~/.claude/skills/MetaAds/tools/Publish.py --resume CAMPAIGN_IDPost-activation guidance for the user:
- Don't touch it for 24-48 hours — let Meta's algorithm learn
- Check metrics with
~/.claude/skills/MetaAds/tools/Analytics.py --campaign CAMPAIGN_IDafter day 2 - Meta's learning phase needs ~50 conversions in 7 days to optimize fully
- Don't increase budget more than 20-30% at a time (resets learning)
- If no spend after 48 hours: try switching optimization to Link Clicks temporarily
---
Outputs
Primary Output:
- Campaign ID (for tracking and management)
- Full hierarchy: campaign -> ad sets -> creatives -> ads
Where outputs are stored:
- Campaign exists in Meta Ads Manager
- Config JSON saved locally for reference and reproducibility
- Image hashes printed to console
---
Error Handling
Invalid config:
- Validation runs before any API calls
- Lists all config errors at once
- Fix and re-run
Image upload failure:
- Check file format (PNG/JPG/JPEG/WEBP only)
- Check file size (Meta max: 30MB per image)
- Retry individual images
API permission error:
- Verify token has
ads_managementpermission - Verify token owner/System User is assigned to the ad account
- Run
--checkto diagnose - Route to Setup.md if needed
Rate limiting:
- Automatic exponential backoff (up to 5 retries)
- If persistent, wait 15 minutes and retry
Token expired:
- Error code 190, subcode 463
- Route to Setup.md Part 6 for token refresh
---
Last Updated: 2026-02-07
Meta Ads API Setup Guide
Purpose: Walk a user through the complete Meta Ads API setup from scratch — Business Manager, Developer App, access tokens, ad account ID, and verification. This guide contains everything an agent needs to help without external research.
When to Use:
- User says "set up meta ads", "connect meta ads", "configure facebook ads API"
--checkfails with credential errors- User is setting up this skill for the first time
- Token has expired and needs refreshing
---
Prerequisites Check
Before starting, confirm the user has:
- [ ] A Facebook account with admin access to their business's Facebook Page
- [ ] An active Meta Ad Account (they've run ads before, or are ready to create one)
- [ ]
uvinstalled (Python package runner) — if not:curl -LsSf https://astral.sh/uv/install.sh | sh
---
Part 0: Verify Scripts
The Python scripts run directly from the skill's tools/ directory — no copy or install step needed. They load .env from the current working directory.
Verify scripts are accessible:
uv run ~/.claude/skills/MetaAds/tools/Publish.py --setupIf this prints the setup guide, proceed to Part 1.
Expected project structure after setup:
project-root/
├── .env # Credentials go here
└── Analytics/meta-ads/ # Created automatically by --sync---
Part 1: Meta Business Manager
What it is: Business Manager (business.facebook.com) is Meta's hub for managing ad accounts, pages, and users. Most businesses running ads already have one.
If they already have Business Manager:
1. Go to business.facebook.com 2. Confirm they can see their ad account under Accounts > Ad Accounts 3. Note the Ad Account ID (numeric, e.g., 739416136921369) 4. Skip to Part 2
If they need to create one:
1. Go to business.facebook.com/overview 2. Click "Create Account" 3. Enter business name, their name, and business email 4. Once created, go to Accounts > Ad Accounts 5. Either "Add" an existing ad account or "Create a new ad account" 6. Note the Ad Account ID
The Ad Account ID is the number shown in Business Manager. When used in the API, it needs the act_ prefix: if the ID is 739416136921369, the API format is act_739416136921369.
---
Part 2: Create a Meta Developer App
What it is: A Developer App gives you API access. It doesn't need to be published or reviewed — it's just a container for your API credentials.
Step-by-step:
1. Go to developers.facebook.com 2. Click "My Apps" in the top right 3. Click "Create App" 4. Select app type: "Business" 5. Enter app name (e.g., "My Ads Manager API") 6. Select the Business Manager account to associate with 7. Click "Create App"
Add Marketing API product:
1. In the app dashboard, find "Add Products to Your App" 2. Find "Marketing API" and click "Set Up" 3. That's it — no further configuration needed for this product
Get App ID and App Secret:
1. In the left sidebar, go to Settings > Basic 2. Copy the App ID (numeric, e.g., 167270972883623) 3. Click "Show" next to App Secret and copy the App Secret 4. Save both — you'll need them for token exchange
---
Part 3: Generate an Access Token
There are two paths: System User tokens (recommended, never expire) or User tokens (easier, expire in 60 days).
Option A: System User Token (Recommended — Never Expires)
What it is: A System User is a non-human account in Business Manager specifically for API access. Its tokens don't expire.
1. Go to business.facebook.com/settings 2. Navigate to Users > System Users 3. Click "Add"
- Name: "Ads API" (or whatever you want)
- Role: Admin
4. Click "Create System User" 5. Assign the ad account:
- Click on the System User
- Click "Add Assets"
- Select "Ad Accounts"
- Find and select your ad account
- Toggle "Manage campaigns" permission ON
- Click "Save Changes"
6. Generate token:
- Click on the System User
- Click "Generate New Token"
- Select your app (the one from Part 2)
- Check these permissions:
ads_managementads_readpages_read_engagement(optional, for page-linked ads)- Click "Generate Token"
- Copy and save immediately — you won't see it again
Troubleshooting System Users:
- "Add" button grayed out? You've hit your System User limit. Use Option B instead, or delete an unused System User.
- Can't find your ad account when assigning assets? Make sure the ad account is added to Business Manager first (Accounts > Ad Accounts > Add).
- Permission denied? You need to be a Business Manager admin.
Option B: User Token (Easier — Expires in 60 Days)
What it is: A token tied to your personal Facebook account. Easier to set up but needs refreshing every 60 days.
Step 1: Get a short-lived token 1. Go to developers.facebook.com/tools/explorer 2. Select your app from the "Meta App" dropdown 3. Click "Generate Access Token" 4. When prompted, grant these permissions:
ads_managementads_readpages_read_engagementread_insights
5. Click "Generate Access Token" 6. Copy the token
Step 2: Exchange for a long-lived token (60 days)
The short-lived token expires in ~1 hour. Exchange it immediately:
curl "https://graph.facebook.com/v21.0/oauth/access_token?grant_type=fb_exchange_token&client_id=YOUR_APP_ID&client_secret=YOUR_APP_SECRET&fb_exchange_token=YOUR_SHORT_LIVED_TOKEN"Replace:
YOUR_APP_ID— from Part 2YOUR_APP_SECRET— from Part 2YOUR_SHORT_LIVED_TOKEN— from Step 1
The response will contain your long-lived token:
{"access_token": "EAAxxxxxxxxxx...", "token_type": "bearer"}The agent can run this curl command for the user if they provide the app ID, app secret, and short-lived token.
When the token expires (every 60 days): Repeat Step 1 and Step 2. The user will need to generate a new short-lived token in Graph API Explorer and exchange it again. The agent can automate the exchange step.
---
Part 4: Configure .env
Create or update the .env file in the project root:
META_ADS_ACCESS_TOKEN=EAAxxxxxxxxxx...your_token_here
META_ADS_ACCOUNT_ID=act_XXXXXXXXXAlso save the app credentials for future token refreshes:
FACEBOOK_APP_ID=your_app_id
FACEBOOK_APP_SECRET=your_app_secretCommon mistakes:
- Missing the
act_prefix on the account ID - Extra spaces or quotes around the token
- Using an expired token (error code 190, subcode 463)
- Using a token without
ads_managementscope
---
Part 5: Verify
Run the check command:
uv run ~/.claude/skills/MetaAds/tools/Publish.py --checkExpected successful output:
Checking Meta Ads API access...
Account: Your Business Name
ID: act_XXXXXXXXX
Status: ACTIVE
Currency: USD
Timezone: America/New_York
Spent: $X,XXX.XX
API access verified successfully.If it fails, diagnose by error:
| Error | Cause | Fix |
|---|---|---|
Error validating access token: Session has expired | Token expired | Generate new token (Part 3) |
Invalid OAuth access token | Token is malformed | Check for copy-paste errors, no extra spaces |
(#100) Missing permissions | Token lacks required scopes | Regenerate with ads_management and ads_read |
User does not have permission to manage ad account | System User not assigned to account | Assign in Business Manager (Part 3, Option A, Step 5) |
Ad account is disabled | Account suspended by Meta | User needs to resolve in Business Manager |
Error: Missing Meta Ads credentials | .env not configured | Add variables to .env (Part 4) |
---
Part 6: Token Refresh Process
For System User tokens: No action needed — they never expire.
For User tokens (every ~60 days):
Check if the agent has the app credentials stored:
# Check .env for app credentials
grep "FACEBOOK_APP" .envIf FACEBOOK_APP_ID and FACEBOOK_APP_SECRET are present, the refresh process is:
1. Tell the user: "Your Meta Ads token has expired. Please go to developers.facebook.com/tools/explorer, select your app, and click Generate Access Token. Grant the same permissions (ads_management, ads_read). Paste the new token here."
2. Once the user provides the short-lived token, exchange it:
curl -s "https://graph.facebook.com/v21.0/oauth/access_token?grant_type=fb_exchange_token&client_id=APP_ID&client_secret=APP_SECRET&fb_exchange_token=SHORT_TOKEN"3. Update META_ADS_ACCESS_TOKEN in .env with the long-lived token from the response.
4. Verify with --check.
---
Meta Pixel Setup (For Conversion Tracking)
What it is: The Meta Pixel tracks purchases, add-to-carts, and other actions on your website. Without it, Meta can't optimize for conversions and ROAS won't appear in analytics.
If using Shopify: 1. In Shopify admin, go to Settings > Customer Events (or Online Store > Preferences) 2. Look for "Facebook & Instagram" or "Meta" section 3. Enter your Pixel ID (found in Meta Events Manager) 4. Shopify handles all event tracking automatically
If using a custom site: 1. Go to business.facebook.com > Events Manager 2. Click "Connect Data Sources" > "Web" 3. Name your pixel and enter your website URL 4. Choose installation method:
- Partner integration (Shopify, WordPress, etc.) — easiest
- Manual install — add the pixel base code to your site's
<head>
5. Set up standard events: Purchase, AddToCart, ViewContent, InitiateCheckout
Finding your Pixel ID: 1. Go to Events Manager (business.facebook.com/events_manager) 2. Select your pixel 3. The Pixel ID is the numeric ID shown (e.g., 123456789012345)
The Pixel ID is needed in campaign configs when using OUTCOME_SALES objective with OFFSITE_CONVERSIONS optimization. Add it to ad set config:
{
"pixel_id": "123456789012345",
"optimization_goal": "OFFSITE_CONVERSIONS"
}---
Quick Reference: Common API Scopes
| Scope | What It Allows | Required For |
|---|---|---|
ads_management | Create, edit, delete campaigns/ads | Publishing campaigns |
ads_read | Read campaign metrics and insights | Analytics |
pages_read_engagement | Read page post engagement | Page-linked ad creative |
read_insights | Read page-level insights | Facebook page analytics |
business_management | Manage Business Manager assets | System User setup |
---
Quick Reference: Meta API Limits
| Limit | Value | Notes |
|---|---|---|
| API rate limit | ~200 calls/hour per ad account | Automatic backoff in scripts |
| Image upload size | 30MB max per image | PNG/JPG/JPEG/WEBP |
| Images per creative | 10 max in asset_feed_spec | Advantage+ flexible format |
| Primary texts per creative | 5 max | Advantage+ testing |
| Headlines per creative | 5 max | Advantage+ testing |
| Min daily budget | $1.00 (100 cents) | Per ad set |
| Campaign name length | 400 chars max | Keep short for readability |
| Token exchange window | Short-lived token must be < 1 hour old | Exchange immediately |
| Long-lived token duration | 60 days | Set a reminder to refresh |
---
Last Updated: 2026-02-07
Related skills
FAQ
Is Metaads safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.