Apple Ads
Use this skill when asked how Apple Ads are delivering (spend, CPI, taps, installs, which keywords / search terms drive volume), when changing the account (bids, budgets, keywords, launches), or when making a keep/kill/scale call on a keyword.
This is a generic version of a system run in production on a real account.
Everything structural transfers: the four scripts, the write discipline, the
testing slots, the ROAS method. Everything account-specific is marked
FILL IN — replace those with your own values and keep them current. The
single most important habit: maintain the "Current account state" section at
the bottom of this file, and record every change in ledger.md with its
reason.
The system in one paragraph
Four scripts read and write the account (scripts/). Every applied change is
recorded in ledger.md with a reason — that file is the account's decision
history, because the account config lives at Apple and nowhere else. Two
permanently occupied test slots keep the account learning (backlog.md holds
the candidate queue), staged kill gates price each test's downside at ~30% of
its full run, and keep/kill decisions come from the mature-cohort ROAS calc in
roas.md, never from Apple's own numbers (Apple data cannot tell you whether
spend converted to revenue).
There are exactly four scripts. Do not write a fifth.
scripts/ holds api-client.ts, fetch-ads.ts (read delivery + insights),
inspect-account.ts (read config), and apply-change.ts (the only write
path). That is the whole surface.
Never create a new script to change or investigate the account. In the account this system comes from, the docs originally described how to READ the account and never said how to CHANGE it — so every change became a bespoke dated file. It reached 36 files and 4,600 lines before being deleted, and the cost was not just volume: change rationales lived in source comments nobody diffed, so one change was silently reverted three weeks later by a script that could not know it existed; two scripts shipped the same day for the same campaign and only file dates said which won.
So, when you need to do something to the account:
| You want to | Do this |
|---|---|
| Change a bid, budget, or status | apply-change.ts <op> --id=… --to=…, dry run first |
| Add campaign negatives | apply-change.ts negative-add --campaign=… --terms=… |
| Launch a new campaign | campaign-create → negative-add → adgroup-create → keyword-add → campaign-status --to=ENABLED |
| An operation that does not exist yet | Add a row to OPERATIONS (update) or CREATIONS (create) in apply-change.ts |
| Investigate anything | fetch-ads.ts / inspect-account.ts, or an inline bun -e one-liner you do not commit |
| Record why a change was made | ledger.md — apply-change.ts appends to it on every applied change |
A one-off investigation is a shell command, not a file. A repeated one is a
--report= on fetch-ads.ts. A change is a row in OPERATIONS. A third
instance of anything is a row, never a new file.
Commands
Commands below assume you run from this directory; prefix the paths if you run
from elsewhere. First run ever: bun install in this directory (the scripts
need jose + dotenv). Credentials come from the APPLE_ADS_* env vars in a
.env here or in the working directory (see .env.example / README.md).
Read live delivery from the Apple Ads API:
# Campaign-level delivery (spend, impressions, taps, installs, CPI)
bun run scripts/fetch-ads.ts --report=campaigns --days=30 --output=json
# Keyword mining (bids, match type, CPI per keyword)
bun run scripts/fetch-ads.ts --report=keywords --days=7 --output=json
# Search-term mining (the actual queries that triggered ads)
bun run scripts/fetch-ads.ts --report=searchterms --days=7 --output=json
Market-position reads, which are about the auction rather than your own spend:
# Share of available impressions + competitive rank, per search term
bun run scripts/fetch-ads.ts --report=impressionshare --days=7 --country=US
# Apple's own first-party search-term popularity — ALWAYS pass --terms, or the
# endpoint's default sort returns whatever genre sorts first, not your app's
bun run scripts/fetch-ads.ts --report=popularity --terms=yourbrand,competitor
# Read-only keyword candidates with popularity scores (NOT Search Match)
bun run scripts/fetch-ads.ts --report=suggestions --country=US
# Audit log of every config change, UI and API, with actor
bun run scripts/fetch-ads.ts --report=changehistory --days=14
Options: --report= one of campaigns|keywords|searchterms|impressionshare|
popularity|suggestions|changehistory (default campaigns), --days=N
(default 7), --output=json|markdown (default markdown), --campaign=ID,
--by-country (campaigns report only: one row per campaign × storefront — the
per-country read for a multi-storefront campaign), --country=CC (default
APPLE_ADS_DEFAULT_COUNTRY, else US), --slots=all|first (impression
share), --limit=N (popularity / suggestions), --terms=a,b,c (popularity
lookup).
First-party popularity is worth knowing about for ASO, not just ads. Apple
publishes rankInGenre and a 1–100 score per storefront and genre — the only
first-party demand source there is, and the check on the third-party estimates
ASO work otherwise relies on.
A search-term report row with no term is not missing data. Apple withholds
the query text below a privacy volume threshold and aggregates those searches
per keyword; the row still carries real spend. The report labels these
(low volume, term withheld) and counts them in a footnote.
--days=N means N COMPLETE days ending yesterday. A window whose last day
is still in progress makes the newest day look like a collapse in every
day-over-day comparison — that trap has produced wrong readouts before, so the
partial current day is opt-in via --include-today, never a default.
Making a change
apply-change.ts is the only write path. It is dry run by default — a bare
run prints the before/after diff and exits without touching anything — and
--apply requires --reason, which it records in ledger.md alongside the
value the API returned on read-back (never the value we asked for).
# 1. See what would change. Writes nothing.
bun run scripts/apply-change.ts keyword-bid --id=<keywordId> --to=1.75
# 2. Execute, with the reasoning that goes in the ledger.
bun run scripts/apply-change.ts keyword-bid --id=<keywordId> --to=1.75 \
--apply --reason="Term is at 1% impression share, rank 6; buying back ad rank."
# 3. Confirm the account shape.
bun run scripts/inspect-account.ts
Update operations: campaign-budget, campaign-status, adgroup-bid,
adgroup-status, keyword-bid, keyword-status, negative-status. --id=
takes a comma-separated list to batch. Create operations: campaign-create,
adgroup-create, keyword-add, negative-add — each keyed on a natural
identifier (a name, a keyword text) and each skipping what already exists, so a
half-finished launch is safe to re-run. Expand the auto-written ledger entry by
hand when the reasoning runs past one line — that prose is the point of the
file.
Launching a campaign
campaign-create always produces a PAUSED campaign and there is no flag to
defeat that. A campaign is not launchable in one call — it needs an ad group,
keywords and (in practice always) a negative wall, because Apple's loose
matching reroutes queries across keywords even on EXACT match. Negatives go on
before the keywords, so the guards exist before anything can serve, and the
enable is a separate deliberate command:
S=scripts/apply-change.ts
bun run $S campaign-create --name="US - Foo" --budget=25 --countries=US --apply --reason="…" # PAUSED
bun run $S negative-add --campaign=<id> --terms="yourbrand,ai" --apply --reason="…"
bun run $S adgroup-create --campaign=<id> --name="Foo intent" --bid=2 --apply --reason="…"
bun run $S keyword-add --adgroup=<id> --terms="a,b,c" --apply --reason="…"
bun run $S campaign-status --id=<id> --to=ENABLED --apply --reason="…" # LAST
The negative wall between campaigns that split queries is load-bearing, not insurance. If you isolate a query in its own campaign for clean attribution (the recommended structure — one campaign per query you actually care about), each campaign must negative the other campaigns' terms IN BOTH DIRECTIONS. Apple reroutes queries across keywords; a missing negative does not fail anything, it silently re-mixes the queries and turns your per-keyword numbers into an attribution mirage. In the origin account, before that wall existed, a brand query was being served by the brand+free keyword at 2.9× the CPI of the true keyword.
Always-on testing — two standing slots
Testing loses whenever it has to re-argue for budget against a proven line. In the origin account, a designed, powered incrementality holdout was reviewed and committed with a start date — and it never ran, because the account moved the other way first and nobody noticed. The fix is structural: the account runs two permanently occupied slots, so the decision "should we be testing?" is never re-litigated, only "what is in the slot?".
| slot | what it tests | cost |
|---|---|---|
| A — Discovery | new queries; one candidate at a time, in its own campaign so attribution stays clean | fixed daily budget, ring-fenced (origin account: $25/day) |
| B — Structural | bids, caps, holdouts, custom product pages on lines already funded | $0 incremental |
Rules, in order of how often they get broken:
- Slot A's budget is never reallocated to proven spend, even when the proven line looks better. That rule exists because the argument for raiding it is always available and always persuasive.
- One test per slot, and a slot is never empty. When a test dies its
successor moves in the same day, from
backlog.md. - Price a test's downside at its first kill gate, not the full run. A staged gate makes most failures cost ~30% of the full run. Never decline a cheap test by predicting its outcome — the gate IS the prediction, and it is cheaper to run than to argue.
- Every test's result goes in
ledger.md; the candidate queue lives inbacklog.md. A candidate that gets ruled out is recorded there with why, so it is never re-proposed.
Kill gates — derive them from YOUR funnel
A new keyword gets three staged gates. Each is a checkpoint where the test dies if the number is worse than break-even; passing all three means the keyword graduates to a funded line and the slot takes the next candidate.
The gates come from your own unit economics, not from this file:
break-even CPI = proceeds_per_paid × (trial→paid rate) × (download→trial rate)
break-even cost/trial = proceeds_per_paid × (trial→paid rate)
proceeds_per_paidis what YOU receive per paying customer — net of Apple's commission and estimated tax (RevenueCat calls this "proceeds"), never the sticker price.- Choose the payback basis deliberately and write it down (year-1 proceeds? two-year? include expected renewals?). In the origin account no keyword cleared year-one break-even at then-current conversion — a year-one gate would have rejected everything including the brand term — so gates ran on a two-year basis. Whatever you pick, apply it to every gate consistently.
- Re-derive the numbers before every new test. Every term in the formula moves.
Template (assuming a $25/day Slot A budget — scale the "spent" column to yours):
| day | kill if | spent by then |
|---|---|---|
| 3 | CPI > break-even CPI | ~$75 |
| 7 | download→trial < your funnel's baseline | ~$175 |
| 14 | cost per trial > break-even cost/trial | ~$350 |
FILL IN: your derived gate values and the date you derived them.
Scale rules — when a winner gets more money
- Budget-limited vs demand-limited vs rank-limited — diagnose before raising anything. Budget-limited: raises buy proportionally more volume at unchanged CPI (safe to raise; keep raising until that stops being true). Demand-limited: the campaign under-spends its cap because the query has no more volume — a raise buys nothing, and an unfilled cap costs nothing; say "the query is spent" rather than filling the cap with traffic nobody chose. Rank-limited: impression share is low and rank is poor while spend is flat — that is a BID decision, not a budget decision (see impression share below).
- Graduation rule: a search term inside a probe or multi-term campaign that shows real volume at a CPI under your best line graduates to its own campaign with its own cap and its own negatives. That is also how you keep attribution clean enough for the ROAS grid to mean anything.
- A bid is not a price. A keyword that clears well under its bid (check CPT vs bid) means the bid moves ad rank — what share of auctions you win — not what you pay per tap. Read a bid change as a volume lever. But a capture bid still needs a ceiling you would actually accept: "it never binds" is only true while you are unopposed, and the moment a rival enters the auction, an unbounded bid pays whatever the rival decides. The origin account learned this by leaving a $5 bid on a term that cleared at $1.40 — fine for months, then a competitor arrived and it became an open cheque.
- An ad group's
defaultBidAmountshould equal the bid of the keyword that group actually runs — a price observed to clear. It applies to any keyword added without an explicit bid, and both failure directions are silent: too low and a new keyword barely serves (reading as "no demand" when the truth is "we underbid it", which retires a term instead of repricing it); too high and it reintroduces, one level up, exactly the exposure a keyword-level bid cap closed.
Keep / kill / scale — the ROAS call
You cannot compute ROAS from Apple data alone. Apple Ads reports delivery
(spend, taps, installs); it does not know whether the install converted to
revenue. You need a revenue source attributed back to the keyword — RevenueCat
attribution, an MMP (Adjust/AppsFlyer), or your own backend. The full method
lives in roas.md; the shape of it:
realized = revenue actually booked from mature cohorts (proceeds, not sticker)
PROJ = realized + (committed-but-unresolved inventory × proceeds per convert)
e.g. set-to-renew active trials — users who have NOT canceled
PROJ x = PROJ / spend in the same book
PROJ x is the call. One decision number, defined once, printed alone — never alongside competing definitions of the "real" number.
The readout is one grid over LIVE queries only:
query | spend | realized | pending trials | +PROJ | PROJ x
Traps that have each produced a wrong number at least once (details and the
full trap table in roas.md):
- A window ROAS is not a rate of return. This window's revenue ÷ this window's spend compares different people — a long-paused keyword can read 5× on residual spend against months of accumulated revenue tail while its lifetime figure is 0.5×. Quote cohort (or lifetime) whenever a window number looks surprising.
- Row unit is the QUERY, not the keyword ID and not the campaign — and you must merge on BOTH sides. Keyword IDs change when campaigns split; revenue segments key on text AND on bare IDs. Merging one side only roughly doubles (or halves) a keyword's ROAS.
- Retired entities flatter the blend. Compute blended numbers over live entities only; a paused keyword's revenue tail is spend that can never recur.
- Renewal-year revenue is a forward scenario, not a decision input, until a renewal has actually happened in your data.
Impression share — the volume question, answered directly
--report=impressionshare gives, per search term, the share of available
impressions you won and your stack-ranked position against every other
advertiser. This is the measurement people usually try to infer from spend
curves — "is a raise buying more volume?" — read directly off the auction.
Read it as a bid rule:
- Low share at a poor rank is headroom a bid can buy — but weigh it by the popularity column: winning 100% of a popularity-1 term is worth less than 10% of a popularity-5 one. The structural failure to look for: winning nearly everything on terms nobody searches and a sliver of the one that matters.
- Share near saturation means the term is spent.
≥91%is Apple's saturation bucket, not a wide estimate: above 90% it encodes low0.91/ high1.0deliberately. More bid buys nothing there. - Read RANK, not share, whenever share is small. Rank is an integer Apple states outright; share is rounded to one digit, so 1% vs 2% is a rounding boundary, not a measurement.
- A term-specific rank break is the test for "did we cause this?" Pull every term's daily rank together. No account-side change can hit one query and leave its siblings at identical rank — if one term dropped and the rest held, something changed in the AUCTION (a competitor moved), not in your config.
- A bid raise that moves CPT but not rank means you are still under the rival. Paying more per tap to stand still is not a recovery; the next raise is bidding blind into an unobservable ceiling. Price that against your capture-bid ceiling before going higher.
- Change history is the control for every share reading. Before blaming
the auction for a delivery move, run
--report=changehistoryover the same window and confirm you did not cause it yourself. It carries six months, names the actor (CUSTOMER_APIvsCUSTOMERvsAPPLE_SUPPORT), and settles "did that command actually run?" in one call. - Impression share cannot attribute. It says how much of the auction you won, never who took the rest or why. Attributing a drop needs delivery + search-term evidence on top.
Search Match — off, and why (this system's default)
This system runs exact-only: automatedKeywordsOptIn: false on every ad group,
enforced by apply-change.ts on create and warned on by inspect-account.ts.
The reasoning transfers to any account: Apple's matching is loose even on
EXACT — bare generic queries leak into specific keywords, and near-miss
spellings route to the most expensive sibling. Search Match is that same
failure with the guardrail removed: it hands Apple discretion over which
queries to buy, and it matches on APP METADATA, so a generic word in your app
title gets you served against generic category queries you never chose. When a
campaign under-spends its cap, the honest answer is usually that the query has
no more volume — an unfilled cap costs nothing, while filling it with traffic
nobody chose is how the account loses money. The only legitimate expansion is
adding specific EXACT keywords you can name and justify (the suggestions
report is a read-only source of candidates — nothing in it is bid on).
If you deliberately choose to run Search Match anyway, change the policy in
all three places (this section, apply-change.ts's adgroup-create,
inspect-account.ts's warning) so the system stops fighting you.
Report on LIVE entities only
Readouts cover what is currently running. Do not report on, tabulate, or factor in paused campaigns, paused ad groups, or paused keywords.
- Filter every table to live campaigns / ad groups / keywords (
isLive()— v1 spells itENABLED). The delivery reports happily return 30 days of spend for a keyword paused on day 3; that row is history, not a decision input. - Blended totals (CPI, ROAS) are computed over live entities. A paused keyword's revenue tail can drag a blended number well off what the live account will actually do.
- If a paused entity materially distorts a headline number, that is one sentence of caveat, not a section or a row.
- Exception: a paused entity is worth a mention when it is the subject of the question ("did pausing X work?") or when a verify step finds something live that should be paused (a config regression — always surface that).
This is about decision inputs; verification (inspect-account.ts) still
audits paused entities deliberately.
What you can and can't say
- Can: spend, impressions, taps, tap-through rate, installs / new downloads, CPI, average CPT, and per-keyword / per-search-term breakdowns — all as reported by Apple Ads for the requested window.
- Can: impression share and competitive rank per search term, Apple's own search-term popularity, and the config-change audit log. These describe the AUCTION, not your spend.
- Can't from Apple data alone: paid CPA, ROAS, trial→paid conversion, or
campaign→revenue profitability. Apple Ads delivery does not tell you whether
the spend converted. Say so plainly rather than estimating — then run the
roas.mdmethod against your revenue source.
Apple Ads Platform API v1 — traps
The scripts target the Platform API v1 (Campaign Management v5 is sunset 2027-01-26). The traps that fail silently rather than loudly, worst first:
ACTIVEno longer exists; live isENABLED. A leftoverstatus === 'ACTIVE'test throws nothing and matches nothing, so it reports an account with no live keywords and no negative wall. UseisLive()fromapi-client.ts.- Campaign-level negatives need
adGroupId IS_NULL. A barecampaignIdfilter is rejected outright, butadGroupId EQUALS 0— the value change history reports for these rows — returns[]rather than an error, which looks exactly like "this campaign has no negatives". UsequeryCampaignNegatives(). - Base URL
https://api.ads.apple.com/v1; the context header carriesadAccountId=, notorgId=. Orgs and ad accounts are distinct concepts that often share a numeric value —GET /aclslists the real accounts. - Reads are
POST /querywithfilters/sorting/pagination, and paths are flat: ad groups come account-wide in one call.campaignIdaccepts EQUALS only (API users are refusedIN), so keyword and search-term reports loop per campaign. - The envelope is
{ result, pagination, error }, whereresultis a bare array on entity queries and{ rows, summary }on reports. - Rate limits are published per response and differ by family: reports and
entity queries allow 200 per window, insights allow 5.
api-client.tspaces off the headers and honoursRetry-After; callers need do nothing. - Report dates are the ACCOUNT's calendar days, not the machine's. Delivery
reports send bare
YYYY-MM-DDwithtimeZone: 'ORTZ';fetch-ads.tsresolves the account timezone before building any window. The insights endpoints are UTC-fixed by Apple and get their own window — which is why an impression-share header can name a different date range than a delivery report run in the same second. - SEARCHTERM reports accept
GRAND_TOTALor granularity, never both — asking for both 400s withINCOMPATIBLE_VALUES_FIELDS.fetch-ads.tsdropsGRAND_TOTALthere.
Current account state — FILL IN AND MAINTAIN
Keep this section current by hand. It is the map a fresh session reads before touching anything, and
inspect-account.tsoutput is diffed against it. Update it whenever a change lands; date every update.
Status date: (FILL IN — verify live via --report=campaigns and
inspect-account.ts)
- Campaign: (name, id, status, daily budget, storefronts, ad groups, live keywords with bids, negatives — one bullet per live campaign)
- Standing facts about the account's shape: (budget-limited or demand-limited? what clears under its bid? which terms are saturated?)
- (campaign, why it was paused, what its result was, whether its structure is preserved deliberately)
Response order
- State the date window — and that live-cohort columns are a snapshot, not a window, when the readout includes them.
- Give spend, installs / new downloads, CPI, and tap CPI — live campaigns only.
- Break down keywords / search terms if asked — live keywords only.
- If asked about profitability / CPA / ROAS, run the mature-cohort method in
roas.md(realized + PROJ) against your revenue source; never estimate revenue from Apple delivery data alone.
For anything broader than a delivery question — "how are ads doing", a readout, a budget decision — build the per-query ROAS grid rather than answering column by column. The merged view is what makes the account legible. Lead with what changed and what to do; put the caveats after the grid, not instead of it.
References
- Apple Ads attribution overview: https://ads.apple.com/app-store/help/attribution/0094-ad-attribution-overview
- Apple Ads API documentation: https://developer.apple.com/documentation/apple_search_ads