// 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) } } }, });