The best CLI for Apple Ads. Agent-first, JSON-native, spend-safe, and built to work out of the box.
# Inspect
$ ads campaigns list --json
# Dry-run
$ ads keywords add 123 456 --text "photo editor,image filter" --match EXACT --bid 1.75 --json
# Apply
$ ads keywords add 123 456 --text "photo editor,image filter" --match EXACT --bid 1.75 --apply --jsonSpottable — marketplace alerts and deal intelligence for finding better local deals.
Apple Ads should have a great CLI. Now it does.
No Python project. No Node stack. No browser automation. No dashboard clicking. Just one Go CLI for the full inspect -> plan -> apply -> verify loop.
- 🤖 Agent-first - JSON output, stable commands, predictable workflows.
- 🛡️ Spend-safe - mutating commands dry-run first and require
--apply. - ⚡ Works out of the box - installs as
ads; config is local and non-interactive. - 📦 Single compiled binary - no runtime dependency chain after install.
- 🔎 Full escape hatch -
ads apireaches raw Apple Ads API endpoints. - 🔐 Secret-friendly - first-class 1Password hydration.
- 🧭 Four-campaign aware - Brand, Category, Competitor, Discovery.
This is unofficial. It is also the CLI Apple Ads should have had.
- Campaigns, ad groups, keywords, negative keywords, reports, budgets, geo, ads, creatives, ACLs.
- Apple Ads Platform API v1 — opt in per context (
ads config api v1) with all new endpoints: search term popularity, recommendations, suggestions, change history, ad accounts, shared budgets, impression share. - Dry-run mutations everywhere spend can change.
- Clean JSON for agents, scripts, and dashboards.
- Raw API access without writing OAuth code.
- Local config in
~/.apple-ads-cli. - 1Password setup that avoids pasting secrets into chat.
Download a prebuilt macOS binary:
arch="$(uname -m)"
case "$arch" in
arm64) target="arm64" ;;
x86_64) target="amd64" ;;
*) echo "unsupported macOS arch: $arch" >&2; exit 1 ;;
esac
curl -L -o ads.zip "https://github.com/dannolan/apple-ads-cli/releases/latest/download/ads_darwin_${target}.zip"
unzip -q -o ads.zip ads
install -m 0755 ads /usr/local/bin/ads
ads --helpOr build locally with Go:
go install github.com/dannolan/apple-ads-cli/cmd/ads@latest
ads --helpFrom a checkout:
go build -o ads ./cmd/ads
./ads --helpHomebrew builds from source:
brew tap dannolan/tap
brew install apple-ads-cli
ads --helpCreate Apple Ads API credentials in Apple Ads account settings. You need:
- Org ID
- Client ID
- Team ID
- Key ID
- EC private key PEM path
Generate an EC key if needed:
openssl ecparam -genkey -name prime256v1 -noout -out private-key.pem
openssl ec -in private-key.pem -pubout -out public-key.pemSave credentials and an app profile:
ads config init \
--org-id 123456 \
--client-id "$APPLE_ADS_CLIENT_ID" \
--team-id "$APPLE_ADS_TEAM_ID" \
--key-id "$APPLE_ADS_KEY_ID" \
--private-key ./private-key.pem
ads config app add \
--app-id 1234567890 \
--name "My App" \
--countries US,CA,GB \
--currency USD \
--bid 1.50 \
--cpa-goal 5.00Verify access with read-only checks:
ads config test --json
ads smoke --json
ads campaigns list --jsonManage several accounts side by side, like doctl contexts. Each context is an isolated credentials and app store. The default context maps to the config directory root, so existing setups keep working with no migration.
ads context list --json # show all contexts and which is active
ads context current --json # print the active context name
ads context create work # create a context and make it active
ads config init --org-id ... # writes to the active context
ads config app add --app-id ... # app profiles are per context too
ads context use default # switch back
ads context remove work # delete a context and its local configScope a single command to another context without switching — --context overrides the active context for that command only and never changes it:
ads config init --context work --org-id ...
ads campaigns list --context work --jsonCheck the current context anytime: ads context current --json, ads context list --json, or ads config show --json (which reports both active_context and the context the command resolved to).
Apple's Campaign Management API v5 is sunsetting January 26, 2027 in favor of the new Apple Ads Platform API v1 (https://api.ads.apple.com/v1). ads supports both. The default stays v5 so existing setups keep working; flip a context to v1 when you're ready to migrate:
ads config api v1 --json # per-context API version switch
ads config ad-account 123456 --json # v1 replaces orgId with adAccountId
ads config show --json # confirm api_version + ad_account_idOr override for a single command with --api v1 (no config change):
ads campaigns list --api v1 --jsonOn v1 the CLI automatically uses the flattened /{entity}/query endpoints, the result envelope, adAccountId headers, money objects, bidStrategy.bid bids, and synchronous reports. New v1-only commands:
# Search term popularity ("the terms stuff") — trending terms by genre + country
ads terms --country US --genre PRODUCTIVITY --granularity WEEKLY_SUN_SAT --json
ads terms --country GB --genre GAMES --granularity MONTHLY --days 30 --json
# Recommendations — query, then apply or dismiss
ads recommendations keyword query --json
ads recommendations keyword apply --id rec-kw-001 --apply --json
ads recommendations target-cpa query --json
ads recommendations budget dismiss --id rec-budget-001 --apply --json
# Suggestions (read-only)
ads suggestions keywords --body '{"filters":[...]}' --json
ads suggestions target-cpa --json
# Change history (audit trail with field-level before/after)
ads change-history query --entity-type Campaign --json
ads change-history detail Campaign.444555666.txn_abc123def456 --json
# Ad account management
ads ad-accounts list --json
ads ad-accounts get 123456 --json
ads ad-accounts resources --resource-type CONTENT_PROVIDER --json
# Insights + shared budgets
ads insights impression-share <campaign-id> --days 14 --json
ads shared-budgets list --json
ads shared-budgets get 777890001 --jsonAll wrapped commands (campaigns, adgroups, keywords, reports, budget, ads, etc.) are v1-aware: in v1 mode they hit the Platform API shapes above automatically. Dry-run protection and --json output behave identically.
Do not paste Apple Ads credentials into agent chat. Store them in 1Password and hydrate the local CLI config with the 1Password CLI (op).
Recommended layout:
| 1Password object | Name | Fields |
|---|---|---|
| API credential item | Apple Ads API |
org_id, client_id, team_id, key_id, ad_account_id (optional), api_version (optional) |
| Document | Apple Ads API Private Key |
EC private key PEM |
Then run:
ads config from-1password \
--vault "Private" \
--item "Apple Ads API" \
--key-document "Apple Ads API Private Key" \
--app-id 1234567890 \
--app-name "My App" \
--countries US \
--currency USD \
--json
ads smoke --jsonads smoke is read-only. It verifies OAuth, /me, supported countries, active app config, app eligibility, and campaign listing.
ads config show --json
ads acl me --json
ads campaigns list --json
ads campaigns audit --json
ads campaigns health --days 3 --json
ads account snapshot --days 7 --jsonads reports summary --days 7 --json
ads reports keywords <campaign-id> --days 14 --json
ads reports search-terms <campaign-id> --days 14 --json
ads reports impression-share <campaign-id> --days 14 --json
ads reports impression-share <campaign-id> --days 14 --apply --json
ads reports diagnose <campaign-id> --days 7 --jsonMutating commands return a dry-run payload until --apply is present.
ads campaigns setup --prefix "My App" --countries US --daily-budget 50 --json
ads keywords add <campaign-id> <adgroup-id> --text "term one,term two" --match EXACT --bid 1.50 --skip-existing --json
ads keywords add-negatives <campaign-id> --text "bad term" --match EXACT --json
ads campaigns pause <campaign-id> --jsonads keywords add <campaign-id> <adgroup-id> \
--text "term one,term two" \
--match EXACT \
--bid 1.50 \
--apply \
--jsonads keywords list <campaign-id> <adgroup-id> --json
ads reports summary --days 1 --json| Area | Commands |
|---|---|
| Config | ads config init, show, test, api, ad-account, app add, app list, app use, from-1password |
| Context | ads context list, use, current, create, remove |
| Access | ads acl list, me, search-apps, eligibility, countries |
| Campaigns | ads campaigns list, get, audit, health, setup, create, update, pause, enable, delete |
| Ad groups | ads adgroups list, create, pause, enable, delete |
| Keywords | ads keywords list, add, add-negatives, find, update-bid, pause, enable, delete, list-negatives, delete-negative |
| Reports | ads reports summary, adgroups, keywords, search-terms, ads, adgroup-keywords, adgroup-search-terms, impression-share, diagnose, bid-recommendations, custom, custom-list, custom-get |
| Account | ads account snapshot |
| Budget | ads budget list, get, status, create |
| Geo | ads geo search, show, set |
| Ads and creatives | ads ads list, create, delete, creative, creatives, product-pages, rejections |
| Terms (v1) | ads terms |
| Recommendations (v1) | `ads recommendations keyword |
| Suggestions (v1) | `ads suggestions keywords |
| Change history (v1) | `ads change-history query |
| Ad accounts (v1) | `ads ad-accounts list |
| Insights (v1) | ads insights impression-share |
| Shared budgets (v1) | `ads shared-budgets list |
| Escape hatch | ads api <method> <path> |
Print the live command manifest:
ads manifest --jsonads campaigns audit --json
ads campaigns setup --prefix "My App" --countries US --daily-budget 50 --json
ads campaigns setup --prefix "My App" --countries US --daily-budget 50 --apply --json
ads campaigns update <campaign-id> --body '{"dailyBudgetAmount":{"amount":"20.00","currency":"AUD"}}' --apply --json
ads campaigns rename <campaign-id> --name "ARCHIVED - Discovery" --json
ads campaigns set-budget <campaign-id> --amount 20 --json
ads campaigns set-countries <campaign-id> --countries AU,US --jsonads keywords list <campaign-id> <adgroup-id> --json
ads keywords add <campaign-id> <adgroup-id> --text "brand,my app" --match EXACT --bid 1.50 --json
ads keywords add-negatives <campaign-id> --text "free coins,testflight" --match EXACT --apply --json
ads keywords find --text "photo" --json
ads adgroups set-bid <campaign-id> <adgroup-id> --bid 2.00 --json
ads keywords set-bid <campaign-id> <adgroup-id> <keyword-id> --bid 2.25 --json
ads keywords update-bid <campaign-id> <adgroup-id> <keyword-id> --bid 2.25 --apply --jsonads reports summary --days 7 --json
ads reports adgroups <campaign-id> --days 7 --json
ads reports keywords <campaign-id> --days 14 --json
ads reports search-terms <campaign-id> --days 14 --json
ads reports ads <campaign-id> --days 14 --json
ads reports adgroup-keywords <campaign-id> <adgroup-id> --days 14 --json
ads reports adgroup-search-terms <campaign-id> <adgroup-id> --days 14 --json
ads reports bid-recommendations <campaign-id> <adgroup-id> --json
ads reports impression-share <campaign-id> --days 14 --jsonads reports impression-share is an async custom report wrapper. It returns a dry-run /custom-reports payload unless --apply is present.
Use --table for human-readable scans while keeping --json for agents:
ads campaigns list --table
ads reports summary --days 7 --table
ads keywords list <campaign-id> <adgroup-id> --tableads budget status --json
ads budget create --name "Q3" --amount 5000 --start 2026-07-01 --apply --json
ads geo search --query "California" --json
ads geo set <campaign-id> --countries US,CA --apply --json
ads ads product-pages --json
ads ads rejections --body '{}' --jsonUse this when Apple exposes something before the typed CLI wraps it. Same auth, same config, same dry-run safety for mutating calls.
ads api GET /campaigns --query limit=100 --json
ads api POST /reports/campaigns --body @body.json --json
ads api PUT /campaigns/123 --body '{"campaign":{"status":"PAUSED"}}' --json
ads api PUT /campaigns/123 --body '{"campaign":{"status":"PAUSED"}}' --apply --json
ads api GET /me --no-org-context --jsonads optimize generates an agent-readable optimization plan for weekly search-term maintenance. It keeps the expensive decisions visible: promote winners, block losers, and prevent Discovery from competing with your exact campaigns.
ads optimize --days 14 --jsonRecommended policy:
- Pull Discovery search-term reports.
- Treat search terms with at least 2 installs and CPA at or below the app goal as winners.
- Promote winners as exact keywords in Brand, Category, or Competitor campaigns.
- Add promoted winners as negatives in Discovery to prevent overlap.
- Treat search terms with spend and no installs as losers.
- Add losers as negative keywords to the relevant campaign.
Agent-first means one strict loop:
- Inspect account and app state.
- Run reports needed for the decision.
- Produce dry-run mutation payloads without
--apply. - Apply only after the plan matches intent.
- Verify with list or report commands.
The canonical guide is SKILL.md. If you are packaging this for an agent runtime, use the bundled skill at skills/apple-ads-cli/SKILL.md.
~/.apple-ads-cli/
|-- context.json # active context marker ({"active": "default"})
|-- credentials.json # Apple Ads API credentials for the default context; redacted by `ads config show`
|-- apps.json # App profiles, active app, countries, bid, and CPA goal for the default context
|-- private-key.pem # Optional local key path when hydrated from 1Password
`-- contexts/
|-- work/ # named contexts hold their own credentials.json + apps.json
| |-- credentials.json
| `-- apps.json
`-- personal/
|-- credentials.json
`-- apps.json
The default context lives at the config root for backward compatibility. Override the location with --config-dir, or pick a context with ads context use <name> / --context <name>.
go test ./...
go build ./cmd/adsRelease and publishing notes live in docs/release.md. Wrapped endpoint coverage is tracked in docs/api-audit.md.