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.

  • Mobile & Desktop
  • $in-app-purchases
  • 166 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- in-app-purchases

Installs to ~/.agents/skills/in-app-purchases/ for every project on this machine.

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

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.

~/.agents/skills/in-app-purchases/SKILL.md

--- name: in-app-purchases description: Implements in-app purchases and subscriptions with StoreKit 2, Google Play Billing Library and RevenueCat - product setup, purchase flows,… license: MIT ---

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

  1. Read AGENTS.md, then find existing billing code, the entitlement model on the server, and whether RevenueCat, Adapty or another SDK is already present.
  2. 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).
  3. Confirm accounts: the app’s user ID, how it maps to store transactions (appAccountToken on iOS, obfuscatedAccountId on Android), and what happens for purchases made while logged out.
  4. 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

SituationChoice
Subscriptions on both platforms, small team, want analytics and paywall experimentsRevenueCat (or similar): it runs receipt validation, webhooks and entitlement state
Existing backend with user accounts, need full data ownershipNative StoreKit 2 + Play Billing, server verification described below
A single non-consumable purchase on iOSStoreKit 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.updates listener 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 show product.displayPrice.
  • Restore: try await AppStore.sync() only behind an explicit “Restore Purchases” button (it may prompt for the Apple Account password). Read state from Transaction.currentEntitlements.
  • Subscription status: product.subscription?.status gives renewal state and grace period.
  • verifyReceipt and 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).

  1. Build BillingClient with a PurchasesUpdatedListener and enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()).
  2. startConnection; on disconnect, reconnect with backoff before the next call.
  3. queryProductDetailsAsync for the product IDs; for subscriptions pick an offerToken from subscriptionOfferDetails.
  4. launchBillingFlow with ProductDetailsParams and setObfuscatedAccountId(hash(userId)).
  5. In the listener and on every app start (queryPurchasesAsync), for each purchase with purchaseState == PURCHASED: send purchaseToken to the server, which verifies it and then acknowledges (acknowledge API or consumeAsync for consumables).
  6. PENDING purchases grant nothing until they move to PURCHASED.

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

TaskAppleGoogle
Verify a purchaseVerify the signed transaction JWS (App Store Server Library)purchases.products.get / purchases.subscriptionsv2.get
Current subscription stateApp Store Server API Get All Subscription Statusespurchases.subscriptionsv2.get
Change eventsApp Store Server Notifications V2 (signed payload)Real-time Developer Notifications via Cloud Pub/Sub
Refunds / revocationsREFUND, REVOKE notificationsvoidedPurchaseNotification, 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 .storekit configuration file in the Xcode scheme; test renewals at accelerated rates, Ask to Buy, refunds and billing failures from Xcode’s transaction manager. Unit-test with SKTestSession.
  • 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.updates and 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.yamlgenerated from this page

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.md93 lines, 5.3 KB
# 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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem