ios-monetization

StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, paywall UI, receipt validation, or App Store Connect configuration.

Best use case

ios-monetization is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, paywall UI, receipt validation, or App Store Connect configuration.

Teams using ios-monetization should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/ios-monetization/SKILL.md --create-dirs "https://raw.githubusercontent.com/peterbamuhigire/skills-web-dev/main/skills/ios/ios-monetization/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/ios-monetization/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How ios-monetization Compares

Feature / Agentios-monetizationStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, paywall UI, receipt validation, or App Store Connect configuration.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# iOS Monetization — StoreKit 2
Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com, +256 784 464178.

<!-- dual-compat-start -->
## Use When

- StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, paywall UI, receipt validation, or App Store Connect configuration.
- The task needs reusable judgment, domain constraints, or a proven workflow rather than ad hoc advice.

## Do Not Use When

- The task is unrelated to `ios-monetization` or would be better handled by a more specific companion skill.
- The request only needs a trivial answer and none of this skill's constraints or references materially help.

## Required Inputs

- Gather relevant project context, constraints, and the concrete problem to solve.
- Confirm the desired deliverable: design, code, review, migration plan, audit, or documentation.

## Workflow

- Read this `SKILL.md` first, then load only the referenced deep-dive files that are necessary for the task.
- Apply the ordered guidance, checklists, and decision rules in this skill instead of cherry-picking isolated snippets.
- Produce the deliverable with assumptions, risks, and follow-up work made explicit when they matter.

## Quality Standards

- Keep outputs execution-oriented, concise, and aligned with the repository's baseline engineering standards.
- Preserve compatibility with existing project conventions unless the skill explicitly requires a stronger standard.
- Prefer deterministic, reviewable steps over vague advice or tool-specific magic.

## Anti-Patterns

- Treating examples as copy-paste truth without checking fit, constraints, or failure modes.
- Loading every reference file by default instead of using progressive disclosure.

## Outputs

- A concrete result that fits the task: implementation guidance, review findings, architecture decisions, templates, or generated artifacts.
- Clear assumptions, tradeoffs, or unresolved gaps when the task cannot be completed from available context alone.
- References used, companion skills, or follow-up actions when they materially improve execution.

## Evidence Produced

| Category | Artifact | Format | Example |
|----------|----------|--------|---------|
| Correctness | StoreKit 2 purchase flow test plan | Markdown doc covering consumable, non-consumable, subscription, and restore flows | `docs/ios/storekit-tests-checkout.md` |
| Release evidence | App Store subscription configuration record | Markdown doc capturing product IDs, pricing tiers, and intro offers per region | `docs/ios/subscription-config-2026-04-16.md` |

## References

- Use the links and companion skills already referenced in this file when deeper context is needed.
<!-- dual-compat-end -->
## Architecture Principles

StoreKit 2 is async/await-native. The entire `SKPaymentQueue`/delegate callback model is abandoned. Every purchase, verification, and entitlement check is a Swift concurrency operation.

**Transaction observer is not optional.** It must start at app entry point — before any UI renders. Transactions that completed while the app was terminated are delivered via `Transaction.updates` on next launch. Starting this loop in the paywall means you silently drop those deliveries.

```swift
// App.swift — Swift 6 actor-isolated entry point
@main
struct MyApp: App {
    @StateObject private var store = StoreService()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
                .task { await store.observeTransactions() }
        }
    }
}
```

---

## StoreService — Core Implementation

```swift
import StoreKit

@MainActor
final class StoreService: ObservableObject {
    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []

    private var transactionObserver: Task<Void, Never>?

    init() {
        transactionObserver = Task { await observeTransactions() }
    }

    deinit {
        transactionObserver?.cancel()
    }

    // MARK: - Product Loading

    func loadProducts(ids: [String]) async {
        do {
            products = try await Product.products(for: ids)
            // Products come back unordered — sort by price or custom order
            products.sort { $0.price < $1.price }
        } catch {
            // StoreKitError.networkError — retry with backoff
            // StoreKitError.notEntitled — sandbox/config issue
        }
    }

    // MARK: - Transaction Observer (MUST run for app lifetime)

    func observeTransactions() async {
        for await result in Transaction.updates {
            await process(result)
        }
    }

    // MARK: - Purchase

    func purchase(_ product: Product,
                  options: Set<Product.PurchaseOption> = []) async throws -> Transaction? {
        let result = try await product.purchase(options: options)
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updateEntitlements(transaction)
            await transaction.finish()   // CRITICAL — unfinished = re-delivered on next launch
            return transaction
        case .userCancelled:
            return nil
        case .pending:
            // Awaiting Ask to Buy or billing fix — show "payment pending" UI
            return nil
        @unknown default:
            return nil
        }
    }

    // MARK: - Entitlement Refresh

    func refreshEntitlements() async {
        purchasedProductIDs.removeAll()
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            guard transaction.revocationDate == nil else { continue }  // Apple refunded
            purchasedProductIDs.insert(transaction.productID)
        }
    }

    // MARK: - Restore

    func restore() async throws {
        // AppStore.sync() triggers re-validation + re-delivery of current entitlements
        // Do NOT use SKPaymentQueue.restoreCompletedTransactions — it is deprecated
        try await AppStore.sync()
        await refreshEntitlements()
    }

    // MARK: - Private

    private func process(_ result: VerificationResult<Transaction>) async {
        await updateEntitlements(try? checkVerified(result))
    }

    private func updateEntitlements(_ transaction: Transaction?) async {
        guard let transaction else { return }
        if transaction.revocationDate == nil {
            purchasedProductIDs.insert(transaction.productID)
        } else {
            purchasedProductIDs.remove(transaction.productID)
        }
    }

    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            // JWS signature invalid — tampered receipt or configuration error
            throw StoreError.failedVerification
        case .verified(let value):
            return value
        }
    }
}

enum StoreError: LocalizedError {
    case failedVerification
    var errorDescription: String? { "Purchase could not be verified." }
}
```

---

## Subscription Status — Full Detail

`Transaction.currentEntitlements` gives current owned state. `Product.subscription?.status` gives renewal metadata.

```swift
struct SubscriptionStatus {
    let isActive: Bool
    let willAutoRenew: Bool
    let expirationDate: Date?
    let isInBillingRetry: Bool
    let scheduledDowngradeProductID: String?
}

func subscriptionStatus(for product: Product) async -> SubscriptionStatus? {
    guard let subscription = product.subscription,
          let statusArray = try? await subscription.status else { return nil }

    // statusArray contains one entry per subscription in the group
    for status in statusArray {
        guard case .verified(let renewalInfo) = status.renewalInfo,
              case .verified(let transaction) = status.transaction else { continue }

        let isActive = status.state == .subscribed || status.state == .inGracePeriod
        return SubscriptionStatus(
            isActive: isActive,
            willAutoRenew: renewalInfo.willAutoRenew,
            expirationDate: transaction.expirationDate,
            isInBillingRetry: status.state == .inBillingRetryPeriod,
            scheduledDowngradeProductID: renewalInfo.autoRenewProductID != product.id
                ? renewalInfo.autoRenewProductID : nil
        )
    }
    return nil
}
```

Subscription states to handle:

| State | Meaning | Action |
|---|---|---|
| `.subscribed` | Active | Full access |
| `.inGracePeriod` | Billing failed, grace period active | Full access + soft prompt |
| `.inBillingRetryPeriod` | Grace expired, Apple retrying | Restricted access + hard prompt |
| `.expired` | Lapsed | Paywall |
| `.revoked` | Family sharing revoked | Remove access immediately |

---

## Introductory Offers

Intro offers are Apple ID-scoped — once consumed, the user is ineligible forever. Check before displaying.

```swift
func introOfferDetails(for product: Product) async -> Product.SubscriptionOffer? {
    guard let subscription = product.subscription,
          await subscription.isEligibleForIntroOffer == true else { return nil }
    return subscription.introductoryOffer
}

// Render based on paymentMode
func introLabel(_ offer: Product.SubscriptionOffer) -> String {
    switch offer.paymentMode {
    case .freeTrial:
        return "Free for \(offer.period.localizedDescription)"
    case .payAsYouGo:
        return "\(offer.displayPrice)/\(offer.period.value) \(offer.period.unit) for \(offer.periodCount) periods"
    case .payUpFront:
        return "\(offer.displayPrice) for \(offer.periodCount) periods"
    @unknown default:
        return offer.displayPrice
    }
}
```

---

## Promotional Offers (Win-Back / Loyalty)

Promotional offers require a server-generated signature. The signature proves your server authorised the discount.

```swift
// Server returns: keyID, nonce, signature, timestamp
let offerID = "annual_winback_50"
let signature = try await fetchPromoSignature(productID: product.id, offerID: offerID)

let purchaseOption = Product.PurchaseOption.promotionalOffer(
    offerID: offerID,
    keyID: signature.keyID,
    nonce: signature.nonce,
    signature: signature.data,
    timestamp: signature.timestamp
)
let transaction = try await storeService.purchase(product, options: [purchaseOption])
```

Never hard-code or generate signatures client-side — App Store will reject them.

---

## Paywall ViewModel

```swift
@MainActor
final class PaywallViewModel: ObservableObject {
    enum PurchaseState: Equatable {
        case idle, loading, purchasing, purchased, failed(String)
    }

    @Published var products: [Product] = []
    @Published var purchaseState: PurchaseState = .idle
    @Published var selectedProduct: Product?
    @Published var introOffer: Product.SubscriptionOffer?

    private let store: StoreService

    init(store: StoreService) {
        self.store = store
    }

    func load(productIDs: [String]) async {
        purchaseState = .loading
        await store.loadProducts(ids: productIDs)
        products = store.products
        selectedProduct = products.first(where: { $0.type == .autoRenewable })
        if let selected = selectedProduct {
            introOffer = await introOfferDetails(for: selected)
        }
        purchaseState = .idle
    }

    func purchase() async {
        guard let product = selectedProduct else { return }
        purchaseState = .purchasing
        do {
            guard try await store.purchase(product) != nil else {
                // userCancelled or pending — no error, just reset
                purchaseState = .idle
                return
            }
            purchaseState = .purchased
        } catch {
            purchaseState = .failed(error.localizedDescription)
        }
    }

    func restore() async {
        purchaseState = .loading
        do {
            try await store.restore()
            purchaseState = store.purchasedProductIDs.isEmpty ? .idle : .purchased
        } catch {
            purchaseState = .failed(error.localizedDescription)
        }
    }
}
```

---

## Receipt Validation — JWS vs Legacy

StoreKit 2 signs every transaction as a JWS (JSON Web Signature). You do not need the old base64 `appReceipt` + `/verifyReceipt` endpoint.

**Client-side (sufficient for most apps):**
`VerificationResult.verified` means Apple's signature checked out locally. Use this.

**Server-side (required for high-value entitlements, fraud prevention):**

```
1. Decode JWS: split by ".", base64url-decode payload
2. Verify signature using Apple's public key from WWDR certificate chain
3. Check: environment, bundleID, productID, expirationDate, revocationDate
4. Use App Store Server API for real-time status (not polled receipts)
```

App Store Server Notifications v2 (webhooks) push events to your server:
- `DID_RENEW`, `EXPIRED`, `REFUND`, `GRACE_PERIOD_EXPIRED`, `REVOKE`

Register the endpoint in App Store Connect > App Information > App Store Server Notifications.

---

## Consumables — Delivery Pattern

Consumables are **not** tracked by `Transaction.currentEntitlements`. You must persist delivery yourself.

```swift
func purchaseConsumable(_ product: Product) async throws {
    guard let transaction = try await store.purchase(product) else { return }
    // Deliver immediately before finish — if app crashes between deliver+finish,
    // transaction re-delivers on next launch via Transaction.updates
    await deliverConsumable(transaction.productID, quantity: transaction.purchasedQuantity)
    await transaction.finish()
}

// Idempotency: store transaction.id in your DB — re-delivery must not double-grant
func deliverConsumable(_ productID: String, quantity: Int) async {
    // Check if transaction.id already processed before crediting
}
```

---

## App Store Connect Configuration — Critical Steps

1. Create IAPs **before** running on device — Xcode cannot synthesise them
2. Subscription Group required before adding Auto-Renewable subscriptions; group name is user-visible in cancellation flow
3. All tiers in the same subscription group share one active subscription; Apple handles proration on upgrades automatically
4. Localisations on products are required — missing localisation = product not returned by `Product.products(for:)`
5. Tax categories must be set (Software, Newspaper, etc.) — affects storefront availability
6. Pricing: set a base territory first, then "Sync" to all territories — do not set each manually
7. Sandbox testers created in App Store Connect > Users and Access > Sandbox Testers — use a new Apple ID, not your own

---

## StoreKit Configuration File (Local Testing)

Add a `.storekit` file to the Xcode project, configure via Edit Scheme > Run > Options > StoreKit Configuration. This bypasses App Store Connect entirely.

Non-obvious capabilities of the config file:
- Simulate interrupted purchases (requires StoreKit testing in-process)
- Set transaction speed to "Monthly" = 1 minute, "Annual" = 12 minutes in sandbox
- Trigger refunds and revocations from Xcode debug menu
- Test subscription state transitions without waiting for real time

```swift
// In XCTest — use SKTestSession to script scenarios
import StoreKitTest

class SubscriptionTests: XCTestCase {
    var session: SKTestSession!

    override func setUp() async throws {
        session = try SKTestSession(configurationFileNamed: "Products")
        session.resetToDefaultState()
        session.disableDialogs = true
        session.timeRate = .monthlyRenewalEveryThirtySeconds
    }

    func testSubscriptionRenews() async throws {
        let store = StoreService()
        // purchase → wait 30s → verify renewal transaction delivered
    }
}
```

---

## Anti-Patterns

| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Start `Transaction.updates` in paywall | Miss offline/terminated-app purchases | Start in App init or `@main` `.task` |
| Forget `transaction.finish()` | Re-delivered on every launch, double grants | Always finish after delivery |
| Use `SKPaymentQueue.restoreCompletedTransactions` | Deprecated, triggers App Store login alert unnecessarily | Use `AppStore.sync()` |
| Poll `isSubscribed()` on every `onAppear` | Rate limiting, perf degradation | Cache state, invalidate on `Transaction.updates` |
| Trust `.unverified` transactions | Security hole — spoofed purchase | Always throw/ignore unverified |
| Consumable delivery after `finish()` | Lost delivery if crash between them | Deliver first, then finish |
| Test with production Apple ID | Real charges, irreversible | Always use sandbox account |
| One product ID for multiple tiers | Cannot offer upgrade pricing or group logic | Separate product per tier |
| Not handling `.pending` state | User sees no feedback; assume purchase failed | Show "payment pending" UI |
| Client-side promo offer signatures | Rejected by App Store | Server-generated only |
| Infer subscription active from purchase date + duration | Clock skew, grace periods, billing retry | Use `Transaction.currentEntitlements` |
| Show intro offer without eligibility check | Offer silently fails; user confused | Always check `isEligibleForIntroOffer` |

---

## Launch Checklist

- [ ] `Transaction.updates` loop started at app entry point, not in paywall
- [ ] All transactions finished with `transaction.finish()` after delivery
- [ ] `Transaction.currentEntitlements` queried on app launch to restore entitlement state
- [ ] `transaction.revocationDate != nil` check before granting access
- [ ] Introductory offer eligibility verified before displaying offer UI
- [ ] `AppStore.sync()` called from Restore Purchases button
- [ ] `.pending` purchase state handled with visible user feedback
- [ ] Consumable delivery is idempotent (transaction ID deduplication)
- [ ] Sandbox test accounts created in App Store Connect
- [ ] StoreKit Config File added for local automated testing
- [ ] App Store Server Notifications v2 endpoint registered for subscriptions
- [ ] Server-side JWS validation implemented for high-value entitlements
- [ ] Subscription group configured in App Store Connect before testing
- [ ] All product localisations complete — missing localisation silently drops product

Related Skills

web-app-security-audit

8
from peterbamuhigire/skills-web-dev

Use when auditing a PHP/JavaScript/HTML web application for security vulnerabilities. Covers configuration, authentication, authorization, input validation, XSS, API security, HTTP headers, and dependency scanning. Produces a severity-rated audit...

vibe-security-skill

8
from peterbamuhigire/skills-web-dev

Use when designing or reviewing security for a web application, API, or multi-tenant SaaS — produces threat model, abuse case list, auth/authz matrix, and secret handling plan; covers OWASP Top 10 2025 and the AI-code-generation blind spots. Neighbours — api-design-first owns auth model fields, deployment-release-engineering owns secret rotation choreography, ai-security and llm-security own model-specific threats.

network-security

8
from peterbamuhigire/skills-web-dev

Use when designing, hardening, or auditing network-layer security for self-managed Debian/Ubuntu SaaS infrastructure — firewalls (nftables/UFW), WAF (ModSecurity + OWASP CRS), VPN (WireGuard, OpenVPN, IPsec), TLS/PKI ops, IDS/IPS (Suricata, Fail2ban), zero-trust, SSH hardening, DDoS mitigation, DNS security. Complements web-app-security-audit (app layer) and cicd-devsecops (secrets/CI).

linux-security-hardening

8
from peterbamuhigire/skills-web-dev

Use when hardening a Debian/Ubuntu server — user/group/sudo hardening, file permission audits, PAM password policy + MFA, AppArmor mandatory access control, auditd system call logging, kernel sysctl hardening, file integrity monitoring (AIDE), rootkit detection (rkhunter/chkrootkit), unattended security patching, GRUB + UEFI + LUKS boot security, and CIS benchmark compliance.

dpia-generator

8
from peterbamuhigire/skills-web-dev

Generate a Data Protection Impact Assessment (DPIA), Uganda DPPA 2019-compliant. Use when producing or reviewing a data protection impact assessment, a privacy impact assessment, when uganda-dppa-compliance flags [DPIA-REQUIRED], or when processing large-scale or sensitive personal data for a new feature.

code-safety-scanner

8
from peterbamuhigire/skills-web-dev

Scan any codebase for 14 critical safety issues across security vulnerabilities, server stability (500 errors), and payment misconfigurations. Use when auditing code before deployment, reviewing AI-generated code for production readiness, or...

world-class-engineering

8
from peterbamuhigire/skills-web-dev

Use when designing, building, reviewing, or upgrading production software systems that must be secure, performant, maintainable, scalable, and user-centered. Apply before writing specs, code, architecture, APIs, databases, mobile apps, SaaS platforms, or ERP systems.

update-Codex-documentation

8
from peterbamuhigire/skills-web-dev

Update project documentation files (README.md, PROJECT_BRIEF.md, TECH_STACK.md, ARCHITECTURE.md, docs/API.md, docs/DATABASE.md, AGENTS.md, docs/plans/NEXT_FEATURES.md) when significant changes occur. MANDATORY at end of each work session to...

skill-writing

8
from peterbamuhigire/skills-web-dev

Use when creating or upgrading skills in this repository. Covers repository-specific frontmatter rules, progressive disclosure, reference-file strategy, validation, and the quality bar required for production-grade engineering skills.

skill-safety-audit

8
from peterbamuhigire/skills-web-dev

Scan new or updated skills for unsafe or malicious instructions (unknown tools, external installers, credential harvesting) before accepting them into the repository.

skill-composition-standards

8
from peterbamuhigire/skills-web-dev

Use when authoring a new skill, normalising an older skill, or reviewing a skill PR — defines the repository-wide house style (frontmatter, decision rules, anti-patterns, references), the output contracts each baseline-skill type must produce, and the input contracts each specialist skill must declare. This is the enforcement spine that makes the repository compose as a system, not a library of linked documents.

sdlc-documentation

8
from peterbamuhigire/skills-web-dev

Use when producing, reviewing, or consolidating SDLC documentation across planning, requirements, design, testing, deployment, user rollout, post-deployment, and maintenance phases. Load absorbed SDLC phase references as needed.