120 lines
11 KiB
Markdown
120 lines
11 KiB
Markdown
---
|
|
name: store-iap-setup
|
|
description: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - regional prices, local overrides, a price sheet CSV for many products and regions (diff first, future-dated App Store changes), activation, review screenshots, submitting IAPs and subscriptions to review through the API (first ones together with an app version), offer codes for one-time purchases, license/sandbox testers. Use when the user sets up, prices, re-prices or tests in-app products/IAPs, checks that both stores match (catalog diff), or hits Play onetimeproducts / inappproducts 403 or App Store IAP submission errors.
|
|
---
|
|
|
|
# In-app purchases on both stores
|
|
|
|
CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolchain>/cli/index.ts`). One list in `storebound.config.ts` `products[]` drives
|
|
both stores: `id` (same on both), `type` (consumable | non_consumable | non_renewing), `usd` base price, optional
|
|
`overrides` (`{ region: 'TR', currency: 'TRY', price: 99.99 }`), `listings` per language (title <= 30, description <=
|
|
45 for Apple), `reviewScreenshot`, optional `platforms: ['ios']` / `['android']` to sell it on one store only.
|
|
Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscriptions` skill).
|
|
- `non_renewing`: Apple NON_RENEWING_SUBSCRIPTION (access for a fixed time; your app tracks the expiry and offers
|
|
restore through your account system). Google Play has no such type: storebound makes it a one-time product your
|
|
app consumes when the access ends (or model it as a prepaid base plan in Play Console).
|
|
- Cross-store parity: `storebound catalog diff` (MCP `catalog_diff`, read-only) compares the config with both stores:
|
|
missing / extra products, type, US and override prices, localizations, and regions where the two stores price the
|
|
same product more than 10 % apart (`--threshold`).
|
|
- Offer codes for one-time products exist on the App Store API (`inAppPurchaseOfferCodes`); storebound does not
|
|
create them yet (App Store Connect > the IAP > Offer Codes). Play promo codes have no API (Play Console > Monetize >
|
|
Promo codes).
|
|
|
|
## Prerequisites
|
|
- Play: a build with `com.android.vending.BILLING` uploaded to any track; payments profile set up.
|
|
- Apple: Paid Applications agreement + banking + tax active (Business > Agreements), the app record exists.
|
|
|
|
## Google Play (API)
|
|
1. `storebound check`, then `storebound play products --dry` (read-only: per product create, unchanged, or exactly
|
|
what would change: listings, regional prices old -> new, dropped regions, activation). Show it to the owner.
|
|
2. `storebound play products`. Per product: `pricing:convertRegionPrices` (USD -> every region at local price points,
|
|
and the `regionsVersion`), your overrides on top, `PATCH .../onetimeproducts/{id}?allowMissing=true®ionsVersion.version=...`
|
|
(create or update), then `oneTimeProducts/{id}/purchaseOptions:batchUpdateStates` to activate (new options are DRAFT).
|
|
New products (with their first prices) and listing text go straight through; `--yes` confirms activating the
|
|
purchase option of an existing product. Price or region changes of an EXISTING product are the owner's decision
|
|
and `--yes` never confirms them (also not the `--yes` that `flow run --apply --yes` adds): the dry run marks those
|
|
rows `owner` and lists them in a "price changes on existing products (held)" section, and a real run writes
|
|
everything else, prints `price change on <id> (<from> -> <to>) needs the owner: re-run with
|
|
--allow-price-changes` and exits 3 (declined; a flow stops there). A terminal "y" never covers them either: only
|
|
`--allow-price-changes` does, and then a second question lists each old -> new. So adding new products to the
|
|
config and re-running is safe without `--only`. After the owner's yes in chat for exactly that diff, run
|
|
`storebound play products --yes --allow-price-changes`, or the owner uses the desktop Prices "Apply" (owner action).
|
|
3. Verify: `storebound play status` lists every product with `buy:ACTIVE`.
|
|
4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing
|
|
link; purchases are free test orders.
|
|
|
|
## App Store (API + UI)
|
|
1. `storebound apple iap --dry`, then `storebound apple iap`: creates missing products, localizations, a price schedule
|
|
(USA base + overrides; Apple equalizes the rest) and availability. Existing schedules and availability are never
|
|
changed by it; price changes go through `storebound prices import` (diff first, then the owner's yes and
|
|
`--allow-price-changes`).
|
|
2. Review screenshot per product (a real device size, e.g. 1290 x 2796, showing the item in the shop):
|
|
`storebound apple iap-review`. States move to READY_TO_SUBMIT when complete.
|
|
3. **Send them to App Review** (the owner's step): `storebound apple iap-submit --dry` shows the plan: which version
|
|
of each product goes in, what is left out and why, and which products are the **first of their type** for this app.
|
|
- Apple's rule (App Store Connect Help, "Submit an In-App Purchase"): the first consumable, non-consumable,
|
|
non-renewing subscription and auto-renewable subscription of an app must be submitted **with a new app version**;
|
|
later ones of an approved type go alone as long as the app has at least one approved version. A new subscription
|
|
goes with its subscription group while the group is not approved. Everything reviewed together sits in one draft
|
|
submission, up to 200 items. Content-hosted IAPs can no longer be submitted.
|
|
- Through the API (App Store Connect API 4.4.1+): each product has versions (`inAppPurchaseVersions`,
|
|
`subscriptionVersions`, `subscriptionGroupVersions`) that go into a review submission as items, next to the app
|
|
version when needed. So the first ones no longer need the web UI: `--with-version` puts the editable app version
|
|
(`storebound apple listing --version x.y.z --build N` first) into the same submission, and then submitting it
|
|
**submits the app too**.
|
|
- The real run asks before adding the items and again before submitting; `--no-submit` stops at the filled draft so
|
|
the owner presses "Submit for Review" in App Store Connect. `--new-version` makes a new draft version of an
|
|
approved or rejected product first (changed name, description or screenshot). After the owner canceled a
|
|
submission the versions are DEVELOPER_REJECTED: they go into the next submission as they are (the plan says
|
|
"add (developer rejected, re-submitted as is)"); no new version is needed. Never an MCP tool; MCP has the
|
|
read-only plan `apple_iap_review_plan`. `--json` prints the plan for scripts.
|
|
4. Offer codes (free or discounted) for one-time purchases: see the app-store-promotion skill (`storebound apple offer-codes`).
|
|
5. Test: Sandbox testers (Users and Access > Sandbox) on device; or a StoreKit configuration file in Xcode for local
|
|
runs (those transactions are signed locally and fail server-side receipt checks).
|
|
|
|
## Many prices at once: the price sheet (both stores)
|
|
For price changes after the products exist (sales, regional pricing, a new price everywhere), keep one CSV:
|
|
`product,store,region,currency,price,start_date` (`store` = apple | play | both; `region` ISO alpha-2; `start_date`
|
|
App Store only, a future YYYY-MM-DD).
|
|
1. `storebound prices export` writes the current prices of both stores to `store/prices.csv` (`--with-automatic` adds
|
|
Apple's equalized prices as `kind=auto`, for reference; they are ignored on import).
|
|
2. Edit the rows. Apple only takes its own price points: an exact value per territory (the error lists the
|
|
neighbours); Play takes any price in the region's currency.
|
|
3. `storebound prices import --dry` (MCP: `prices_diff`) prints the diff table: product, store, region, from, current,
|
|
new, change %. Show it to the owner.
|
|
4. After the owner's yes for that diff: `storebound prices import --yes --allow-price-changes` (not an MCP tool;
|
|
`--yes` alone only sets first prices of products that have none and exits 3 naming every held price change).
|
|
Apple: one new price schedule per changed
|
|
product (base territory, default the current one or `prices.baseTerritory`, plus every manual price; territories
|
|
without one follow Apple's equalization). Play: the purchase option's regional prices are replaced; regions the
|
|
product has no price for get `convertRegionPrices` from the USD base, `--reconvert` resets every region the CSV
|
|
does not list.
|
|
- Future-dated changes (`start_date`) are App Store only; Play has no scheduled prices (change them on the day). The
|
|
same CSV feeds subscription prices: `storebound apple subscriptions --csv store/prices.csv` (dated rows become
|
|
scheduled subscription price changes) and `storebound play subscriptions --csv store/prices.csv`.
|
|
- Apple's IAP pricing guide warns that a price **increase cannot be reverted** once it is in effect: tell the owner.
|
|
- Prices are public the moment the stores apply them. Never import without the owner reading the diff.
|
|
|
|
## Gotchas
|
|
- Play legacy `inappproducts` returns 403 "Please migrate to the new publishing API" -> use
|
|
`monetization.onetimeproducts`.
|
|
- Path casing: PATCH works on `onetimeproducts`, but list + `purchaseOptions:batchUpdateStates` need `oneTimeProducts`;
|
|
the wrong casing answers with an HTML 404 page.
|
|
- Play has no consumable flag: the app consumes the purchase after granting it. Use `legacyCompatible: true` so older
|
|
billing libraries see the product by id.
|
|
- Apple: first IAPs must ship with an app version ("FIRST_*_MUST_BE_SUBMITTED_ON_VERSION" from the old
|
|
inAppPurchaseSubmissions). If the version is already in review without them: cancel the review submission
|
|
(`storebound apple status --cancel-submission <id>`, ask the owner), then `storebound apple iap-submit --with-version`
|
|
and resubmit.
|
|
- Verified on a real first release (2026-10-12): `storebound apple iap-submit --with-version --yes` (after the owner's
|
|
yes: it submits) added the first IAPs to the existing draft submission that already held version 1.0.0 and
|
|
submitted them, first try, through `reviewSubmissionItems`. Do not use the console for this: the IAP page's "Add for
|
|
Review" menu did not react to scripted clicks, and the version page no longer has an "In-App Purchases and
|
|
Subscriptions" section (pitfall 28). The deprecated `inAppPurchaseSubmissions` / `subscriptionSubmissions` are not used.
|
|
- Apple needs an exact existing price point for each manual price; the error lists the nearest ones.
|
|
- Apple IAP display name 2-30 characters, description max **45** (App Store Connect Help > Reference > "In-app
|
|
purchase information"; an older App Store marketing page says 55, but App Store Connect enforces 45).
|
|
- Product ids can never be reused after deletion on either store. Never delete without the owner.
|
|
- Do not put prices or product names in store descriptions; say "offers optional in-app purchases".
|
|
- If the app ships without real billing yet, hide every purchase surface in store builds (review rejects mock
|
|
purchases) and answer "digital purchases: No" in IARC.
|