// Apple Ads Platform API client: OAuth client credentials (an ES256 client secret signed with the key uploaded in // Apple Ads > Account Settings > API), Bearer calls with X-AP-Context: adAccountId=, retries on 429 / 5xx. // The access token lives in memory for this process only (never written to disk, never printed). // Credentials (env, else .storebound/credentials.json): APPLE_ADS_CLIENT_ID, APPLE_ADS_TEAM_ID, APPLE_ADS_KEY_ID, // APPLE_ADS_PRIVATE_KEY (path to the PEM, or the PEM itself), APPLE_ADS_ACCOUNT_ID (optional for `status`). // Sources (checked October 2026): see cli/lib/apple-ads.ts; retries per // https://developer.apple.com/documentation/apple-ads-platform-api/rate-limits (Retry-After on 429). import { createPrivateKey, type KeyObject } from 'node:crypto'; import { credentialValue, inline } from '../lib/credentials'; import { ADS_API, adsHeaders, type AdsCreds, clientSecret, type J, TOKEN_URL, tokenForm } from '../lib/apple-ads'; import { errorBody } from '../lib/http'; export const CRED_NAMES = ['APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID', 'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY'] as const; export function adsCreds(root: string): AdsCreds { // the template's placeholders (templates/credentials.example.json, copied by `storebound init`) count as not set const v = (n: string) => { const x = credentialValue(root, n); return x && !/00000000-0000-0000|\/absolute\/path\/to\//.test(x) ? x : undefined; }; const missing = CRED_NAMES.filter(n => !v(n)); if (missing.length) throw new Error(`${missing.join(', ')} not set (env, or .storebound/credentials.json); see the apple-ads skill`); let pem: string; try { pem = inline(v('APPLE_ADS_PRIVATE_KEY')!, /BEGIN (EC )?PRIVATE KEY/); } catch { throw new Error('APPLE_ADS_PRIVATE_KEY: the private key file could not be read (set a path to the PEM, or the PEM itself)'); } return { clientId: v('APPLE_ADS_CLIENT_ID')!, teamId: v('APPLE_ADS_TEAM_ID')!, keyId: v('APPLE_ADS_KEY_ID')!, pem, accountId: v('APPLE_ADS_ACCOUNT_ID') }; } /** Seconds to wait before retry `attempt` (1-based): Retry-After when the server sends it, else 2, 4, 8, 16. */ export const retryWait = (retryAfter: string | null, attempt: number): number => { const s = Number(retryAfter); return Number.isFinite(s) && s >= 0 && retryAfter !== null && retryAfter !== '' ? Math.min(s, 60) : Math.min(2 ** attempt, 16); }; export type Ads = ReturnType; export function adsClient(root: string, o: { sleep?: (ms: number) => Promise } = {}) { const c = adsCreds(root); let key: KeyObject; try { key = createPrivateKey(c.pem); } catch { throw new Error('APPLE_ADS_PRIVATE_KEY is not a valid EC private key (PEM)'); } const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms)); let cached = { token: '', until: 0 }; async function token(): Promise { if (cached.token && Date.now() < cached.until) return cached.token; const r = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenForm(c.clientId, clientSecret(c, key)).toString() }); const t = await r.text(); // the answer may echo the request; only the status and Apple's error code are shown if (!r.ok) { let code = ''; try { code = String(JSON.parse(t).error ?? ''); } catch { /* not JSON */ } throw new Error(`Apple Ads token request -> ${r.status}${code ? ' ' + code : ''} (check APPLE_ADS_CLIENT_ID / TEAM_ID / KEY_ID and that the uploaded public key matches APPLE_ADS_PRIVATE_KEY)`); } const j = JSON.parse(t) as { access_token?: string; expires_in?: number }; if (!j.access_token) throw new Error('Apple Ads token request: no access_token in the answer'); cached = { token: j.access_token, until: Date.now() + Math.max(60, (j.expires_in ?? 3600) - 120) * 1000 }; return cached.token; } async function call(method: string, path: string, body?: unknown, scoped = true): Promise { if (scoped && !c.accountId) throw new Error('APPLE_ADS_ACCOUNT_ID is not set: run `storebound ads apple status` to list your ad accounts'); for (let attempt = 1; ; attempt++) { const r = await fetch(ADS_API + path, { method, headers: adsHeaders(await token(), scoped ? c.accountId : undefined), body: body === undefined ? undefined : JSON.stringify(body) }); const t = await r.text(); if ((r.status === 429 || r.status >= 500) && attempt < 5) { await sleep(retryWait(r.headers.get('Retry-After'), attempt) * 1000); continue; } if (r.status === 401 && attempt === 1) { cached = { token: '', until: 0 }; continue; } if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 800)}`); return t ? JSON.parse(t) : {}; } } return { accountId: c.accountId, token, get: (path: string, scoped = true) => call('GET', path, undefined, scoped), post: (path: string, body: unknown) => call('POST', path, body), put: (path: string, body: unknown) => call('PUT', path, body), }; }