// App Store offers beyond introductory / promotional offers, as pure parts: offer codes for one-time purchases, // win-back offers for subscriptions, promoted in-app purchases and app pre-orders. The commands (cli/apple/offers.ts) // read, plan, print the dry run and send; everything that becomes visible to customers asks first and none of it is // an MCP tool. // Sources (App Store Connect API 4.5.1 OpenAPI spec + docs, App Store Connect Help; checked October 2026): // In-app purchase offer codes: POST /v1/inAppPurchaseOfferCodes (name, customerEligibilities NON_SPENDER | // ACTIVE_SPENDER | CHURNED_SPENDER, inAppPurchase, prices = inAppPurchaseOfferPrices: territory + pricePoint), // POST /v1/inAppPurchaseOfferCodeCustomCodes (customCode, numberOfCodes, expirationDate?), // POST /v1/inAppPurchaseOfferCodeOneTimeUseCodes (numberOfCodes, expirationDate, environment PRODUCTION | SANDBOX), // GET /v1/inAppPurchaseOfferCodeOneTimeUseCodes/{id}/values (the codes), GET /v2/inAppPurchases/{id}/offerCodes. // Help "Create offer codes for In-App Purchases": up to 10 active offers per app, 1 million codes per app per // quarter, one-time-use batches of 500-25,000 valid at most 6 months, custom codes up to 64 characters without // special characters and up to 25,000 redemptions, paid or free offers. Consumables, non-consumables and // non-renewing subscriptions (iOS 16.3+). // https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/create-offer-codes-for-in-app-purchases // Win-back offers: POST /v1/winBackOffers (referenceName, offerId, duration, offerMode, periodCount, // customerEligibilityPaidSubscriptionDurationInMonths, customerEligibilityTimeSinceLastSubscribedInMonths {minimum, // maximum}, customerEligibilityWaitBetweenOffersInMonths?, startDate, endDate?, priority HIGH | NORMAL, // promotionIntent NOT_PROMOTED | USE_AUTO_GENERATED_ASSETS; subscription; prices = winBackOfferPrices with a // subscriptionPricePoint), GET /v1/subscriptions/{id}/winBackOffers. // https://developer.apple.com/documentation/appstoreconnectapi/post-v1-winbackoffers // Help "Set up win-back offers": the subscription must be approved first; paid duration 1-24 months or 3, 4, 5 // years; time since last subscribed 1-24 months (min and max required); wait between offers 2-24 months; start at // the earliest tomorrow, run at least 3 days; at most 350 per subscription and 5 at a time per storefront; reference // name, offer id and price cannot be changed later; an offer id must not be reused in the group. // https://developer.apple.com/help/app-store-connect/manage-subscriptions/set-up-win-back-offers // Promoted purchases: POST /v1/promotedPurchases (visibleForAllUsers, enabled?, app, inAppPurchaseV2 | subscription), // PATCH /v1/promotedPurchases/{id} (visibleForAllUsers, enabled), GET /v1/apps/{id}/promotedPurchases, // PATCH /v1/apps/{id}/relationships/promotedPurchases (the order). Help "Promote In-App Purchases": up to 20 // promoted at a time, each needs its promotional image, the app must handle purchases started on the App Store // (PurchaseIntent). https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/promote-in-app-purchases // Pre-orders: GET /v1/apps/{id}/appAvailabilityV2, GET /v2/appAvailabilities/{id}/territoryAvailabilities // (available, releaseDate, preOrderEnabled, preOrderPublishDate, contentStatuses), PATCH // /v1/territoryAvailabilities/{id} (available, releaseDate, preOrderEnabled), POST /v1/endAppAvailabilityPreOrders // ("End the pre-order for your app and release to store immediately"). Help "Publish for pre-order": a version // must be approved first; release date 2-180 days ahead (Apple's pre-orders page: up to 365 days for an existing // app in a new region); not possible where the app is already released. // https://developer.apple.com/help/app-store-connect/manage-your-apps-availability/publish-for-pre-order // Google Play pre-registration has no API (androidpublisher v3 lists no such resource): Play Console only. import type { Req } from '../apple/jsonapi'; import { many, one } from '../apple/jsonapi'; import { APPLE_OFFER_DURATION, APPLE_OFFER_MODE } from './subscriptions'; // ---- config ---- /** Offer code campaign for a one-time purchase (consumable, non-consumable, non-renewing). */ export interface IapOfferCodeDef { /** Reference name of the campaign (unique per product). */ name: string; /** NON_SPENDER = never purchased in the app, ACTIVE_SPENDER = purchased in the last 30 days, CHURNED_SPENDER = * purchased more than 30 days ago. All three = everyone. */ eligibility: ('NON_SPENDER' | 'ACTIVE_SPENDER' | 'CHURNED_SPENDER')[]; /** Free offer (no price) or a paid one in USD (the USA price point and its equalizations). */ free?: boolean; usd?: number; /** Limit the offer to these App Store territories (alpha-3). Default: every territory. */ territories?: string[]; /** Custom codes (anyone with the code; created only from a terminal run with --codes that confirms). */ customCodes?: { code: string; count: number; expires?: string }[]; /** One-time-use code batches (500-25,000, expiry at most 6 months; --codes, confirms; the codes are saved to * .storebound/offer-codes/). */ oneTimeCodes?: { count: number; expires: string; sandbox?: boolean }[]; } /** Win-back offer for lapsed subscribers of one subscription. */ export interface WinBackDef { /** Offer identifier (unique in the subscription group, never reused; cannot change later). */ id: string; /** Reference name (default: the id; cannot change later). */ name?: string; type: 'free' | 'pay-as-you-go' | 'pay-up-front'; /** free / pay-up-front: P3D ... P1Y; pay-as-you-go: the subscription period (default). */ duration?: string; /** pay-as-you-go: number of periods (default 1). */ periods?: number; /** USD price (per period for pay-as-you-go, total for pay-up-front); fixed after creation. */ usd?: number; /** Customers who paid at least this many months: 1-24, 36, 48 or 60. */ paidMonths: number; /** Lapsed between min and max months ago (1-24). */ lapsedMonths: { min: number; max: number }; /** Months between two win-back offers for one customer (2-24, optional). */ waitMonths?: number; /** YYYY-MM-DD, at the earliest tomorrow. */ start: string; /** YYYY-MM-DD, at least 3 days after start (optional). */ end?: string; priority?: 'high' | 'normal'; /** Promote on the App Store with auto-generated assets (needs an approved subscription image). */ promote?: boolean; } export interface PromotedConfig { /** Product / subscription ids in display order (at most 20). */ products: string[]; /** Shown to every user (true) or only to users your app makes it visible for (false). Default true. */ visibleForAllUsers?: boolean; } const DAY = 86_400_000; const isDate = (s: unknown): s is string => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s)); const ELIG = ['NON_SPENDER', 'ACTIVE_SPENDER', 'CHURNED_SPENDER']; const PAID_MONTHS = new Set([...Array.from({ length: 24 }, (_, i) => i + 1), 36, 48, 60]); /** Problems with products[].offerCodes. With `today` (when codes are about to be created) also the 6-month expiry * rule; the config check leaves dates relative to today out, so an old campaign never breaks the config. Pure. */ export function validateIapOfferCodes(at: string, codes: IapOfferCodeDef[] | undefined, type: string, today?: string): string[] { const e: string[] = []; const names = new Set(); const max = today ? new Date(Date.parse(today) + 183 * DAY).toISOString().slice(0, 10) : '9999-12-31'; for (const [i, c] of (codes ?? []).entries()) { const p = `${at}.offerCodes[${i}]`; if (!c.name || [...c.name].length > 64) e.push(`${p}.name: 1-64 characters`); if (names.has(c.name)) e.push(`${p}.name: duplicate "${c.name}"`); names.add(c.name); if (!Array.isArray(c.eligibility) || !c.eligibility.length || !c.eligibility.every(x => ELIG.includes(x))) e.push(`${p}.eligibility: some of ${ELIG.join(', ')}`); if (!!c.free === (c.usd !== undefined)) e.push(`${p}: free: true or usd (one of them)`); if (c.usd !== undefined && !(c.usd > 0)) e.push(`${p}.usd: positive`); if (c.territories && !c.territories.every(t => /^[A-Z]{3}$/.test(t))) e.push(`${p}.territories: App Store territories, alpha-3 (USA)`); for (const [k, cc] of (c.customCodes ?? []).entries()) { if (!/^[A-Za-z0-9]{1,64}$/.test(cc.code ?? '')) e.push(`${p}.customCodes[${k}].code: letters and digits, up to 64`); if (!(Number.isInteger(cc.count) && cc.count >= 1 && cc.count <= 25000)) e.push(`${p}.customCodes[${k}].count: 1-25000`); if (cc.expires !== undefined && (!isDate(cc.expires) || cc.expires > max)) e.push(`${p}.customCodes[${k}].expires: YYYY-MM-DD within 6 months`); } for (const [k, b] of (c.oneTimeCodes ?? []).entries()) { const lo = b.sandbox ? 10 : 500, hi = b.sandbox ? 10000 : 25000; if (!(Number.isInteger(b.count) && b.count >= lo && b.count <= hi)) e.push(`${p}.oneTimeCodes[${k}].count: ${lo}-${hi}`); if (!isDate(b.expires) || b.expires > max) e.push(`${p}.oneTimeCodes[${k}].expires: YYYY-MM-DD within 6 months (required)`); } } if ((codes ?? []).length && !['consumable', 'non_consumable', 'non_renewing'].includes(type)) e.push(`${at}.offerCodes: one-time purchases only`); return e; } /** Problems with subscriptions[].winBack. With `today` (right before creating) also "start at the earliest * tomorrow". Pure. */ export function validateWinBack(at: string, offers: WinBackDef[] | undefined, today?: string): string[] { const e: string[] = []; const ids = new Set(); for (const [i, o] of (offers ?? []).entries()) { const p = `${at}.winBack[${i}]`; if (!/^[A-Za-z0-9._-]{1,64}$/.test(o.id ?? '')) e.push(`${p}.id: letters, digits, ".", "_", "-"`); if (ids.has(o.id)) e.push(`${p}.id: duplicate "${o.id}"`); ids.add(o.id); if (!(o.type in APPLE_OFFER_MODE)) e.push(`${p}.type: free | pay-as-you-go | pay-up-front`); if (o.type !== 'pay-as-you-go' && !(o.duration && o.duration in APPLE_OFFER_DURATION)) e.push(`${p}.duration: ${Object.keys(APPLE_OFFER_DURATION).join(', ')}`); if (o.type !== 'free' && !(typeof o.usd === 'number' && o.usd > 0)) e.push(`${p}.usd: positive (a paid offer)`); if (o.type === 'free' && o.usd !== undefined) e.push(`${p}.usd: not for a free offer`); if (o.periods !== undefined && !(Number.isInteger(o.periods) && o.periods >= 1 && o.periods <= 12)) e.push(`${p}.periods: 1-12`); if (!PAID_MONTHS.has(o.paidMonths)) e.push(`${p}.paidMonths: 1-24, 36, 48 or 60`); const l = o.lapsedMonths; if (!l || !(Number.isInteger(l.min) && Number.isInteger(l.max) && l.min >= 1 && l.max <= 24 && l.min <= l.max)) e.push(`${p}.lapsedMonths: { min, max } within 1-24`); if (o.waitMonths !== undefined && !(Number.isInteger(o.waitMonths) && o.waitMonths >= 2 && o.waitMonths <= 24)) e.push(`${p}.waitMonths: 2-24`); if (!isDate(o.start)) e.push(`${p}.start: YYYY-MM-DD`); else if (today && o.start <= today) e.push(`${p}.start: at the earliest tomorrow`); if (o.end !== undefined && (!isDate(o.end) || (isDate(o.start) && Date.parse(o.end) - Date.parse(o.start) < 3 * DAY))) e.push(`${p}.end: YYYY-MM-DD, at least 3 days after start`); if (o.priority !== undefined && o.priority !== 'high' && o.priority !== 'normal') e.push(`${p}.priority: high | normal`); } return e; } export function validatePromoted(p: PromotedConfig | undefined, ids: Set): string[] { if (!p) return []; const e: string[] = []; if (!Array.isArray(p.products) || !p.products.length) e.push('apple.promoted.products: at least one product id'); if ((p.products ?? []).length > 20) e.push('apple.promoted.products: at most 20 (App Store limit)'); for (const id of p.products ?? []) if (!ids.has(id)) e.push(`apple.promoted.products: "${id}" is not a product or subscription of the config`); if (new Set(p.products).size !== (p.products ?? []).length) e.push('apple.promoted.products: duplicates'); return e; } // ---- request bodies ---- export interface TerritoryPoint { territory: string; pointId?: string } /** POST /v1/inAppPurchaseOfferCodes. A free offer sends the territories without a price point (the schema marks the * price point optional; not verified against a live account). */ export function iapOfferCodeBody(iapId: string, c: IapOfferCodeDef, prices: TerritoryPoint[]) { const ids = prices.map((_, i) => `\${iapcode-${i}}`); return { data: { type: 'inAppPurchaseOfferCodes', attributes: { name: c.name, customerEligibilities: c.eligibility }, relationships: { inAppPurchase: one('inAppPurchases', iapId), prices: { data: ids.map(id => ({ type: 'inAppPurchaseOfferPrices', id })) } } }, included: prices.map((p, i) => { if (!c.free && !p.pointId) throw new Error(`${c.name}: paid offer in ${p.territory} needs a price point`); return { type: 'inAppPurchaseOfferPrices', id: ids[i], relationships: { territory: one('territories', p.territory), ...(p.pointId ? { pricePoint: one('inAppPurchasePricePoints', p.pointId) } : {}) } }; }), }; } export const iapCustomCodeReq = (offerCodeId: string, cc: { code: string; count: number; expires?: string }): Req => ({ method: 'POST', path: '/v1/inAppPurchaseOfferCodeCustomCodes', note: `custom code ${cc.code} (${cc.count} redemptions${cc.expires ? `, until ${cc.expires}` : ''})`, body: { data: { type: 'inAppPurchaseOfferCodeCustomCodes', attributes: { customCode: cc.code, numberOfCodes: cc.count, ...(cc.expires ? { expirationDate: cc.expires } : {}) }, relationships: { offerCode: one('inAppPurchaseOfferCodes', offerCodeId) } } } }); export const iapOneTimeCodesReq = (offerCodeId: string, b: { count: number; expires: string; sandbox?: boolean }): Req => ({ method: 'POST', path: '/v1/inAppPurchaseOfferCodeOneTimeUseCodes', note: `${b.count} one-time-use ${b.sandbox ? 'sandbox ' : ''}codes until ${b.expires}`, body: { data: { type: 'inAppPurchaseOfferCodeOneTimeUseCodes', attributes: { numberOfCodes: b.count, expirationDate: b.expires, environment: b.sandbox ? 'SANDBOX' : 'PRODUCTION' }, relationships: { offerCode: one('inAppPurchaseOfferCodes', offerCodeId) } } } }); /** POST /v1/winBackOffers. `subPeriod` = the subscription's period (pay-as-you-go duration). Paid prices: one * subscriptionPricePoint per territory (the point carries its territory, as in Apple's example request). A free offer * sends the territories without a price point (not verified against a live account). */ export function winBackBody(subId: string, subPeriod: string, o: WinBackDef, prices: TerritoryPoint[]) { const ids = prices.map((_, i) => `\${winback-${i}}`); const duration = o.type === 'pay-as-you-go' ? (o.duration ?? subPeriod) : o.duration!; return { data: { type: 'winBackOffers', attributes: { referenceName: o.name ?? o.id, offerId: o.id, duration: APPLE_OFFER_DURATION[duration], offerMode: APPLE_OFFER_MODE[o.type], periodCount: o.type === 'pay-as-you-go' ? (o.periods ?? 1) : 1, customerEligibilityPaidSubscriptionDurationInMonths: o.paidMonths, customerEligibilityTimeSinceLastSubscribedInMonths: { minimum: o.lapsedMonths.min, maximum: o.lapsedMonths.max }, ...(o.waitMonths !== undefined ? { customerEligibilityWaitBetweenOffersInMonths: o.waitMonths } : {}), startDate: o.start, ...(o.end ? { endDate: o.end } : {}), priority: (o.priority ?? 'normal').toUpperCase(), promotionIntent: o.promote ? 'USE_AUTO_GENERATED_ASSETS' : 'NOT_PROMOTED', }, relationships: { subscription: one('subscriptions', subId), prices: { data: ids.map(id => ({ type: 'winBackOfferPrices', id })) } } }, included: prices.map((p, i) => { if (o.type !== 'free' && !p.pointId) throw new Error(`${o.id}: paid win-back offer in ${p.territory} needs a price point`); return { type: 'winBackOfferPrices', id: ids[i], relationships: p.pointId ? { subscriptionPricePoint: one('subscriptionPricePoints', p.pointId) } : { territory: one('territories', p.territory) } }; }), }; } // ---- promoted purchases ---- export interface PromotedNow { id: string; productId: string; enabled: boolean; visibleForAllUsers: boolean; state?: string } export interface PromotedPlan { create: Req[]; update: Req[]; order: Req | null; unknown: string[]; extra: string[] } /** Wanted promoted purchases vs the current ones. New ones are created disabled unless enable; `enable` also turns on * existing ones. The order request lists the configured ones first, then the others as they are. Pure. */ export function planPromoted(appId: string, cfg: PromotedConfig, now: PromotedNow[], products: Map, o: { enable?: boolean } = {}): PromotedPlan { const plan: PromotedPlan = { create: [], update: [], order: null, unknown: [], extra: [] }; const visible = cfg.visibleForAllUsers ?? true; const have = new Map(now.map(p => [p.productId, p] as const)); for (const id of cfg.products) { const cur = have.get(id); if (cur) { const attrs: Record = {}; if (cur.visibleForAllUsers !== visible) attrs.visibleForAllUsers = visible; if (o.enable && !cur.enabled) attrs.enabled = true; if (Object.keys(attrs).length) plan.update.push({ method: 'PATCH', path: `/v1/promotedPurchases/${cur.id}`, note: `${id}: ${Object.entries(attrs).map(([k, v]) => `${k}=${v}`).join(', ')}`, body: { data: { type: 'promotedPurchases', id: cur.id, attributes: attrs } } }); continue; } const p = products.get(id); if (!p) { plan.unknown.push(id); continue; } plan.create.push({ method: 'POST', path: '/v1/promotedPurchases', note: `promote ${id}${o.enable ? ' (enabled)' : ' (not enabled yet)'}`, body: { data: { type: 'promotedPurchases', attributes: { visibleForAllUsers: visible, enabled: !!o.enable }, relationships: { app: one('apps', appId), ...(p.kind === 'iap' ? { inAppPurchaseV2: one('inAppPurchases', p.apiId) } : { subscription: one('subscriptions', p.apiId) }) } } } }); } plan.extra = now.filter(p => !cfg.products.includes(p.productId)).map(p => p.productId); return plan; } /** PATCH /v1/apps/{id}/relationships/promotedPurchases: the display order (configured first, then the rest). */ export function promotedOrderReq(appId: string, cfg: PromotedConfig, now: PromotedNow[]): Req | null { const byProduct = new Map(now.map(p => [p.productId, p.id] as const)); const ordered = [...cfg.products.map(id => byProduct.get(id)).filter((x): x is string => !!x), ...now.filter(p => !cfg.products.includes(p.productId)).map(p => p.id)]; if (!ordered.length || ordered.join() === now.map(p => p.id).join()) return null; return { method: 'PATCH', path: `/v1/apps/${appId}/relationships/promotedPurchases`, note: 'promoted purchase order', body: many('promotedPurchases', ordered) }; } // ---- pre-orders ---- export interface TerritoryAvail { id: string; territory: string; available?: boolean; releaseDate?: string | null; preOrderEnabled?: boolean; preOrderPublishDate?: string | null; contentStatuses?: string[] } /** Territory availabilities response(s) (include=territory) -> rows. Pure. */ export function territoryRows(pages: { data?: { id: string; attributes?: Record; relationships?: { territory?: { data?: { id: string } } } }[] }[]): TerritoryAvail[] { return pages.flatMap(p => (p.data ?? []).map(t => ({ id: t.id, territory: t.relationships?.territory?.data?.id ?? '?', available: t.attributes?.available as boolean | undefined, releaseDate: (t.attributes?.releaseDate as string | null | undefined) ?? null, preOrderEnabled: t.attributes?.preOrderEnabled as boolean | undefined, preOrderPublishDate: (t.attributes?.preOrderPublishDate as string | null | undefined) ?? null, contentStatuses: t.attributes?.contentStatuses as string[] | undefined }))); } /** PATCH requests that put the chosen territories on pre-order with this release date. Territories where the app is * already out (AVAILABLE) cannot go on pre-order. Pure. */ export function planPreOrder(rows: TerritoryAvail[], releaseDate: string, territories: string[] | null, today = new Date().toISOString().slice(0, 10)): { reqs: Req[]; problems: string[]; warnings: string[] } { const problems: string[] = [], warnings: string[] = []; if (!isDate(releaseDate)) problems.push('--release-date: YYYY-MM-DD'); const days = (Date.parse(releaseDate) - Date.parse(today)) / DAY; if (isDate(releaseDate) && days < 2) problems.push('--release-date: at least 2 days ahead'); if (isDate(releaseDate) && days > 365) problems.push('--release-date: at most 365 days ahead (180 for a new app)'); else if (isDate(releaseDate) && days > 180) warnings.push('more than 180 days ahead: only allowed for an existing app in a new region'); const pick = territories ? rows.filter(r => territories.includes(r.territory)) : rows; if (territories) for (const t of territories) if (!rows.some(r => r.territory === t)) problems.push(`territory ${t}: not in the app's availability`); const reqs: Req[] = []; for (const r of pick) { if (r.contentStatuses?.includes('AVAILABLE')) { warnings.push(`${r.territory}: already released there, no pre-order possible`); continue; } if (r.preOrderEnabled && r.releaseDate === releaseDate && r.available) continue; reqs.push({ method: 'PATCH', path: `/v1/territoryAvailabilities/${r.id}`, note: `${r.territory}: pre-order, release ${releaseDate}`, body: { data: { type: 'territoryAvailabilities', id: r.id, attributes: { available: true, preOrderEnabled: true, releaseDate } } } }); } return { reqs, problems, warnings }; } /** POST /v1/endAppAvailabilityPreOrders: end the pre-order in these territories and release there now. */ export function endPreOrderReq(rows: TerritoryAvail[], territories: string[] | null): Req | null { const pick = rows.filter(r => r.preOrderEnabled && (!territories || territories.includes(r.territory))); if (!pick.length) return null; return { method: 'POST', path: '/v1/endAppAvailabilityPreOrders', note: `end the pre-order and release now in ${pick.map(r => r.territory).join(', ')}`, body: { data: { type: 'endAppAvailabilityPreOrders', relationships: { territoryAvailabilities: many('territoryAvailabilities', pick.map(r => r.id)) } } } }; }