Ios Monetization logo

Ios Monetization

Community
peterbamuhigire
ios-monetization

Use when implementing iOS purchases, subscriptions, StoreKit 2 verification, entitlement delivery, paywalls, or App Store server events; use ios-development for non-commerce features.

Overview

Publisherpeterbamuhigire
Repositorychwezi-dev-engine
Skill nameios-monetization
Stars
29
Forks
6
Bundled files
1
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 1 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by peterbamuhigire on GitHub. Read the source before you install it.

Installation

Install the Ios Monetization AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/peterbamuhigire/chwezi-dev-engine/tree/main/skills/ios/ios-monetization
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/peterbamuhigire/chwezi-dev-engine.git /tmp/chwezi-dev-engine
mkdir -p .claude/skills
cp -r /tmp/chwezi-dev-engine/skills/ios/ios-monetization .claude/skills/ios-monetization
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Ios Monetization in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Ios Monetization on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Ios Monetization is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

iOS Monetization — StoreKit 2

Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com, +256 784 464178.

Use When

  • StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, group or organization subscription watch items, Unity StoreKit plugin routing, paywall UI, receipt validation, or App Store Connect configuration.

Evidence Produced

CategoryArtifactFormatExample
CorrectnessStoreKit 2 purchase flow test planMarkdown doc covering consumable, non-consumable, subscription, and restore flowsdocs/ios/storekit-tests-checkout.md
Release evidenceApp Store subscription configuration recordMarkdown doc capturing product IDs, pricing tiers, and intro offers per regiondocs/ios/subscription-config-2026-04-16.md

References

  • Use the links and companion skills already referenced in this file when deeper context is needed.

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.

WWDC26 App Store Watch Items

  • Review current Apple In-App Purchase and subscription sessions before using new group or organization subscription behavior.
  • Unity projects should review Apple's official Unity StoreKit plugin before building a custom bridge.
  • Treat App Store Small Business Program, Private Cloud Compute model access, and subscription eligibility as separate business rules; do not mix them in entitlement code.
  • Keep StoreKit test scenarios in Xcode 27 current: interrupted purchases, refunds, revocations, billing retry, grace period, Ask to Buy, and restore.

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:

StateMeaningAction
.subscribedActiveFull access
.inGracePeriodBilling failed, grace period activeFull access + soft prompt
.inBillingRetryPeriodGrace expired, Apple retryingRestricted access + hard prompt
.expiredLapsedPaywall
.revokedFamily sharing revokedRemove 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-PatternConsequenceFix
Start Transaction.updates in paywallMiss offline/terminated-app purchasesStart in App init or @main .task
Forget transaction.finish()Re-delivered on every launch, double grantsAlways finish after delivery
Use SKPaymentQueue.restoreCompletedTransactionsDeprecated, triggers App Store login alert unnecessarilyUse AppStore.sync()
Poll isSubscribed() on every onAppearRate limiting, perf degradationCache state, invalidate on Transaction.updates
Trust .unverified transactionsSecurity hole — spoofed purchaseAlways throw/ignore unverified
Consumable delivery after finish()Lost delivery if crash between themDeliver first, then finish
Test with production Apple IDReal charges, irreversibleAlways use sandbox account
One product ID for multiple tiersCannot offer upgrade pricing or group logicSeparate product per tier
Not handling .pending stateUser sees no feedback; assume purchase failedShow "payment pending" UI
Client-side promo offer signaturesRejected by App StoreServer-generated only
Infer subscription active from purchase date + durationClock skew, grace periods, billing retryUse Transaction.currentEntitlements
Show intro offer without eligibility checkOffer silently fails; user confusedAlways check isEligibleForIntroOffer

Launch Checklist

Decision Rules

ProductStoreKit choice
Durable unlockNon-consumable
Renewable time-based accessAuto-renewable subscription with server entitlement state
Repeatable unit consumed by useConsumable with idempotent delivery ledger
Purchase cannot be cryptographically verifiedWithhold entitlement and retry verification

Degraded Mode

Without App Store Connect, sandbox accounts, or server notifications, produce configuration and test matrices but mark purchase, restore, renewal, refund, grace-period, and revocation paths unverified. If a required capability is unavailable, withhold launch approval for the affected purchase path.

Domain Anti-Patterns

  • Granting entitlement before transaction verification. Fix: verify first, then record delivery atomically.

  • Trusting only local subscription state. Fix: reconcile signed server events and current status.

  • Delivering a consumable twice after retry. Fix: key delivery by transaction identifier.

  • Omitting restore behaviour. Fix: expose and test a restore path.

  • Hiding price or renewal terms. Fix: display StoreKit-localised terms before purchase.

  • 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

Inputs

ArtefactRequired?Purpose
Product catalogue, entitlement rules, StoreKit configuration, pricing, and server contractyesDesign purchase lifecycle

Outputs

  • Produce monetisation implementation or design with entitlement, receipt, restore, refund, and test evidence.

Capability contract

Store configuration, price changes, sandbox purchases, receipt validation, and entitlement mutation require explicit environment and account authority.

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Ios Monetization AI skill do?

Use when implementing iOS purchases, subscriptions, StoreKit 2 verification, entitlement delivery, paywalls, or App Store server events; use ios-development for non-commerce features.

Why use Ios Monetization on TypingMind?

Because you install it once and use it with any model. Ios Monetization is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Ios Monetization in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/peterbamuhigire/chwezi-dev-engine/tree/main/skills/ios/ios-monetization. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Ios Monetization?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Ios Monetization?

As many as you like. As long as a model supports skills, you can use Ios Monetization with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Ios Monetization AI skill free?

Yes. It is published on GitHub by peterbamuhigire under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇