| name | apple-ads |
| description | Run an Apple Ads (Apple Search Ads) account as a system — live delivery and market-position reads, one audited write path with a change ledger, always-on keyword testing with staged kill gates, and a mature-cohort ROAS method for keep/kill/scale calls. Use when asked how Apple Ads are delivering, to change bids/budgets/keywords, to launch a campaign, or to decide whether a keyword should be kept, killed, or scaled. |
| license | MIT |
| metadata | {"author":"safaiyeh","version":"1.0.0"} |
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:
bun run scripts/fetch-ads.ts --report=campaigns --days=30 --output=json
bun run scripts/fetch-ads.ts --report=keywords --days=7 --output=json
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:
bun run scripts/fetch-ads.ts --report=impressionshare --days=7 --country=US
bun run scripts/fetch-ads.ts --report=popularity --terms=yourbrand,competitor
bun run scripts/fetch-ads.ts --report=suggestions --country=US
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).
bun run scripts/apply-change.ts keyword-bid --id=<keywordId> --to=1.75
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."
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="…"
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="…"
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 in
backlog.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_paid is 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
defaultBidAmount should 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 low 0.91 /
high 1.0 deliberately. 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=changehistory over the same
window and confirm you did not cause it yourself. It carries six months,
names the actor (CUSTOMER_API vs CUSTOMER vs APPLE_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 it ENABLED). 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.md method 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:
ACTIVE no longer exists; live is ENABLED. A leftover
status === 'ACTIVE' test throws nothing and matches nothing, so it reports
an account with no live keywords and no negative wall. Use isLive() from
api-client.ts.
- Campaign-level negatives need
adGroupId IS_NULL. A bare campaignId
filter is rejected outright, but adGroupId EQUALS 0 — the value change
history reports for these rows — returns [] rather than an error, which
looks exactly like "this campaign has no negatives". Use
queryCampaignNegatives().
- Base URL
https://api.ads.apple.com/v1; the context header carries
adAccountId=, not orgId=. Orgs and ad accounts are distinct concepts
that often share a numeric value — GET /acls lists the real accounts.
- Reads are
POST /query with filters/sorting/pagination, and paths are
flat: ad groups come account-wide in one call. campaignId accepts EQUALS
only (API users are refused IN), so keyword and search-term reports loop
per campaign.
- The envelope is
{ result, pagination, error }, where result is 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.ts
paces off the headers and honours Retry-After; callers need do nothing.
- Report dates are the ACCOUNT's calendar days, not the machine's. Delivery
reports send bare
YYYY-MM-DD with timeZone: 'ORTZ'; fetch-ads.ts
resolves 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_TOTAL or granularity, never both —
asking for both 400s with INCOMPATIBLE_VALUES_FIELDS. fetch-ads.ts drops
there.
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.ts output 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?)
Paused — do not report on these; kept so they are not re-created or accidentally revived
- (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