// App Store custom product pages and in-app events, as pure parts: config checks, the create requests and what to // change. The commands (cli/apple/cpp.ts, cli/apple/events.ts) read, plan, show the dry run and send. Creating and // uploading change nothing customers see; sending a page or an event to App Review, and making a page visible, are // the owner's (terminal, asks first, not MCP tools). // Sources (App Store Connect API 4.5.1 OpenAPI spec + docs, App Store Connect Help; checked October 2026): // Custom product pages: POST /v1/appCustomProductPages (name, app, optional appStoreVersionTemplate / // customProductPageTemplate, inline appCustomProductPageVersions + appCustomProductPageLocalizations with locale and // promotionalText; Apple's example request), PATCH /v1/appCustomProductPages/{id} (name, visible), // POST /v1/appCustomProductPageVersions (deepLink), POST /v1/appCustomProductPageLocalizations (locale, // promotionalText), screenshots: appScreenshotSets with the appCustomProductPageLocalization relationship. // https://developer.apple.com/documentation/appstoreconnectapi/post-v1-appcustomproductpages // Help: up to 70 custom product pages per app; each needs App Review (independent of an app update); the deep link // works from iOS / iPadOS 18 once approved. https://developer.apple.com/app-store/custom-product-pages/ // In-app events: POST /v1/appEvents (referenceName, badge, deepLink, purchaseRequirement, primaryLocale, priority, // purpose, territorySchedules [{ territories, publishStart, eventStart, eventEnd }]), POST /v1/appEventLocalizations // (locale, name, shortDescription, longDescription), POST /v1/appEventScreenshots (fileName, fileSize, // appEventAssetType EVENT_CARD | EVENT_DETAILS_PAGE) + the upload operations. // Help "Offer In-App Events": reference name up to 64 characters, event name 30, short description 50, long // description 120; an event lasts 15 minutes to 31 days; it can be published at most 14 days before it starts; // up to 10 published and 15 approved events per app; events need App Review. // https://developer.apple.com/help/app-store-connect/offer-in-app-events/offer-in-app-events // Media: event card 16:9, 1920x1080 to 3840x2160; details page 9:16, 1080x1920 to 2160x3840; .jpg / .png. // https://developer.apple.com/help/app-store-connect/reference/in-app-events/in-app-event-media-and-audio-specifications // Sending either to review: a review submission item with appCustomProductPageVersion | appEvent // (https://developer.apple.com/documentation/appstoreconnectapi/review-submissions). // Deprecation note (API 4.5.1): screenshot sets and app event screenshots are deprecated in favour of the App Asset // Library; they still work and storebound uses them until the asset library is the only way. import type { Req } from '../apple/jsonapi'; import { one } from '../apple/jsonapi'; import type { Language } from './config'; export const CPP_LIMITS = { pages: 70, promotionalText: 170 } as const; export const EVENT_LIMITS = { referenceName: 64, name: 30, shortDescription: 50, longDescription: 120, published: 10, approved: 15, maxDays: 31, minMinutes: 15, publishLeadDays: 14 } as const; export const EVENT_BADGES = ['LIVE_EVENT', 'PREMIERE', 'CHALLENGE', 'COMPETITION', 'NEW_SEASON', 'MAJOR_UPDATE', 'SPECIAL_EVENT'] as const; export const EVENT_PURPOSES = ['APPROPRIATE_FOR_ALL_USERS', 'ATTRACT_NEW_USERS', 'KEEP_ACTIVE_USERS_INFORMED', 'BRING_BACK_LAPSED_USERS'] as const; /** Event media: [min w, min h, max w, max h]. */ export const EVENT_MEDIA = { EVENT_CARD: [1920, 1080, 3840, 2160], EVENT_DETAILS_PAGE: [1080, 1920, 2160, 3840] } as const; export interface CustomProductPageDef { /** Page name (unique per app; shown only in App Store Connect). */ name: string; /** Screenshots from this shots variant (`storebound shots render --variant `); default: the base renders. */ variant?: string; /** Universal link or custom URL that "Open" goes to (iOS / iPadOS 18+, after approval). */ deepLink?: string; /** Language id -> promotional text (170). Every language with an App Store locale gets a localization. */ promotionalText?: Record; } export interface AppEventDef { /** Reference name (unique per app, up to 64 characters). */ referenceName: string; badge: typeof EVENT_BADGES[number]; purpose?: typeof EVENT_PURPOSES[number]; priority?: 'high' | 'normal'; deepLink?: string; /** Text shown when the event needs a purchase or subscription, e.g. "Pro subscription". */ purchaseRequirement?: string; /** Language id of the primary locale (default: the first language with an App Store locale). */ primaryLanguage?: string; /** When and where: ISO date-times; territories (alpha-3) default to every territory of the app. */ schedule: { publishStart: string; eventStart: string; eventEnd: string; territories?: string[] }; /** Language id -> name (30), shortDescription (50), longDescription (120). */ listings: Record; /** Images (paths from the project root; "{lang}" is replaced by the language id): card 16:9, details 9:16. */ media?: { card?: string; details?: string }; } const len = (s: string): number => [...s].length; export function validateCpp(pages: CustomProductPageDef[] | undefined, langIds: Set, variants: string[]): string[] { const e: string[] = []; const names = new Set(); if ((pages ?? []).length > CPP_LIMITS.pages) e.push(`apple.customProductPages: at most ${CPP_LIMITS.pages} per app`); for (const [i, p] of (pages ?? []).entries()) { const at = `apple.customProductPages[${i}]`; if (!p.name) e.push(`${at}.name: missing`); if (names.has(p.name)) e.push(`${at}.name: duplicate "${p.name}"`); names.add(p.name); if (p.variant && !variants.includes(p.variant)) e.push(`${at}.variant: "${p.variant}" is not in shots.variants`); if (p.deepLink && !/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(p.deepLink)) e.push(`${at}.deepLink: a universal link (https://...) or custom URL (myapp://...)`); for (const [l, t] of Object.entries(p.promotionalText ?? {})) { if (!langIds.has(l)) e.push(`${at}.promotionalText.${l}: not in languages`); if (len(t) > CPP_LIMITS.promotionalText) e.push(`${at}.promotionalText.${l}: ${len(t)} > ${CPP_LIMITS.promotionalText}`); } } return e; } export function validateEvents(events: AppEventDef[] | undefined, langIds: Set): string[] { const e: string[] = []; const names = new Set(); for (const [i, ev] of (events ?? []).entries()) { const at = `apple.events[${i}]`; if (!ev.referenceName || len(ev.referenceName) > EVENT_LIMITS.referenceName) e.push(`${at}.referenceName: 1-${EVENT_LIMITS.referenceName} characters`); if (names.has(ev.referenceName)) e.push(`${at}.referenceName: duplicate`); names.add(ev.referenceName); if (!EVENT_BADGES.includes(ev.badge)) e.push(`${at}.badge: ${EVENT_BADGES.join(' | ')}`); if (ev.purpose && !EVENT_PURPOSES.includes(ev.purpose)) e.push(`${at}.purpose: ${EVENT_PURPOSES.join(' | ')}`); if (ev.priority && ev.priority !== 'high' && ev.priority !== 'normal') e.push(`${at}.priority: high | normal`); if (ev.primaryLanguage && !langIds.has(ev.primaryLanguage)) e.push(`${at}.primaryLanguage: not in languages`); const s = ev.schedule; const t = (x: string | undefined) => (x ? Date.parse(x) : NaN); if (!s || [s.publishStart, s.eventStart, s.eventEnd].some(x => Number.isNaN(t(x)))) e.push(`${at}.schedule: publishStart, eventStart, eventEnd as ISO date-times`); else { const dur = t(s.eventEnd) - t(s.eventStart); if (dur < EVENT_LIMITS.minMinutes * 60_000) e.push(`${at}.schedule: an event lasts at least ${EVENT_LIMITS.minMinutes} minutes`); if (dur > EVENT_LIMITS.maxDays * 86_400_000) e.push(`${at}.schedule: an event lasts at most ${EVENT_LIMITS.maxDays} days`); if (t(s.publishStart) > t(s.eventStart)) e.push(`${at}.schedule: publishStart after eventStart`); if (t(s.eventStart) - t(s.publishStart) > EVENT_LIMITS.publishLeadDays * 86_400_000) e.push(`${at}.schedule: published at most ${EVENT_LIMITS.publishLeadDays} days before it starts`); if (s.territories && !s.territories.every(x => /^[A-Z]{3}$/.test(x))) e.push(`${at}.schedule.territories: alpha-3 (USA)`); } if (!ev.listings || !Object.keys(ev.listings).length) e.push(`${at}.listings: at least one language`); for (const [l, x] of Object.entries(ev.listings ?? {})) { if (!langIds.has(l)) e.push(`${at}.listings.${l}: not in languages`); for (const k of ['name', 'shortDescription', 'longDescription'] as const) { if (!x[k]) e.push(`${at}.listings.${l}.${k}: missing`); else if (len(x[k]) > EVENT_LIMITS[k]) e.push(`${at}.listings.${l}.${k}: ${len(x[k])} > ${EVENT_LIMITS[k]}`); } } } return e; } /** POST /v1/appCustomProductPages with its first version and one localization per locale (Apple's example shape). * templateVersionId copies the screenshots / previews of an App Store version (optional). */ export function cppCreateBody(appId: string, p: CustomProductPageDef, locales: { locale: string; promotionalText?: string }[], templateVersionId?: string) { const locIds = locales.map((_, i) => `\${cpp-loc-${i}}`); return { data: { type: 'appCustomProductPages', attributes: { name: p.name }, relationships: { app: one('apps', appId), ...(templateVersionId ? { appStoreVersionTemplate: one('appStoreVersions', templateVersionId) } : {}), appCustomProductPageVersions: { data: [{ type: 'appCustomProductPageVersions', id: '${cpp-version}' }] } } }, included: [ { type: 'appCustomProductPageVersions', id: '${cpp-version}', ...(p.deepLink ? { attributes: { deepLink: p.deepLink } } : {}), relationships: { appCustomProductPageLocalizations: { data: locIds.map(id => ({ type: 'appCustomProductPageLocalizations', id })) } } }, ...locales.map((l, i) => ({ type: 'appCustomProductPageLocalizations', id: locIds[i], attributes: { locale: l.locale, ...(l.promotionalText ? { promotionalText: l.promotionalText } : {}) } })), ], }; } /** Localizations of an editable page version to add (POST) or change (PATCH promotional text). Pure. */ export function planCppLocales(versionId: string, have: { id: string; locale: string; promotionalText?: string | null }[], want: { locale: string; promotionalText?: string }[]): Req[] { const out: Req[] = []; for (const w of want) { const cur = have.find(h => h.locale === w.locale); if (!cur) out.push({ method: 'POST', path: '/v1/appCustomProductPageLocalizations', note: w.locale, body: { data: { type: 'appCustomProductPageLocalizations', attributes: { locale: w.locale, ...(w.promotionalText ? { promotionalText: w.promotionalText } : {}) }, relationships: { appCustomProductPageVersion: one('appCustomProductPageVersions', versionId) } } } }); else if (w.promotionalText !== undefined && (cur.promotionalText ?? '') !== w.promotionalText) out.push({ method: 'PATCH', path: `/v1/appCustomProductPageLocalizations/${cur.id}`, note: `${w.locale} promotional text`, body: { data: { type: 'appCustomProductPageLocalizations', id: cur.id, attributes: { promotionalText: w.promotionalText } } } }); } return out; } /** Locales of a page from the config: every language with an App Store locale. */ export function cppLocales(p: CustomProductPageDef, langs: Language[]): { locale: string; langId: string; promotionalText?: string }[] { return langs.filter(l => l.apple).map(l => ({ locale: l.apple!, langId: l.id, ...(p.promotionalText?.[l.id] ? { promotionalText: p.promotionalText[l.id] } : {}) })); } /** Editable states of a custom product page version / in-app event (can still be changed and uploaded to). */ export const CPP_EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'REJECTED']); export const EVENT_EDITABLE = new Set(['DRAFT', 'REJECTED']); /** POST /v1/appEvents. */ export function eventCreateBody(appId: string, ev: AppEventDef, primaryLocale: string, territories: string[]) { return { data: { type: 'appEvents', attributes: { referenceName: ev.referenceName, badge: ev.badge, primaryLocale, priority: (ev.priority ?? 'normal').toUpperCase(), purpose: ev.purpose ?? 'APPROPRIATE_FOR_ALL_USERS', ...(ev.deepLink ? { deepLink: ev.deepLink } : {}), ...(ev.purchaseRequirement ? { purchaseRequirement: ev.purchaseRequirement } : {}), territorySchedules: [{ territories, publishStart: ev.schedule.publishStart, eventStart: ev.schedule.eventStart, eventEnd: ev.schedule.eventEnd }], }, relationships: { app: one('apps', appId) } } }; } /** Event localizations to add or change. Pure. */ export function planEventLocales(eventId: string, have: { id: string; locale: string; name?: string | null; shortDescription?: string | null; longDescription?: string | null }[], want: { locale: string; name: string; shortDescription: string; longDescription: string }[]): Req[] { const out: Req[] = []; for (const w of want) { const cur = have.find(h => h.locale === w.locale); const attrs = { name: w.name, shortDescription: w.shortDescription, longDescription: w.longDescription }; if (!cur) out.push({ method: 'POST', path: '/v1/appEventLocalizations', note: w.locale, body: { data: { type: 'appEventLocalizations', attributes: { locale: w.locale, ...attrs }, relationships: { appEvent: one('appEvents', eventId) } } } }); else { const changed = Object.fromEntries(Object.entries(attrs).filter(([k, v]) => (cur as Record)[k] !== v)); if (Object.keys(changed).length) out.push({ method: 'PATCH', path: `/v1/appEventLocalizations/${cur.id}`, note: `${w.locale} ${Object.keys(changed).join(', ')}`, body: { data: { type: 'appEventLocalizations', id: cur.id, attributes: changed } } }); } } return out; } /** Event media size check (card 16:9 landscape, details 9:16 portrait). Pure. */ export function checkEventMedia(kind: keyof typeof EVENT_MEDIA, w: number, h: number, format: string): string[] { const [minW, minH, maxW, maxH] = EVENT_MEDIA[kind]; const e: string[] = []; if (format !== 'png' && format !== 'jpeg') e.push('PNG or JPEG only'); if (w < minW || h < minH || w > maxW || h > maxH) e.push(`${w}x${h}: ${minW}x${minH} to ${maxW}x${maxH}`); if (Math.abs(w / h - minW / minH) > 0.01) e.push(`${w}x${h}: aspect ${kind === 'EVENT_CARD' ? '16:9' : '9:16'}`); return e; } /** POST /v1/reviewSubmissionItems for a custom product page version or an in-app event (into a draft submission). */ export const reviewItemReq = (submissionId: string, kind: 'appCustomProductPageVersion' | 'appEvent', id: string, label: string): Req => ({ method: 'POST', path: '/v1/reviewSubmissionItems', note: label, body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', submissionId), [kind]: one(kind === 'appEvent' ? 'appEvents' : 'appCustomProductPageVersions', id) } } }, }); /** Event attributes that differ from the config: `attrs` = the PATCH attributes (empty when nothing differs), `lines` = readable. Pure. */ export function eventAttrDiff(ev: AppEventDef, now: { badge?: string; priority?: string; purpose?: string; deepLink?: string | null; territorySchedules?: { territories?: string[]; publishStart?: string; eventStart?: string; eventEnd?: string }[] }): { attrs: Record; lines: string[] } { const attrs: Record = {}; const lines: string[] = []; const same = (a?: string, b?: string) => (a && b ? Date.parse(a) === Date.parse(b) : a === b); if (now.badge !== undefined && now.badge !== ev.badge) { attrs.badge = ev.badge; lines.push(`badge ${now.badge} -> ${ev.badge}`); } const pri = (ev.priority ?? 'normal').toUpperCase(); if (now.priority !== undefined && now.priority !== pri) { attrs.priority = pri; lines.push(`priority ${now.priority} -> ${pri}`); } const purpose = ev.purpose ?? 'APPROPRIATE_FOR_ALL_USERS'; if (now.purpose !== undefined && now.purpose !== purpose) { attrs.purpose = purpose; lines.push(`purpose ${now.purpose} -> ${purpose}`); } const s = now.territorySchedules?.[0]; const sch: string[] = []; if (!same(s?.publishStart, ev.schedule.publishStart)) sch.push(`publishStart ${s?.publishStart ?? '(none)'} -> ${ev.schedule.publishStart}`); if (!same(s?.eventStart, ev.schedule.eventStart)) sch.push(`eventStart ${s?.eventStart ?? '(none)'} -> ${ev.schedule.eventStart}`); if (!same(s?.eventEnd, ev.schedule.eventEnd)) sch.push(`eventEnd ${s?.eventEnd ?? '(none)'} -> ${ev.schedule.eventEnd}`); const wt = ev.schedule.territories; if (wt && [...new Set(s?.territories ?? [])].sort().join() !== [...new Set(wt)].sort().join()) sch.push(`territories ${(s?.territories ?? []).length} -> ${wt.length}`); if (sch.length) { attrs.territorySchedules = [{ territories: wt ?? s?.territories ?? [], publishStart: ev.schedule.publishStart, eventStart: ev.schedule.eventStart, eventEnd: ev.schedule.eventEnd }]; lines.push(...sch); } return { attrs, lines }; } /** PATCH /v1/appEvents/{id} with the changed attributes, null when none. */ export const eventPatchReq = (eventId: string, name: string, attrs: Record, lines: string[]): Req | null => Object.keys(attrs).length ? { method: 'PATCH', path: `/v1/appEvents/${eventId}`, note: `event "${name}": ${lines.join('; ')}`, body: { data: { type: 'appEvents', id: eventId, attributes: attrs } } } : null; /** PATCH of a page version's deep link: set, change, or clear (config has none, the page has one). Null when equal. Pure. */ export function cppDeepLinkReq(versionId: string, name: string, want: string | undefined, have: string | null | undefined): Req | null { if ((want ?? null) === (have ?? null)) return null; return { method: 'PATCH', path: `/v1/appCustomProductPageVersions/${versionId}`, note: want ? `"${name}" deep link` : `"${name}" deep link removed`, body: { data: { type: 'appCustomProductPageVersions', id: versionId, attributes: { deepLink: want ?? null } } } }; } /** Locales on a page version that the config has no language for. Pure. */ export const extraCppLocales = (have: string[], want: string[]): string[] => have.filter(l => !want.includes(l));