In-App Purchases skill for Claude Code
Subscriptions that unlock, renew and refund correctly. Claude builds purchase flows where signed store data, not a local flag, decides access. It starts a StoreKit transaction listener at launch, acknowledges Play purchases before the 3-day auto-refund, verifies JWS and purchase tokens on the server, maps App Store Server Notifications v2 and RTDN to one entitlement table, and walks the sandbox matrix of renewals, grace, refunds and upgrades.
- name
in-app-purchases - size241 lines · ≈3,050 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Implements in-app purchases and subscriptions with StoreKit 2, Google Play Billing Library 8, or RevenueCat — product catalogs, purchase flows, transaction listeners, finishing and acknowledging, entitlement checks, restore, server-side verification with the App Store Server API and Play Developer API, App Store Server Notifications v2 and Play RTDN, grace periods, refunds, and sandbox testing. Use when adding a paywall or subscription, purchases do not grant access or are refunded after 3 days, entitlements drift between devices, or choosing between native billing and RevenueCat. Try asking
- Add a monthly and yearly subscription to our iOS and Android apps with a server-side entitlement check
- Some Android users get refunded after 3 days even though they paid — figure out why
- Should we use RevenueCat or build StoreKit 2 and Play Billing ourselves? We already have a backend
Use it when
- Adding a paywall, subscription, or consumable purchase to a mobile app
- Purchases fail to unlock, unlock twice, or vanish after reinstall
- Building a backend endpoint for App Store Server Notifications or Play RTDN
- Migrating off verifyReceipt or an old Play Billing Library major
Skip it when
- Selling on the web or desktop through a reseller of record — use merchant-of-record-billing
- Hardening the app binary against tampering in general — use mobile-app-security
How to check the result
Every scenario in the sandbox matrix (buy, cancel, pending, renew, grace, expire, refund, upgrade, kill mid-purchase) leaves exactly one correct entitlement row and a finished or acknowledged transaction.
The common mistake it prevents
Granting access on the client and never acknowledging Play purchases or finishing StoreKit transactions, which causes automatic refunds and endless redelivery.
Done well means: the server (or RevenueCat) is the source of truth for entitlements, every purchase is verified from signed store data and then finished/acknowledged exactly once, a launch-time listener catches purchases made outside the paywall, renewals, refunds and billing retries arrive via server notifications, and every flow was exercised in sandbox or a StoreKit configuration file before review.
Before changing anything
- Inventory products in both consoles: product IDs, type (consumable, non-consumable, auto-renewable subscription, non-renewing), subscription groups (Apple) and base plans plus offers (Play). Mismatched IDs between code and console are the top “products array is empty” cause.
- Check agreements: Paid Applications agreement, tax and banking in App Store Connect; merchant account in Play Console. Without them products silently do not load.
- Find where entitlement is decided today (a
isProflag in UserDefaults is a bug waiting to happen) and whether a backend account exists to attach purchases to. - Read the platform rules that matter: digital goods consumed in the app must use store billing; physical goods and person-to-person services must not. External purchase links are region- and program-specific; treat them as a legal decision, not a code one.
Choose the stack
| Situation | Use |
|---|---|
| Subscriptions on iOS + Android, small team, want charts, experiments, webhooks | RevenueCat (or a similar SDK) |
| iOS only, simple non-consumables, no backend | StoreKit 2 with on-device Transaction.currentEntitlements |
| Own backend with accounts, strict control, or very high volume | Native StoreKit 2 + Play Billing, server verification, notifications |
| Web checkout as well as app stores | RevenueCat with web billing or a merchant of record, keyed by your user ID |
RevenueCat does not remove the need to understand the store states below; it removes the
receipt plumbing. Keep entitlement names (pro) separate from product IDs so prices and
durations can change without code changes.
Workflow
- [ ] 1. Products configured in both consoles; IDs listed in one shared constants file
- [ ] 2. Agreements, tax, banking active; license testers / sandbox accounts created
- [ ] 3. Transaction listener started at app launch (before any paywall)
- [ ] 4. Load products, render localized price strings from the store (never hardcode)
- [ ] 5. Purchase with an account token tying it to your user ID
- [ ] 6. Verify (JWS / purchase token) on the server, grant entitlement, then finish/acknowledge
- [ ] 7. Server notifications endpoint (ASSN v2, Play RTDN) updates entitlements
- [ ] 8. Restore purchases button + entitlement refresh on launch and foreground
- [ ] 9. Test matrix: buy, cancel, pending/Ask to Buy, renew, grace, expire, refund, upgrade
StoreKit 2 (iOS 15+, prefer iOS 17+ APIs)
import StoreKit
@MainActor @Observable
final class Store {
private(set) var products: [Product] = []
private(set) var activeProductIDs: Set<String> = []
private var updatesTask: Task<Void, Never>?
init() {
// Start at launch: catches renewals, Ask to Buy approvals, offer-code redemptions,
// purchases from other devices, and anything left unfinished.
updatesTask = Task { [weak self] in
for await result in Transaction.updates { await self?.handle(result) }
}
}
func load() async throws {
products = try await Product.products(for: ProductID.all)
await refreshEntitlements()
}
func buy(_ product: Product, userID: UUID) async throws {
let result = try await product.purchase(options: [.appAccountToken(userID)])
switch result {
case .success(let verification): await handle(verification)
case .pending: break // Ask to Buy / SCA: wait for Transaction.updates
case .userCancelled: break
@unknown default: break
}
}
private func handle(_ result: VerificationResult<Transaction>) async {
guard case .verified(let tx) = result else { return } // never grant unverified
// Send result.jwsRepresentation to your server here; grant only after it confirms.
await refreshEntitlements()
await tx.finish() // finish after granting
}
func refreshEntitlements() async {
var ids: Set<String> = []
for await case .verified(let tx) in Transaction.currentEntitlements where tx.revocationDate == nil {
ids.insert(tx.productID)
}
activeProductIDs = ids
}
func restore() async throws { try await AppStore.sync() } // only from an explicit button
}
Transaction.currentEntitlementsreturns active subscriptions and non-consumables only; consumables never appear. Track consumable balances on your server.currentEntitlement(for:)(singular) is deprecated from iOS 18.4; use the pluralcurrentEntitlements(for:)because Family Sharing can yield several transactions.- SwiftUI offers
ProductView,StoreView, andSubscriptionStoreView(iOS 17+) plus.subscriptionStatusTask(for:); use them for fast, review-friendly paywalls. AppStore.sync()prompts for the Apple ID password. Call it only from a Restore button; normal launches usecurrentEntitlements.appAccountTokenmust be a UUID; map your user ID to a stable UUID and store it.- Offer eligibility:
product.subscription?.isEligibleForIntroOfferper group. Do not show “free trial” copy to ineligible users (review rejection and refunds).
Google Play Billing Library 8
val client = BillingClient.newBuilder(context)
.setListener { billingResult, purchases -> onPurchasesUpdated(billingResult, purchases) }
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build()
// Query products; v8 returns fetched and unfetched lists separately
val params = QueryProductDetailsParams.newBuilder().setProductList(
listOf(QueryProductDetailsParams.Product.newBuilder()
.setProductId("pro_monthly").setProductType(BillingClient.ProductType.SUBS).build())
).build()
// Launch: subscriptions require an offerToken from subscriptionOfferDetails
val flow = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(details.subscriptionOfferDetails!!.first().offerToken)
.build()))
.setObfuscatedAccountId(hashedUserId) // ties the purchase to your account
.build()
client.launchBillingFlow(activity, flow)
Purchase handling rules:
- Only
Purchase.PurchaseState.PURCHASEDgrants access;PENDING(cash, slow payment methods) shows “pending” UI and waits for an update. - Send
purchaseTokento the server, verify withpurchases.subscriptionsv2.getorpurchases.products.get, grant, then acknowledge. Unacknowledged purchases are refunded automatically after 3 days. Consumables useconsumeAsync(which also acknowledges); acknowledge server-side for subscriptions and non-consumables. - Call
queryPurchasesAsyncon every app start and resume to recover purchases whose listener callback was missed (process death mid-purchase is common). - v8 removed
queryPurchaseHistoryAsyncandquerySkuDetailsAsync; purchase history now lives on your backend. Keep the library on the current major: Play blocks app updates that ship a deprecated major after its published deadline. - Prorations on upgrade/downgrade use
SubscriptionUpdateParamswith a replacement mode; the new purchase token haslinkedPurchaseTokenpointing to the old one, so the server must retire the old token.
RevenueCat
Purchases.configure(withAPIKey: "appl_xxx", appUserID: currentUser?.id) // nil = anonymous
let offerings = try await Purchases.shared.offerings()
let result = try await Purchases.shared.purchase(package: offerings.current!.monthly!)
let isPro = result.customerInfo.entitlements["pro"]?.isActive == true
// On login / logout
let (info, _) = try await Purchases.shared.logIn(user.id)
- Call
logInwith your own user ID before purchases whenever accounts exist; anonymous IDs that later alias cause “lost purchase” tickets. - Use RevenueCat webhooks (or its REST API) for server-side entitlement, not the client.
- Still configure App Store Server Notifications and Play RTDN to point at RevenueCat.
Server side
Verify signed data, never trust the client. For the full ASSN v2 and RTDN state tables, read references/server-verification.md when building or debugging the backend.
| Task | Apple | |
|---|---|---|
| Verify a purchase | Verify JWS (x5c chain to Apple Root CA G3) or call Get Transaction Info | purchases.subscriptionsv2.get / purchases.products.get with a service account |
| Current subscription state | Get All Subscription Statuses | subscriptionsv2.get (subscriptionState) |
| Push updates | App Store Server Notifications v2 (signed payload) | RTDN via Cloud Pub/Sub, then fetch state |
| Refunds | REFUND notification; Get Refund History | Voided Purchases API + VoidedPurchaseNotification |
| Libraries | App Store Server Library (Swift, Java, Python, Node) | Google API client for Android Publisher |
Rules: verifyReceipt is deprecated, do not build on it. Treat notifications as hints:
on each one, fetch the current state from the API and upsert. Make handlers idempotent
keyed on notificationUUID / Pub/Sub message ID. Store originalTransactionId (Apple)
and the latest purchaseToken chain (Google) per user.
Entitlement model
entitlements(user_id, entitlement, source, product_id, store_original_id,
status, expires_at, grace_until, auto_renew, environment, updated_at)
status ∈ active | grace | billing_retry | expired | revoked | refunded | paused
access = status in (active, grace) and (expires_at > now or grace_until > now)
Separate environment (Sandbox/Production) so sandbox renewals never grant production
access. Grace period: keep access, show a “fix payment” banner. Billing retry or account
hold: remove access, deep-link to the store’s manage-subscription page.
Gotchas
- Not finishing StoreKit transactions: they redeliver on every launch and block repeat consumable purchases. Finishing before your server granted means a lost purchase.
- Granting from the client in Play without server acknowledge: refunded after 3 days.
- Hardcoded prices or “$4.99” strings: wrong currency, review rejection. Use
product.displayPrice/formattedPricefrom offer pricing phases. - Sandbox subscriptions renew on accelerated schedules (minutes); never compare sandbox timing to production in tests of expiry logic.
- Restoring by Apple ID across your accounts: decide the policy (transfer vs. block) for one store account used with two app accounts before launch.
- Missing required paywall content: price, period, auto-renew terms, links to terms and privacy policy. App Review rejects incomplete subscription disclosure.
- Family Sharing and promo/offer codes arrive via
Transaction.updates, not via your purchase call. Without a listener they never grant access.
Verification
- StoreKit configuration file in the Xcode scheme: run purchase, cancel, Ask to Buy (approve/decline in Transaction Manager), refund, renewal and expiry.
- Sandbox account on device and Play license tester on an internal testing track (Play Billing Lab for faster renewals and region changes).
- Server: send a test notification (Apple “Request a Test Notification”; Play RTDN test message from Console) and confirm it is logged and idempotent.
- Check the database after each scenario: one entitlement row per user and product, correct status and expiry, environment set.
- Kill the app mid-purchase, relaunch, confirm the listener/
queryPurchasesAsyncpath grants and finishes/acknowledges exactly once.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/server-verification.md
# Server-side verification and notification states
Read when building or debugging the purchase backend. Always confirm newly added
notification types against Apple's and Google's current documentation; both stores add
types over time and handlers must log and ignore unknown ones rather than fail.
## Apple
### Authenticating to the App Store Server API
- Create an In-App Purchase key in App Store Connect (Users and Access, Integrations).
- Sign a JWT with ES256: header `alg=ES256`, `kid=<key id>`, `typ=JWT`; payload
`iss=<issuer id>`, `iat`, `exp` (max 60 minutes ahead), `aud="appstoreconnect-v1"`,
`bid=<bundle id>`.
- Production base URL `https://api.storekit.itunes.apple.com`, sandbox
`https://api.storekit-sandbox.itunes.apple.com`. If a production lookup returns
"transaction not found", retry against sandbox (TestFlight and review builds use sandbox).
- Prefer Apple's App Store Server Library: it signs the JWT, calls the endpoints, and its
`SignedDataVerifier` validates JWS signatures against Apple root certificates you
download and pin.
### Endpoints used most
| Purpose | Endpoint |
|---|---|
| One transaction | `GET /inApps/v1/transactions/{transactionId}` |
| Subscription status for a group | `GET /inApps/v1/subscriptions/{transactionId}` |
| Purchase history (paged) | `GET /inApps/v2/history/{transactionId}` |
| Refund history | `GET /inApps/v2/refund/lookup/{transactionId}` |
| Missed notifications | `POST /inApps/v1/notifications/history` |
| Trigger a test notification | `POST /inApps/v1/notifications/test` |
| Respond to consumption request | `PUT /inApps/v1/transactions/consumption/{transactionId}` |
### Verifying a JWS from the client
1. Decode the header, take the `x5c` chain, verify each certificate signs the next, and
that the last chains to Apple Root CA G3 you hold locally.
2. Verify the ES256 signature over `header.payload` with the leaf certificate key.
3. Check `bundleId`, `environment`, `productId`, and that `appAccountToken` maps to the
calling user. Reject transactions with `revocationDate` set.
4. Upsert by `originalTransactionId`; respond so the client can `finish()`.
### App Store Server Notifications v2
The POST body is `{"signedPayload": "<JWS>"}`. Verify it exactly like a client JWS, then
decode `data.signedTransactionInfo` and `data.signedRenewalInfo` (also JWS).
| notificationType (subtype) | Meaning | Entitlement action |
|---|---|---|
| `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | New or returning subscriber | active |
| `DID_RENEW` (`BILLING_RECOVERY` optional) | Renewed, or recovered from retry | active, new expiry |
| `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | Billing failed, grace period active | grace until `gracePeriodExpiresDate` |
| `DID_FAIL_TO_RENEW` (no subtype) | Billing retry, no grace | billing_retry, remove access |
| `GRACE_PERIOD_EXPIRED` | Grace ended without recovery | billing_retry or expired |
| `EXPIRED` (`VOLUNTARY`, `BILLING_RETRY`, `PRICE_INCREASE`, `PRODUCT_NOT_FOR_SALE`) | Ended | expired |
| `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED/DISABLED`) | User toggled auto-renew | update auto_renew; keep access to expiry |
| `DID_CHANGE_RENEWAL_PREF` (`UPGRADE`, `DOWNGRADE`) | Plan change (upgrade immediate, downgrade at renewal) | update product |
| `OFFER_REDEEMED` | Offer or offer code redeemed | update product/offer |
| `PRICE_INCREASE` (`PENDING`, `ACCEPTED`) | Price increase consent state | inform user if pending |
| `REFUND` | Refund granted | refunded, revoke; for consumables claw back balance |
| `REFUND_DECLINED`, `REFUND_REVERSED` | Refund outcome changed | restore if reversed |
| `REVOKE` | Family Sharing access withdrawn | revoked for that family member |
| `CONSUMPTION_REQUEST` | Apple asks for consumption info for a refund request | send consumption data within 12 hours if you have consent |
| `RENEWAL_EXTENDED`, `RENEWAL_EXTENSION` | Expiry extended by you | update expiry |
| `ONE_TIME_CHARGE` | Consumable, non-consumable, or non-renewing purchase | grant (newer type; enable in settings) |
| `TEST` | Test notification | log only |
Return HTTP 200 quickly (enqueue work). Apple retries failed deliveries several times
over days; use Get Notification History to backfill outages.
## Google Play
### Authenticating
- Create a Google Cloud service account, link it in Play Console (Users and permissions)
with financial-data and order-management permissions for the app.
- Use the Android Publisher API v3 client with OAuth2 service-account credentials.
### Core calls
| Purpose | Call |
|---|---|
| Subscription state | `purchases.subscriptionsv2.get(packageName, token)` |
| One-time product | `purchases.products.get(packageName, productId, token)` |
| Acknowledge subscription | `purchases.subscriptions.acknowledge` |
| Acknowledge one-time | `purchases.products.acknowledge` |
| Consume (server side) | `purchases.products.consume` |
| Refunds and chargebacks | `purchases.voidedpurchases.list` |
| Revoke / refund from your side | `orders.refund`, `purchases.subscriptionsv2.revoke` |
`subscriptionsv2.get` returns `subscriptionState`:
`SUBSCRIPTION_STATE_ACTIVE`, `_IN_GRACE_PERIOD`, `_ON_HOLD`, `_PAUSED`, `_CANCELED`
(still entitled until expiry), `_EXPIRED`, `_PENDING`, `_PENDING_PURCHASE_CANCELED`.
Check `acknowledgementState`, `linkedPurchaseToken`, `lineItems[].expiryTime`, and
`externalAccountIdentifiers.obfuscatedExternalAccountId` against the calling user.
### Real-time developer notifications
Configure a Cloud Pub/Sub topic in Play Console, grant
`[email protected]` publish rights, and use a
push subscription to your HTTPS endpoint. The message `data` is base64 JSON containing one
of `subscriptionNotification`, `oneTimeProductNotification`,
`voidedPurchaseNotification`, or `testNotification`.
| subscriptionNotification type | Meaning |
|---|---|
| 1 `RECOVERED` | Recovered from account hold |
| 2 `RENEWED` | Active subscription renewed |
| 3 `CANCELED` | Cancelled (voluntary or involuntary); access until expiry |
| 4 `PURCHASED` | New purchase |
| 5 `ON_HOLD` | Account hold: remove access |
| 6 `IN_GRACE_PERIOD` | Grace period: keep access, prompt to fix payment |
| 7 `RESTARTED` | User restored before expiry |
| 9 `DEFERRED` | Renewal deferred by you |
| 10 `PAUSED`, 11 `PAUSE_SCHEDULE_CHANGED` | Pause state changes |
| 12 `REVOKED` | Revoked before expiry: remove access |
| 13 `EXPIRED` | Expired |
| 20 `PENDING_PURCHASE_CANCELED` | Pending purchase never completed |
`oneTimeProductNotification`: 1 `ONE_TIME_PRODUCT_PURCHASED`, 2 `ONE_TIME_PRODUCT_CANCELED`.
Never trust the notification body for state; always call `subscriptionsv2.get` with the
token it carries. Ack the Pub/Sub message only after the database write commits;
unacked messages are redelivered.
### Upgrade and downgrade chains
A plan change issues a new purchase token whose `linkedPurchaseToken` is the old one.
On receipt: mark the old token superseded, move the entitlement to the new token, and
acknowledge the new purchase. Failing to retire the old token double-counts revenue and
can grant access after the old plan expires.
## Idempotency and reconciliation
- Primary key per event: Apple `notificationUUID`, Pub/Sub `messageId`.
- Nightly job: for every entitlement expiring in the next 48 hours or in grace/hold,
re-fetch state from the store API and correct drift.
- Alert when notification volume drops to zero for more than a few hours in production;
it usually means a rotated certificate, a changed URL, or a broken Pub/Sub subscription.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.