// Apple Ads (formerly Apple Search Ads), pure parts: the OAuth client secret (ES256 JWT), request bodies, report row // flattening, CPA math, the text table and CSV. No IO; the network side is cli/ads/client.ts, the command cli/ads/apple.ts. // // storebound targets the Apple Ads Platform API (https://api.ads.apple.com/v1, released August 2026). It supersedes the // Apple Ads Campaign Management API v5 (api.searchads.apple.com/api/v5), which Apple sunsets on January 26, 2027. // Same OAuth credentials for both; the Platform API scopes calls by ad account (X-AP-Context: adAccountId=) // instead of org (orgId=), queries with POST .../query, and wraps every payload in `result`. // // Sources (checked October 2026): // https://developer.apple.com/documentation/apple-ads-platform-api/implementing-oauth-for-the-apple-ads-platform-api // client secret: header alg ES256 + kid = keyId; claims sub = clientId, iss = teamId, aud https://appleid.apple.com, // iat, exp (> now, < iat + 180 days). Token: POST https://appleid.apple.com/auth/oauth2/token, // application/x-www-form-urlencoded grant_type=client_credentials, client_id, client_secret, scope=searchadsorg; // answer { access_token, token_type: Bearer, expires_in: 3600 }. // https://developer.apple.com/documentation/apple-ads-platform-api/calling-apple-ads-platform-api (headers, QueryRequest, // `result` + `pagination` envelope, error codes) // https://developer.apple.com/documentation/apple-ads-platform-api/access-overview (GET /v1/acls, GET /v1/ad-accounts/{id}) // https://developer.apple.com/documentation/apple-ads-platform-api/useracl (roles) // https://developer.apple.com/documentation/apple-ads-platform-api/campaign, .../campaignupdate, .../dailybudgetupdate, // .../money (amounts are decimal strings, currency = the ad account's), .../keyword, .../keywordupdate, .../adgroupupdate // https://developer.apple.com/documentation/apple-ads-platform-api/reports, .../appsreportingrequest, .../timerange, // .../appsoptions, .../appsmetrics (localSpend, impressions, taps, ttr, tapInstalls, viewInstalls, totalInstalls, // totalNewDownloads, totalRedownloads), .../get-app-campaign-reports, .../get-app-keyword-reports, .../reportingkeyword // https://developer.apple.com/documentation/apple-ads-platform-api/rate-limits (429 + Retry-After) // https://developer.apple.com/documentation/apple_ads/implementing-oauth-for-the-apple-search-ads-api (v5, deprecation note) import { createPrivateKey, sign, type KeyObject } from 'node:crypto'; import { toCsv } from './csv'; import { pct, textTable } from './table'; export const ADS_API = 'https://api.ads.apple.com/v1'; export const TOKEN_URL = 'https://appleid.apple.com/auth/oauth2/token'; export const ADS_SCOPE = 'searchadsorg'; export const SECRET_AUDIENCE = 'https://appleid.apple.com'; /** Apple's limit for a client secret's lifetime. storebound signs a fresh one per run with a much shorter one. */ export const MAX_SECRET_TTL = 180 * 86400; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type J = any; export interface AdsCreds { clientId: string; teamId: string; keyId: string; pem: string; accountId?: string } const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url'); /** The client secret: an ES256 JWT signed with the private key whose public half is uploaded in Apple Ads * (Account Settings > API). `ttl` seconds, at most 180 days. */ export function clientSecret(c: Pick, key: KeyObject | string, now = Math.floor(Date.now() / 1000), ttl = 86400): string { if (!(ttl > 0 && ttl <= MAX_SECRET_TTL)) throw new Error('client secret lifetime must be 1 s .. 180 days'); const k = typeof key === 'string' ? createPrivateKey(key) : key; const h = b64(JSON.stringify({ alg: 'ES256', kid: c.keyId })); const p = b64(JSON.stringify({ sub: c.clientId, iss: c.teamId, aud: SECRET_AUDIENCE, iat: now, exp: now + ttl })); return `${h}.${p}.${b64(sign('sha256', Buffer.from(`${h}.${p}`), { key: k, dsaEncoding: 'ieee-p1363' }))}`; } /** Form body of the token request (client credentials grant). */ export const tokenForm = (clientId: string, secret: string): URLSearchParams => new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: secret, scope: ADS_SCOPE }); /** Headers of an API call. GET /v1/acls, /v1/me and /v1/orgs/{id} work without the ad account context. */ export function adsHeaders(token: string, accountId?: string): Record { const h: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' }; if (accountId) h['X-AP-Context'] = `adAccountId=${accountId}`; return h; } // --------------------------------------------------------------------------------------------------------------------- // Accounts and campaigns export interface AclRow { accountId: string; name: string; orgId: string; roles: string[]; used: boolean } /** GET /v1/acls -> rows; `used` marks the configured APPLE_ADS_ACCOUNT_ID. */ export function aclRows(res: J, accountId?: string): AclRow[] { const acls: J[] = res?.result?.acls ?? []; return acls.map(a => ({ accountId: String(a.adAccount?.id ?? ''), name: String(a.adAccount?.name ?? ''), orgId: String(a.adAccount?.orgId ?? ''), roles: (a.roles ?? []).map(String), used: !!accountId && String(a.adAccount?.id) === accountId })); } /** Roles that may change bids, budgets and status (useracl roles reference). */ export const canWrite = (roles: string[]): boolean => roles.some(r => r === 'Admin' || r === 'API Account Manager' || r === 'Limited Access: API Read & Write'); export interface CampaignRow { id: string; name: string; status: string; displayStatus: string; systemStatus: string; dailyBudget: string; currency: string; countries: string[]; adamId: string; bidStrategy: string; thisApp: boolean; } export function campaignRow(c: J, appId?: string): CampaignRow { const adamId = c.promotedObjectType === 'APPSTORE_APP' || c.promotedObjectType === undefined ? String(c.promotedObjectId ?? '') : ''; return { id: String(c.id), name: String(c.name ?? ''), status: String(c.status ?? ''), displayStatus: String(c.displayStatus ?? ''), systemStatus: String(c.systemStatus ?? ''), dailyBudget: String(c.dailyBudget?.value?.amount ?? ''), currency: String(c.dailyBudget?.value?.currency ?? ''), countries: (c.targeting?.countryOrRegion?.include ?? []).map(String), adamId, bidStrategy: String(c.bidStrategy?.bidStrategyType ?? ''), thisApp: !!appId && adamId === String(appId), }; } /** POST /v1/campaigns/query body: App Store campaigns only, by id, one page (the endpoint caps pageSize at 1000). */ export const campaignQuery = (offset = 0, pageSize = 1000): J => ({ filters: [{ field: 'promotedObjectType', operator: 'EQUALS', value: 'APPSTORE_APP' }], sorting: [{ field: 'id', order: 'ASC' }], pagination: { offset, pageSize, fetchTotalCount: true }, }); // --------------------------------------------------------------------------------------------------------------------- // Reports const DAY = /^\d{4}-\d{2}-\d{2}$/; const iso = (d: Date) => d.toISOString().slice(0, 10); /** --days N (ending yesterday, UTC: today is still incomplete) or --start / --end (inclusive). */ export function reportWindow(o: { days?: number; start?: string; end?: string }, today = new Date()): { start: string; end: string } { if (o.start || o.end) { if (!o.start || !o.end || !DAY.test(o.start) || !DAY.test(o.end)) throw new Error('--start and --end: both, as YYYY-MM-DD'); if (o.start > o.end) throw new Error('--start is after --end'); return { start: o.start, end: o.end }; } const days = o.days ?? 7; if (!Number.isInteger(days) || days < 1 || days > 365) throw new Error('--days: 1..365'); const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1)); const start = new Date(end.getTime() - (days - 1) * 86400_000); return { start: iso(start), end: iso(end) }; } /** * AppsReportingRequest for POST /v1/reports/apps/{campaigns|keywords}/query. Every apps report needs a campaignId * filter (scalar EQUALS, as in Apple's payload examples); keywords can be narrowed by adGroupId. No granularity: one * total per row over the window. UTC, sorted by spend. */ export function reportBody(o: { start: string; end: string; campaignId: string; adGroupId?: string; offset?: number; pageSize?: number }): J { const filters: J[] = [{ field: 'campaignId', operator: 'EQUALS', value: String(o.campaignId) }]; if (o.adGroupId) filters.push({ field: 'adGroupId', operator: 'EQUALS', value: String(o.adGroupId) }); return { pagination: { offset: o.offset ?? 0, pageSize: o.pageSize ?? 1000 }, sorting: [{ field: 'localSpend', order: 'DESC' }], filters, timeRange: { start: o.start, end: o.end, timeZone: 'UTC' }, options: { includeRows: ['GRAND_TOTAL'] }, }; } export interface ReportRow { level: 'campaign' | 'keyword'; campaignId: string; campaign: string; adGroupId: string; adGroup: string; keywordId: string; keyword: string; matchType: string; currency: string; spend: number; impressions: number; taps: number; installs: number; tapInstalls: number; viewInstalls: number; newDownloads: number; redownloads: number; /** taps / impressions */ ttr: number | null; /** installs / taps */ conversionRate: number | null; /** spend / installs; null with 0 installs */ cpa: number | null; /** spend / taps */ avgCpt: number | null; } const num = (x: unknown): number => { const n = Number(x ?? 0); return Number.isFinite(n) ? n : 0; }; const ratio = (a: number, b: number): number | null => (b > 0 ? a / b : null); const cents = (x: number): number => Math.round(x * 100) / 100; /** Derived metrics from the counts (the same definitions Apple documents for ttr / totalInstallRate). */ export function derive(r: T): T & Pick { return { ...r, ttr: ratio(r.taps, r.impressions), conversionRate: ratio(r.installs, r.taps), cpa: ratio(r.spend, r.installs), avgCpt: ratio(r.spend, r.taps) }; } function metrics(m: J): Pick { const tapInstalls = num(m?.tapInstalls), viewInstalls = num(m?.viewInstalls); return { currency: String(m?.localSpend?.currency ?? ''), spend: cents(num(m?.localSpend?.amount)), impressions: num(m?.impressions), taps: num(m?.taps), // totalInstalls = tap + view installs; older rows may only carry the two parts installs: m?.totalInstalls !== undefined ? num(m.totalInstalls) : tapInstalls + viewInstalls, tapInstalls, viewInstalls, newDownloads: num(m?.totalNewDownloads), redownloads: num(m?.totalRedownloads), }; } /** Report response rows -> flat rows. `campaignNames` fills the campaign name of keyword rows. */ export function flattenRows(level: ReportRow['level'], res: J, campaignNames: Map = new Map()): ReportRow[] { const rows: J[] = res?.result?.rows ?? []; return rows.map(r => { const md = r.metadata ?? {}; const campaignId = String(level === 'campaign' ? md.id ?? '' : md.campaignId ?? ''); return derive({ level, campaignId, campaign: level === 'campaign' ? String(md.name ?? '') : campaignNames.get(campaignId) ?? '', adGroupId: level === 'keyword' ? String(md.adGroupId ?? '') : '', adGroup: level === 'keyword' ? String(md.adGroup?.name ?? '') : '', keywordId: level === 'keyword' ? String(md.id ?? '') : '', keyword: level === 'keyword' ? String(md.text ?? '') : '', matchType: level === 'keyword' ? String(md.matchType ?? '') : '', ...metrics(r.totalMetrics), }); }); } /** Offset of the next page, or null on the last one (offset paging). */ export const nextOffset = (res: J): number | null => { const p = res?.pagination; if (!p || typeof p.totalCount !== 'number') return null; const next = num(p.offset) + num(p.pageSize); return next < p.totalCount && num(p.pageSize) > 0 ? next : null; }; export type ReportTotals = Omit; export function totals(rows: ReportRow[]): ReportTotals { const cur = [...new Set(rows.map(r => r.currency).filter(Boolean))]; const sum = (k: 'spend' | 'impressions' | 'taps' | 'installs' | 'tapInstalls' | 'viewInstalls' | 'newDownloads' | 'redownloads') => rows.reduce((a, r) => a + r[k], 0); return derive({ currency: cur.length === 1 ? cur[0]! : cur.length ? 'mixed' : '', spend: cents(sum('spend')), impressions: sum('impressions'), taps: sum('taps'), installs: sum('installs'), tapInstalls: sum('tapInstalls'), viewInstalls: sum('viewInstalls'), newDownloads: sum('newDownloads'), redownloads: sum('redownloads'), }); } const money = (x: number | null, cur = ''): string => (x === null ? 'n/a' : `${x.toFixed(2)}${cur ? ' ' + cur : ''}`); export function reportTable(rows: ReportRow[], t: ReportTotals): string { const kw = rows.some(r => r.level === 'keyword'); const head = [...(kw ? ['keyword', 'match', 'ad group'] : []), 'campaign', 'spend', 'impr', 'taps', 'installs', 'TTR', 'CR', 'CPA', 'avg CPT']; const line = (r: ReportRow | ReportTotals, lead: string[]) => [...lead, money(r.spend, r.currency), r.impressions, r.taps, r.installs, pct(r.ttr), pct(r.conversionRate), money(r.cpa), money(r.avgCpt)]; return textTable(head, [ ...rows.map(r => line(r, [...(kw ? [r.keyword, r.matchType, r.adGroup || r.adGroupId] : []), r.campaign || r.campaignId])), line(t, [...(kw ? ['', '', ''] : []), 'TOTAL']), ]); } export const CSV_HEAD = ['level', 'campaign_id', 'campaign', 'ad_group_id', 'ad_group', 'keyword_id', 'keyword', 'match_type', 'currency', 'spend', 'impressions', 'taps', 'installs', 'tap_installs', 'view_installs', 'new_downloads', 'redownloads', 'ttr', 'conversion_rate', 'cpa', 'avg_cpt']; const dec = (x: number | null, d: number): string => (x === null ? '' : x.toFixed(d)); /** One CSV line per row (empty cells for "no value": CPA with 0 installs). Rates as fractions (0.0123). */ export function reportCsv(rows: ReportRow[]): string { return toCsv([CSV_HEAD, ...rows.map(r => [r.level, r.campaignId, r.campaign, r.adGroupId, r.adGroup, r.keywordId, r.keyword, r.matchType, r.currency, r.spend.toFixed(2), r.impressions, r.taps, r.installs, r.tapInstalls, r.viewInstalls, r.newDownloads, r.redownloads, dec(r.ttr, 6), dec(r.conversionRate, 6), dec(r.cpa, 2), dec(r.avgCpt, 2)])]); } // --------------------------------------------------------------------------------------------------------------------- // Writes (bid, budget, pause): the bodies and the "old -> new" line. The command reads first, prints, asks. /** An amount as the API wants it: a positive decimal string, no leading zeros, at most 2 decimals ("5.00", not "05.00"). */ export function parseAmount(v: string | undefined, what: string): string { const s = (v ?? '').trim(); if (!/^(0|[1-9]\d*)(\.\d{1,2})?$/.test(s) || !(Number(s) > 0)) throw new Error(`${what}: a positive amount like 1.25 (account currency, max 2 decimals)`); return s; } /** "1.50 -> 2.00 USD (+33%)" */ export function changeLine(oldAmount: string | null, newAmount: string, currency: string): string { if (oldAmount === null) return `(default) -> ${newAmount} ${currency}`; const o = Number(oldAmount), n = Number(newAmount); const d = o > 0 ? ` (${n >= o ? '+' : ''}${Math.round(((n - o) / o) * 100)}%)` : ''; return `${oldAmount} -> ${newAmount} ${currency}${d}`; } export const keywordBidBody = (amount: string, currency: string): J => ({ bid: { amount, currency } }); export const dailyBudgetBody = (amount: string, currency: string): J => ({ dailyBudget: { value: { amount, currency } } }); export const pauseBody = (): J => ({ status: 'PAUSED' });