Implement in-app purchases in a game or app — Google Play Billing, Apple StoreKit 2, one-time unlocks, consumables, subscriptions, entitlement caching for offline play, restore, refund handling, server-side receipt validation, sandbox testing, and the store-policy rules that get IAP builds rejected. Use when adding, debugging, or reviewing purchases, entitlements, paywalls, "remove ads" products, or premium unlocks.
Installs into .claude/skills of the current project.
Are you the author of Game Iap?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/thomaslimited-game-iap)
---
name: game-iap
description: Implement in-app purchases in a game or app — Google Play Billing, Apple StoreKit 2, one-time unlocks, consumables, subscriptions, entitlement caching for offline play, restore, refund handling, server-side receipt validation, sandbox testing, and the store-policy rules that get IAP builds rejected. Use when adding, debugging, or reviewing purchases, entitlements, paywalls, "remove ads" products, or premium unlocks.
license: MIT
metadata:
version: "1.0.0"
specs-verified: "2026-09-24"
---
# Game IAP
Purchases are the part of a game most likely to lose real money to a bug, and the part least
covered by tests because the store is remote and asynchronous. This skill gives the architecture
that makes IAP testable, the platform APIs, and the policy rules that cause rejection.
## Decide first
Answer these three before writing code. They determine everything else.
| Question | Consequence |
|---|---|
| **Consumable, non-consumable, or subscription?** | Non-consumable (level pack, remove-ads) → entitlement, must survive reinstall via restore. Consumable (gem pack) → must be *consumed* after granting or the user can never rebuy. Subscription → renewal state, grace periods, a server is effectively mandatory. |
| **Does the game have a server?** | No server → entitlements live in local save and the store is the only source of truth. Yes → validate receipts server-side and keep entitlement server-side; the client becomes a cache. |
| **Must purchased content work offline?** | Yes → you must cache entitlements locally and define what happens when the store is unreachable (see [Offline and refunds](#offline-and-refunds)). This is the single most common source of angry reviews. |
**Never gate content on a network call at launch.** A player who paid and boarded a plane must
still have their content.
## Core architecture
One interface, three implementations. This is the whole trick — it makes IAP unit-testable
without a store, and it is what lets you ship a desktop or web build that simply sells nothing.
```
StoreBackend (interface, async via callbacks/signals)
├── PlayBillingBackend — Android
├── StoreKitBackend — iOS
├── FakeStoreBackend — tests, debug builds, screenshots
└── NullBackend — platforms where you do not sell (desktop, web)
```
The interface needs exactly five outbound events and four inbound calls:
```
events: purchase_finished(sku, success, error)
purchases_synced(owned_skus[]) # the store's complete list
sync_failed(error) # offline — do NOT clear entitlements
prices_loaded({sku: localized_price})
availability_changed(available)
calls: start() # connect, then immediately sync + load prices
purchase(sku)
restore() # re-query owned products
is_available()
```
`purchases_synced` carrying the **complete** list (not a delta) is what makes refunds work: when
the store answers, it replaces local state rather than merging into it.
Full worked implementation, including the reconciliation edge cases:
`references/reference-implementation-godot.md`
## Product catalog as data
Keep products in a data file (JSON), not in code, with: `id`, display name key, description key,
`type`, fallback price, and **what it unlocks**. Then:
- `is_covered(sku)` answers "does the player already have this?" including bundles — a
`supporter_bundle` must cover `full_unlock`, which must cover `grade_2..grade_5`. Without a
coverage rule you will sell someone content they already own.
- The store, the paywall UI and the tests all read the same list, so a typo shows up once.
**Product IDs are permanent.** Google Play and Apple both refuse to reuse an ID after it exists,
even deleted. Decide the naming scheme before creating the first one. Lowercase, underscores or
dots, no version numbers in the ID.
## Prices always come from the store
Never display a hard-coded price. Currency, tax-inclusive display and regional pricing are the
store's job, and showing the wrong one is a policy violation in several markets.
Fallback rule when the store has not answered yet: show the *local* list price only for the
currency you actually know (e.g. your home market), and show no price at all for everyone else —
a wrong currency is worse than a bare "Buy" button.
## Offline and refunds
Four states, and code must handle all four:
| State | Behaviour |
|---|---|
| Store reachable, sync succeeds | Store list **replaces** local entitlements. Refunded purchases disappear here — this is the only place a refund can be honoured. |
| Store unreachable | Keep cached entitlements untouched. Never downgrade a paying player because of a dropped connection. |
| Purchase completes *while* a sync is in flight | The sync result may predate the purchase. Track SKUs granted since the sync started and re-add them to the synced list, or the player loses what they just bought. |
| Purchase pending (slow card, cash payment, family approval) | Grant nothing yet. Show "pending". Google returns `PENDING`; grant only on `PURCHASED`. |
The third row is the bug nobody writes a test for. Write the test.
## Platform notes
- **Android** — `references/android-play-billing.md`. Play Billing Library 8+ is mandatory for new
uploads and updates since **31 Aug 2026**; latest is 9.1.0. v8 renamed "in-app items" to
"one-time products" and removed `querySkuDetailsAsync`, `queryPurchaseHistory` and the
no-arg `enablePendingPurchases`.
- **iOS** — `references/ios-storekit.md`. StoreKit 2 (`Product`, `Transaction`,
`Transaction.updates`) is the default; it verifies signatures for you. You must run a
`Transaction.updates` listener for the whole app lifetime or you will drop purchases made
outside your flow.
- **Server validation** — `references/server-receipt-validation.md`. Required the moment a
purchase grants something a cheater would want. Play: Developer API + Real-time developer
notifications (RTDN). Apple: App Store Server API + Server Notifications V2.
## Policy rules that cause rejection
- **Digital content must use the platform's IAP.** Both stores. Taking Stripe/PayPal/bank transfer
for in-game content gets the app removed. (Physical goods and real-world services are the
exception, and must *not* use IAP.)
- **US App Store only, since May 2025**: apps may include buttons and links to external purchase
pages with no entitlement required. Outside the US storefront the old restriction still applies.
Do not build a link-out flow globally on the strength of a US headline.
- **A restore path is mandatory** on iOS for non-consumables. A visible "Restore purchases"
button. Rejections under 3.1.1 for a missing one are routine.
- **Kids' apps**: purchase entry points must sit behind a parent gate, and the gate must not be
solvable by a young child (no simple "tap the biggest number"). See `app-auth` and
`store-listing` for the Families/Kids declarations that follow from selling to children.
- **Do not promise content you gate later.** Changing a bought unlock into a subscription is a
refund event and a review-bomb event.
## Testing
Ranked by what actually catches bugs:
1. **FakeStoreBackend unit tests** — every row of the offline/refund table above, plus
double-purchase, purchase-while-owned, and cancelled flows. These run in CI with no store.
2. **Android closed testing / license testers** — real Play dialogs, no charge. License testers
are set at the account level in Play Console, not per build. Purchases still have to be
refunded/consumed between runs for consumables.
3. **iOS sandbox / StoreKit configuration file** — the `.storekit` file lets you test in the
simulator without App Store Connect at all; do both, because the file does not catch
App Store Connect misconfiguration.
Checklist before you call IAP done: `references/pre-launch-checklist.md`
## Sources
Specs verified 2026-09-24:
- [Play Billing release notes](https://developer.android.com/google/play/billing/release-notes)
- [StoreKit](https://developer.apple.com/documentation/storekit)
- [App Review Guidelines 3.1](https://developer.apple.com/app-store/review/guidelines/#in-app-purchase)