// Apple Ads (formerly Apple Search Ads) for the app's App Store campaigns. // storebound ads apple status [--json] token works?, ad accounts and roles (GET /v1/acls), the one used // storebound ads apple campaigns [--all] [--json] App Store campaigns: status, daily budget, countries, adamId // storebound ads apple report [--days 7 | --start YYYY-MM-DD --end YYYY-MM-DD] [--by campaign|keyword] [--campaign ] // [--adgroup ] [--all] [--csv ] [--dry] [--json] // spend, impressions, taps, installs, TTR, conversion rate, CPA (spend / installs), avg CPT; totals. Read-only. // storebound ads apple bid --keyword --bid [--campaign ] [--adgroup ] [--dry] [--yes] // storebound ads apple budget --campaign --daily [--dry] [--yes] // storebound ads apple pause --campaign [--adgroup ] [--dry] [--yes] // Writes read the current value first, print "old -> new", and only without --dry ask in the terminal (confirm) before // the PUT. They are the owner's decisions (money): never offered over MCP, refused under `storebound serve`. // Report and campaign lists default to the campaigns of config apple.appId (when set); --all shows every app's. // Credentials and API: cli/ads/client.ts. API: Apple Ads Platform API (https://api.ads.apple.com/v1), which replaces // the Campaign Management API v5 (sunset January 26, 2027). // Sources (checked October 2026): the doc URLs in cli/lib/apple-ads.ts, plus // https://developer.apple.com/documentation/apple-ads-platform-api/post-campaigns-query (pageSize cap 1000) // https://developer.apple.com/documentation/apple-ads-platform-api/put-campaigns-_id_ (status / dailyBudget examples) // https://developer.apple.com/documentation/apple-ads-platform-api/put-keywords-_id_ (bid example; bid null is an error) // https://developer.apple.com/documentation/apple-ads-platform-api/put-adgroups-_id_ (status) import { writeFileSync } from 'node:fs'; import { type AclRow, aclRows, campaignQuery, campaignRow, type CampaignRow, canWrite, changeLine, dailyBudgetBody, flattenRows, type J, keywordBidBody, nextOffset, parseAmount, pauseBody, reportBody, reportCsv, type ReportRow, reportTable, reportWindow, totals } from '../lib/apple-ads'; import { confirm, type Ctx } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { textTable } from '../lib/table'; import { adsClient, type Ads } from './client'; const TAG = 'ads apple'; const SUBS = ['status', 'campaigns', 'report', 'bid', 'budget', 'pause']; const idArg = (ctx: Ctx, name: string, required: boolean): string | undefined => { const v = ctx.args.get(name); if (v === undefined || v === 'true') { if (required) throw new Error(`--${name} is required`); return undefined; } if (!/^\d+$/.test(v)) throw new Error(`--${name}: a numeric id`); return v; }; async function allCampaigns(ads: Ads): Promise { const out: J[] = []; for (let offset: number | null = 0; offset !== null;) { const page: J = await ads.post('/campaigns/query', campaignQuery(offset)); out.push(...(page.result ?? [])); offset = (page.result ?? []).length ? nextOffset(page) : null; } return out; } /** The campaigns a report / list covers: one by id, else the app's (config apple.appId) unless --all or none match. */ async function pickCampaigns(ctx: Ctx, ads: Ads, quiet: boolean): Promise { const id = idArg(ctx, 'campaign', false); if (id) return [(await ads.get(`/campaigns/${id}`)).result]; const all = await allCampaigns(ads); const appId = ctx.loaded.config.apple?.appId; if (!appId || ctx.args.has('all')) return all; const mine = all.filter(c => String(c.promotedObjectId) === String(appId)); if (!mine.length && all.length && !quiet) warn(TAG, `no campaign promotes apple.appId ${appId}: showing all ${all.length}`); return mine.length ? mine : all; } async function status(ctx: Ctx, ads: Ads, json: boolean): Promise { await ads.token(); const accounts: AclRow[] = aclRows(await ads.get('/acls', false), ads.accountId); const used = accounts.find(a => a.used) ?? null; const account = ads.accountId ? (await ads.get(`/ad-accounts/${ads.accountId}`)).result ?? null : null; const out = { token: true, accountId: ads.accountId ?? null, accounts, account: account && { id: String(account.id), name: account.name, currency: account.currency, timezone: account.timezone, systemStatus: account.systemStatus, systemStatusReasons: account.systemStatusReasons ?? [], productFeatures: account.productFeatures ?? [] }, canWrite: used ? canWrite(used.roles) : false, }; if (json) return printJson(out); log(TAG, 'token: ok (OAuth client credentials)'); if (!accounts.length) warn(TAG, 'no ad accounts for this API user (Apple Ads > Account Settings > User Management: give it an API role on an account)'); else console.log(textTable(['', 'ad account', 'id', 'org', 'roles'], accounts.map(a => [a.used ? '*' : '', a.name, a.accountId, a.orgId, a.roles.join(', ')]))); if (!ads.accountId) { log(TAG, 'APPLE_ADS_ACCOUNT_ID is not set: put one of the ids above in the env or .storebound/credentials.json'); return; } if (!used) warn(TAG, `APPLE_ADS_ACCOUNT_ID ${ads.accountId} is not in the list above: this API user has no role on it`); if (out.account) { const a = out.account; log(TAG, `using ${a.name} (${a.id}): ${a.currency}, ${a.timezone}, ${a.systemStatus}${a.systemStatusReasons.length ? ' ' + a.systemStatusReasons.join(', ') : ''}, ${a.productFeatures.join(', ') || 'no product features'}`); if (!a.productFeatures.includes('APPSTORE_APP_MANUAL')) warn(TAG, 'this ad account is not an App Store account (APPSTORE_APP_MANUAL)'); } log(TAG, out.canWrite ? 'role allows bid / budget / pause changes (each one still asks first)' : 'read-only role: reports and lists only'); } async function campaigns(ctx: Ctx, ads: Ads, json: boolean): Promise { const appId = ctx.loaded.config.apple?.appId; const rows: CampaignRow[] = (await pickCampaigns(ctx, ads, json)).map(c => campaignRow(c, appId)); if (json) return printJson({ accountId: ads.accountId, appId: appId ?? null, campaigns: rows }); if (!rows.length) return log(TAG, 'no App Store campaigns in this ad account'); console.log(textTable(['', 'id', 'campaign', 'status', 'display', 'daily budget', 'bidding', 'countries', 'adamId'], rows.map(r => [r.thisApp ? '*' : '', r.id, r.name, r.status, r.displayStatus, r.dailyBudget ? `${r.dailyBudget} ${r.currency}` : '', r.bidStrategy, r.countries.join(' '), r.adamId]))); if (appId) log(TAG, `* = promotes apple.appId ${appId}`); } async function report(ctx: Ctx, ads: Ads, json: boolean): Promise { const by = ctx.args.str('by', 'campaign'); if (by !== 'campaign' && by !== 'keyword') throw new Error('--by campaign | keyword'); const w = reportWindow({ days: ctx.args.has('days') ? ctx.args.num('days', 7) : undefined, start: ctx.args.get('start'), end: ctx.args.get('end') }); const adGroupId = idArg(ctx, 'adgroup', false); const camps = await pickCampaigns(ctx, ads, json); const names = new Map(camps.map(c => [String(c.id), String(c.name ?? '')] as const)); const rows: ReportRow[] = []; for (const c of camps) { for (let offset: number | null = 0; offset !== null;) { const res: J = await ads.post(`/reports/apps/${by === 'campaign' ? 'campaigns' : 'keywords'}/query`, reportBody({ ...w, campaignId: String(c.id), adGroupId: by === 'keyword' ? adGroupId : undefined, offset })); const got = flattenRows(by, res, names); rows.push(...got); offset = got.length ? nextOffset(res) : null; } } rows.sort((a, b) => b.spend - a.spend); const t = totals(rows); const csv = ctx.args.get('csv'); if (csv === 'true') throw new Error('--csv '); if (csv) { const path = ctx.loaded.resolve(csv); if (ctx.args.has('dry')) { if (!json) log(TAG, `dry run: would write ${rows.length} row(s) to ${path}`); } else { writeFileSync(path, reportCsv(rows)); if (!json) log(TAG, `wrote ${rows.length} row(s) to ${path}`); } } if (json) return printJson({ accountId: ads.accountId, by, start: w.start, end: w.end, timeZone: 'UTC', campaigns: camps.length, rows, totals: t }); log(TAG, `${by} report ${w.start} .. ${w.end} (UTC), ${camps.length} campaign(s), ${rows.length} row(s) with activity`); if (!rows.length) return log(TAG, 'no spend, impressions or taps in this window'); console.log(reportTable(rows, t)); if (t.installs === 0 && t.spend > 0) log(TAG, 'CPA n/a: no installs in this window'); } interface Plan { action: 'bid' | 'budget' | 'pause'; target: string; path: string; body: J; old: string | null; new: string; currency: string; applied: boolean } async function apply(ctx: Ctx, ads: Ads, json: boolean, p: Plan, unchanged: boolean): Promise { const say = (m: string) => { if (!json) log(TAG, m); }; say(`${p.action}: ${p.target}: ${p.action === 'pause' ? `${p.old} -> ${p.new}` : changeLine(p.old, p.new, p.currency)}`); if (unchanged) { say('already at that value: nothing to change'); return json ? printJson(p) : undefined; } if (ctx.args.has('dry')) { say(`dry run: would PUT ${p.path} ${JSON.stringify(p.body)}`); return json ? printJson(p) : undefined; } if (!(await confirm(ctx, `Apply this ${p.action} change in Apple Ads (${p.target})?`))) { say('not changed'); return json ? printJson(p) : undefined; } await ads.put(p.path, p.body); p.applied = true; if (json) return printJson(p); done(TAG, `${p.action} applied`); } async function bid(ctx: Ctx, ads: Ads, json: boolean): Promise { const kid = idArg(ctx, 'keyword', true)!; const amount = parseAmount(ctx.args.get('bid'), '--bid'); const kw = (await ads.get(`/keywords/${kid}`)).result; if (!kw || kw.deleted) throw new Error(`keyword ${kid} not found or deleted`); const camp = idArg(ctx, 'campaign', false), group = idArg(ctx, 'adgroup', false); if (camp && String(kw.campaignId) !== camp) throw new Error(`keyword ${kid} belongs to campaign ${kw.campaignId}, not ${camp}`); if (group && String(kw.adGroupId) !== group) throw new Error(`keyword ${kid} belongs to ad group ${kw.adGroupId}, not ${group}`); const c = (await ads.get(`/campaigns/${kw.campaignId}`)).result; if (c?.bidStrategy?.bidStrategyType === 'MAX_CONVERSIONS') throw new Error(`campaign ${kw.campaignId} uses Maximize Conversions: keyword bids are not used there`); const currency = String(kw.bid?.currency ?? c?.dailyBudget?.value?.currency ?? ''); if (!currency) throw new Error('could not tell the account currency from the keyword or its campaign'); const old = kw.bid?.amount != null ? String(kw.bid.amount) : null; await apply(ctx, ads, json, { action: 'bid', target: `keyword "${kw.text}" (${kw.matchType}, ${kid}) in "${c?.name ?? kw.campaignId}"`, path: `/keywords/${kid}`, body: keywordBidBody(amount, currency), old, new: amount, currency, applied: false }, old !== null && Number(old) === Number(amount)); } async function budget(ctx: Ctx, ads: Ads, json: boolean): Promise { const id = idArg(ctx, 'campaign', true)!; const amount = parseAmount(ctx.args.get('daily'), '--daily'); const c = (await ads.get(`/campaigns/${id}`)).result; if (!c || c.deleted) throw new Error(`campaign ${id} not found or deleted`); const currency = String(c.dailyBudget?.value?.currency ?? ''); if (!currency) throw new Error(`campaign ${id} has no daily budget currency`); const old = c.dailyBudget?.value?.amount != null ? String(c.dailyBudget.value.amount) : null; await apply(ctx, ads, json, { action: 'budget', target: `daily budget of "${c.name}" (${id})`, path: `/campaigns/${id}`, body: dailyBudgetBody(amount, currency), old, new: amount, currency, applied: false }, old !== null && Number(old) === Number(amount)); } async function pause(ctx: Ctx, ads: Ads, json: boolean): Promise { const id = idArg(ctx, 'campaign', true)!; const group = idArg(ctx, 'adgroup', false); const c = (await ads.get(`/campaigns/${id}`)).result; if (!c || c.deleted) throw new Error(`campaign ${id} not found or deleted`); let target = `campaign "${c.name}" (${id})`, path = `/campaigns/${id}`, old = String(c.status ?? ''); if (group) { const g = (await ads.get(`/adgroups/${group}`)).result; if (!g || g.deleted) throw new Error(`ad group ${group} not found or deleted`); if (String(g.campaignId) !== id) throw new Error(`ad group ${group} belongs to campaign ${g.campaignId}, not ${id}`); target = `ad group "${g.name}" (${group}) in "${c.name}"`; path = `/adgroups/${group}`; old = String(g.status ?? ''); } await apply(ctx, ads, json, { action: 'pause', target, path, body: pauseBody(), old, new: 'PAUSED', currency: '', applied: false }, old === 'PAUSED'); } export async function run(ctx: Ctx): Promise { const sub = ctx.args._[0]; const json = ctx.args.has('json'); if (!sub || ctx.args.has('help') || !SUBS.includes(sub)) { console.log(`usage: storebound ads apple ${SUBS.join(' | ')} (see the apple-ads skill)`); if (sub && !SUBS.includes(sub) && !ctx.args.has('help')) process.exitCode = 2; return; } const ads = adsClient(ctx.loaded.root); switch (sub) { case 'status': return status(ctx, ads, json); case 'campaigns': return campaigns(ctx, ads, json); case 'report': return report(ctx, ads, json); case 'bid': return bid(ctx, ads, json); case 'budget': return budget(ctx, ads, json); case 'pause': return pause(ctx, ads, json); } }