In-App Purchases skill for Codex
The server decides who paid, not the device. Codex wires purchases so the server owns entitlement state: StoreKit 2 transactions are verified and finished only after the server records them, Play purchases are acknowledged before the three-day refund window, and App Store and Play notifications trigger a re-fetch of authoritative state rather than trusting event order. A reference maps each notification type to the access change it implies.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- in-app-purchases Installs to ~/.agents/skills/in-app-purchases/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install in-app-purchases The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project in-app-purchases Installs to .agents/skills/in-app-purchases/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/in-app-purchases.zipunzip -o in-app-purchases.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) in-app-purchases PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $in-app-purchases, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements in-app purchases and subscriptions with StoreKit 2, Google Play Billing Library and RevenueCat - product setup, purchase flows, transaction verification, acknowledgement and finishing, restore, server-side entitlement state from the App Store Server API and Google Play Developer API, App Store Server Notifications V2 and Real-time Developer Notifications, and sandbox testing. Use when adding a paywall, subscriptions or consumables, when purchases are refunded or lost, or when replacing deprecated receipt validation.
Use it when
- Adding a paywall, subscription, consumable or one-time upgrade
- Purchases are auto-refunded, lost after a crash, or missing after restore
- Moving off deprecated receipt validation to signed transactions
- Building the server endpoint for store notifications
Use something else when
- Payments for physical goods or services through a web checkout: use stripe-payments
- Submitting the build or answering an App Review rejection: use ios-app-store-release
How to check the result
Run purchase, renewal, cancellation, billing retry, refund and restore through StoreKit testing and Play license testers, and confirm the server entitlement row changes correctly for each.
The mistake it prevents
Finishing the StoreKit transaction or acknowledging the Play purchase before the server has stored the entitlement, so a crash in between loses a paid purchase.
Done well means: the server, not the device, decides what a user is entitled to; every purchase is verified, acknowledged or finished exactly once; renewals, refunds and billing failures reach the server by notification within minutes; and prices shown are the store’s localized strings, never hard-coded.
Store rules on what must use in-app purchase, external purchase links and regional exceptions (US anti-steering rulings, EU DMA terms) change often. Check the current App Review Guidelines and Play payments policy, and have the client’s lawyer confirm anything outside the default IAP path.
Before changing anything
- Read
AGENTS.md, then find existing billing code, the entitlement model on the server, and whether RevenueCat, Adapty or another SDK is already present. - Write the product catalog: product IDs, type (consumable, non-consumable,
auto-renewable subscription, non-renewing), subscription groups and base plans/offers,
and the entitlement each one grants (
pro,credits,ad_free). - Confirm accounts: the app’s user ID, how it maps to store transactions
(
appAccountTokenon iOS,obfuscatedAccountIdon Android), and what happens for purchases made while logged out. - Creating products, prices and offers in App Store Connect or Play Console changes what real customers pay. Draft them and ask the user to confirm before creating or editing.
Build or buy
| Situation | Choice |
|---|---|
| Subscriptions on both platforms, small team, want analytics and paywall experiments | RevenueCat (or similar): it runs receipt validation, webhooks and entitlement state |
| Existing backend with user accounts, need full data ownership | Native StoreKit 2 + Play Billing, server verification described below |
| A single non-consumable purchase on iOS | StoreKit 2 on device with Transaction.currentEntitlements; server optional |
With RevenueCat, still log in with your own user ID (Purchases.logIn), gate features on
customerInfo.entitlements["pro"]?.isActive, and consume its webhooks on the server.
iOS: StoreKit 2
@MainActor
final class Store: ObservableObject {
private var updates: Task<Void, Never>?
init() {
updates = Task { for await result in Transaction.updates { await self.handle(result) } }
}
func buy(_ product: Product, userID: UUID) async throws {
switch try await product.purchase(options: [.appAccountToken(userID)]) {
case .success(let result): await handle(result)
case .pending: break // Ask to Buy or 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
await sendToServer(jws: result.jwsRepresentation) // server grants entitlement
await tx.finish() // only after it is recorded
}
}
- Start the
Transaction.updateslistener at launch, before any UI; renewals, Ask to Buy approvals and purchases from other devices arrive there. - Load products with
try await Product.products(for: ids)and showproduct.displayPrice. - Restore:
try await AppStore.sync()only behind an explicit “Restore Purchases” button (it may prompt for the Apple Account password). Read state fromTransaction.currentEntitlements. - Subscription status:
product.subscription?.statusgives renewal state and grace period. verifyReceiptand the app receipt are deprecated; send the transaction JWS to the server and verify it with Apple’s App Store Server Library.
Android: Play Billing Library
Keep the library current; Play requires a recent major version for new apps and updates (check the deprecation schedule in the Play Billing release notes).
- Build
BillingClientwith aPurchasesUpdatedListenerandenablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()). startConnection; on disconnect, reconnect with backoff before the next call.queryProductDetailsAsyncfor the product IDs; for subscriptions pick anofferTokenfromsubscriptionOfferDetails.launchBillingFlowwithProductDetailsParamsandsetObfuscatedAccountId(hash(userId)).- In the listener and on every app start (
queryPurchasesAsync), for each purchase withpurchaseState == PURCHASED: sendpurchaseTokento the server, which verifies it and then acknowledges (acknowledgeAPI orconsumeAsyncfor consumables). PENDINGpurchases grant nothing until they move toPURCHASED.
Unacknowledged purchases are refunded automatically after three days. Acknowledge from the server (Developer API) or the client, but only after the entitlement is stored.
Server: source of truth
| Task | Apple | |
|---|---|---|
| Verify a purchase | Verify the signed transaction JWS (App Store Server Library) | purchases.products.get / purchases.subscriptionsv2.get |
| Current subscription state | App Store Server API Get All Subscription Statuses | purchases.subscriptionsv2.get |
| Change events | App Store Server Notifications V2 (signed payload) | Real-time Developer Notifications via Cloud Pub/Sub |
| Refunds / revocations | REFUND, REVOKE notifications | voidedPurchaseNotification, Voided Purchases API |
Pattern for every notification: verify its signature or Pub/Sub auth, dedupe by notification ID, then re-fetch the current state from the store API and recompute the entitlement. Do not apply incremental state from the notification alone; they can arrive late, twice or out of order.
For the notification types that matter and how each maps to entitlement changes, read references/store-notifications.md when building the webhook handler.
Entitlement row shape:
entitlement(user_id, key, source[apple|google|promo], original_transaction_id | purchase_token,
product_id, status[active|grace|billing_retry|expired|revoked], expires_at, updated_at)
Testing
- iOS local: a
.storekitconfiguration file in the Xcode scheme; test renewals at accelerated rates, Ask to Buy, refunds and billing failures from Xcode’s transaction manager. Unit-test withSKTestSession. - iOS sandbox: Sandbox Apple Accounts; TestFlight builds also use sandbox.
- Android: license testers in Play Console, test cards that always approve, decline or stay pending; the app must be uploaded to a testing track with the matching application ID and signed build.
- Send a test notification: App Store Server API
Request a Test Notification; Play Console’s RTDN “Send test notification”.
Gotchas
- Finishing (iOS) or acknowledging (Android) before the server has stored the entitlement loses the purchase if the app dies in between.
- Family Sharing and promo codes create transactions the app never initiated; they arrive
through
Transaction.updatesand notifications only. - Upgrades and downgrades within a subscription group change the product without a new original transaction; key entitlements on the original transaction ID.
- Do not block app launch on the store being reachable; cache the last known entitlement with its expiry and refresh in the background.
- Account deletion: tell users that store subscriptions must be cancelled in store settings, and link to the store’s subscription management page.
Verification
- New purchase, renewal, cancellation, expiry, billing retry and refund each change the server entitlement correctly (sandbox or test notifications)
- Killing the app between purchase and server call recovers on next launch
- Restore on a fresh install and on a second device grants the same entitlement
- Pending purchase grants nothing until approved
- Prices render from store data in at least two locales
Output
**Catalog:** <product id -> type, entitlement>
**Client:** <StoreKit 2 / Play Billing / RevenueCat, listener at launch, restore path>
**Server:** <verification, notifications endpoint, state recompute>
**Tested:** <scenario -> result, environment> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "In-App Purchases"
short_description: "The server decides who paid, not the device"
default_prompt: "$in-app-purchases Add a monthly and yearly subscription with StoreKit 2 and Play Billing" references/store-notifications.md
# Store notifications to entitlement changes
Read this when writing the server endpoint that receives App Store Server Notifications V2
or Google Play Real-time Developer Notifications (RTDN).
## Handler skeleton (both stores)
1. Authenticate the message.
- Apple: the body is `{"signedPayload": "<JWS>"}`. Verify the JWS certificate chain to
Apple's root with the App Store Server Library (`SignedDataVerifier` or the equivalent
in your language), and check `bundleId` and `environment` (Sandbox / Production).
- Google: RTDN arrives through Cloud Pub/Sub. Use a push subscription with an
authenticated push (OIDC token) and verify the token's audience and service account,
or pull from a subscription with your own credentials. The `data` field is base64 JSON.
2. Deduplicate: Apple `notificationUUID`; Pub/Sub `messageId`. Store processed IDs.
3. Identify the subscription or purchase: Apple `originalTransactionId` from the decoded
`signedTransactionInfo`; Google `purchaseToken` (+ `subscriptionId` or `sku`).
4. Re-fetch authoritative state:
- Apple: App Store Server API `Get All Subscription Statuses` (subscriptions) or
`Get Transaction Info` (one-time).
- Google: `purchases.subscriptionsv2.get` or `purchases.products.get`.
5. Recompute the entitlement row from that state and save it in one transaction.
6. Respond 200 quickly. Apple retries failed deliveries several times over a few days;
Pub/Sub redelivers until acknowledged. Long work goes to a job queue.
## Apple: notification types that change access
| notificationType (subtype) | Meaning | Entitlement |
|---|---|---|
| `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | New or returning subscriber | active until `expiresDate` |
| `DID_RENEW` | Renewed, including after billing recovery | active, extend expiry |
| `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | Payment failed, grace period on | grace: keep access until grace end |
| `DID_FAIL_TO_RENEW` (no subtype) | Payment failed, no grace | billing_retry: remove access, prompt to fix payment |
| `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_DISABLED`) | User cancelled; access continues to expiry | no change now; win-back candidate |
| `DID_CHANGE_RENEWAL_PREF` | Upgrade/downgrade/crossgrade scheduled or applied | update product at next renewal or now for upgrades |
| `REFUND` | Apple refunded a transaction | revoke what that transaction granted |
| `REVOKE` | Family Sharing access removed | revoke for that family member |
| `REFUND_REVERSED` | Refund undone | restore |
| `CONSUMPTION_REQUEST` | Apple asks for consumption data on a refund request | optionally send consumption info (with user consent) |
| `RENEWAL_EXTENDED` | You extended the renewal date | extend expiry |
| `OFFER_REDEEMED`, `PRICE_INCREASE` | Informational | usually no change |
| `TEST` | Your test request | log only |
Subscription status values from `Get All Subscription Statuses`: 1 active, 2 expired,
3 billing retry, 4 grace period, 5 revoked. Map grace (4) to access and billing retry (3)
to no access.
Treat unknown types as "re-fetch and recompute"; Apple adds new types over time.
## Google: RTDN payloads
The decoded message contains one of `subscriptionNotification`,
`oneTimeProductNotification`, `voidedPurchaseNotification` or `testNotification`.
Common `subscriptionNotification.notificationType` values:
| Value | Name | Entitlement after re-fetch |
|---|---|---|
| 1 | `SUBSCRIPTION_RECOVERED` | active |
| 2 | `SUBSCRIPTION_RENEWED` | active, new expiry |
| 3 | `SUBSCRIPTION_CANCELED` | active until expiry |
| 4 | `SUBSCRIPTION_PURCHASED` | active; acknowledge if not yet acknowledged |
| 5 | `SUBSCRIPTION_ON_HOLD` | no access (account hold) |
| 6 | `SUBSCRIPTION_IN_GRACE_PERIOD` | access (grace) |
| 7 | `SUBSCRIPTION_RESTARTED` | active |
| 9 | `SUBSCRIPTION_DEFERRED` | extend expiry |
| 10 | `SUBSCRIPTION_PAUSED` | no access while paused |
| 11 | `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` | no change now |
| 12 | `SUBSCRIPTION_REVOKED` | revoke immediately |
| 13 | `SUBSCRIPTION_EXPIRED` | expired |
More types exist and new ones are added; never switch only on this table. The
`subscriptionsv2.get` response has `subscriptionState` (`SUBSCRIPTION_STATE_ACTIVE`,
`_IN_GRACE_PERIOD`, `_ON_HOLD`, `_PAUSED`, `_CANCELED`, `_EXPIRED`,
`_PENDING_PURCHASE_CANCELED`, ...) and per-line-item `expiryTime`; derive access from those.
`oneTimeProductNotification`: purchased or canceled. On purchased, verify with
`purchases.products.get`, grant, then acknowledge or consume.
`voidedPurchaseNotification`: a refund or chargeback. Revoke what that `purchaseToken`
granted; for consumables, decide whether to claw back balance (product decision).
## Linking store records to users
- Apple: set `appAccountToken` (a UUID you store per user) at purchase; it appears in the
transaction and notifications.
- Google: set `obfuscatedAccountId` at purchase; read it from the purchase resource.
- Purchases without a token (made before login, or restored on a new account) need a claim
step: the client sends the transaction or purchase token after login and the server
links it if no other user owns it. Decide what happens on a conflict (support ticket,
move, or refuse) and write it down.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.