All skills
rshankras avatar

/paywall-generator

@32566aa

Generates StoreKit 2 subscription paywall with modern SwiftUI views. Use when user wants to add subscriptions, paywall, or in-app purchases.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/paywall-generator

This session only. Nothing lands on disk.

storekit-patterns.md

≈2.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

StoreKit 2 Patterns and Best Practices

StoreKit 2 vs StoreKit 1

Feature StoreKit 1 StoreKit 2
API Style Delegate-based Async/await
Transaction Verification Manual Built-in JWS
Subscription Status Complex polling Simple async
Testing Limited StoreKit Testing in Xcode
Minimum iOS iOS 3+ iOS 15+

Core Concepts

Products

// Load products
let productIDs = ["com.app.monthly", "com.app.yearly"]
let products = try await Product.products(for: productIDs)

// Product properties
for product in products {
    print(product.displayName)       // "Monthly Pro"
    print(product.displayPrice)      // "$4.99"
    print(product.subscription?.subscriptionPeriod)  // 1 month
}

Transactions

// Purchase
let result = try await product.purchase()

switch result {
case .success(let verification):
    let transaction = try checkVerified(verification)
    await transaction.finish()  // Important!

case .userCancelled:
    break

case .pending:
    // Waiting for approval (Ask to Buy)
    break

@unknown default:
    break
}

Transaction Verification

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .verified(let safe):
        return safe
    case .unverified(_, let error):
        throw StoreError.verificationFailed(error)
    }
}

Subscription Status (iOS 18.4+)

Using subscriptionStatusTask

@main
struct MyApp: App {
    @State private var status: SubscriptionStatus = .unknown

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.subscriptionStatus, status)
                .subscriptionStatusTask(for: "your.group.id") { statuses in
                    status = determineStatus(from: statuses)
                }
        }
    }
}

func determineStatus(from statuses: [Product.SubscriptionInfo.Status]) -> SubscriptionStatus {
    for status in statuses {
        switch status.state {
        case .subscribed:
            return .subscribed
        case .inGracePeriod:
            return .subscribed  // Still has access
        case .inBillingRetryPeriod:
            return .subscribed  // Still has access
        case .expired:
            continue  // Check other statuses
        case .revoked:
            continue
        default:
            continue
        }
    }
    return .notSubscribed
}

Transaction.currentEntitlements (iOS 18.4+)

// New API for checking entitlements
for await result in Transaction.currentEntitlements(for: productID) {
    if case .verified(let transaction) = result {
        // User is entitled to this product
        print("Entitled via transaction: \(transaction.id)")
    }
}

SubscriptionOfferView (iOS 18.4+)

Basic Usage

// Simple subscription merchandising
SubscriptionOfferView(productID: "com.app.monthly")

// With custom icon
SubscriptionOfferView(productID: "com.app.monthly") {
    Image("premium_icon")
        .resizable()
        .frame(width: 40, height: 40)
}

// With detail action
SubscriptionOfferView(productID: "com.app.monthly")
    .subscriptionOfferViewDetailAction {
        showFullPaywall = true
    }

Visible Relationship

// Show upgrade options for current subscribers
SubscriptionOfferView(groupID: "your.group.id", visibleRelationship: .upgrade)

// Options:
// .upgrade - Higher tier than current
// .downgrade - Lower tier
// .crossgrade - Equivalent tier
// .current - Current plan
// .all - All plans

RenewalInfo (iOS 18.4+)

Expiration Reasons

if let renewalInfo = status.renewalInfo,
   let reason = renewalInfo.expirationReason {
    switch reason {
    case .autoRenewDisabled:
        // User turned off auto-renew
        showWinBackOffer()

    case .billingError:
        // Payment failed
        promptToUpdatePaymentMethod()

    case .didNotConsentToPriceIncrease:
        // User declined price increase
        showSpecialOffer()

    case .productUnavailable:
        // Product no longer available
        break

    default:
        break
    }
}

Paywall Design Patterns

Subscription Comparison

struct PaywallView: View {
    let products: [Product]

    var body: some View {
        VStack(spacing: 20) {
            // Hero section
            VStack {
                Image(systemName: "crown.fill")
                    .font(.system(size: 60))
                Text("Unlock Premium")
                    .font(.largeTitle.bold())
            }

            // Plans
            ForEach(sortedProducts) { product in
                SubscriptionButton(
                    product: product,
                    isPopular: product.id.contains("yearly")
                )
            }

            // Features list
            FeaturesList()

            // Terms
            TermsAndPrivacyLinks()
        }
    }
}

Popular Badge

struct SubscriptionButton: View {
    let product: Product
    let isPopular: Bool

    var body: some View {
        VStack {
            if isPopular {
                Text("MOST POPULAR")
                    .font(.caption.bold())
                    .foregroundColor(.white)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .background(Color.orange)
                    .cornerRadius(4)
            }

            // Plan details...
        }
    }
}

Savings Calculation

func calculateSavings(monthly: Product, yearly: Product) -> Int? {
    guard let monthlyPrice = monthly.subscription?.price,
          let yearlyPrice = yearly.subscription?.price else {
        return nil
    }

    let yearlyIfMonthly = monthlyPrice * 12
    let savings = yearlyIfMonthly - yearlyPrice
    let percentage = (savings / yearlyIfMonthly) * 100

    return Int(percentage.rounded())
}

Error Handling

Purchase Errors

func purchase(_ product: Product) async {
    do {
        let result = try await product.purchase()
        // Handle result...
    } catch StoreKitError.userCancelled {
        // User cancelled - not an error
    } catch StoreKitError.notAvailableInStorefront {
        showError("Not available in your region")
    } catch StoreKitError.networkError {
        showError("Network error. Please try again.")
    } catch {
        showError("Purchase failed: \(error.localizedDescription)")
    }
}

Restore Purchases

func restorePurchases() async {
    do {
        try await AppStore.sync()
        // Transactions will be delivered to Transaction.updates
    } catch {
        showError("Failed to restore: \(error.localizedDescription)")
    }
}

Transaction Updates Listener

// Start on app launch
func listenForTransactions() -> Task<Void, Error> {
    Task.detached {
        for await result in Transaction.updates {
            do {
                let transaction = try self.checkVerified(result)
                await self.updateSubscriptionStatus()
                await transaction.finish()
            } catch {
                // Handle verification failure
            }
        }
    }
}

Testing Best Practices

StoreKit Configuration

// Products.storekit
{
    "products": [
        {
            "id": "com.app.monthly",
            "type": "auto-renewable",
            "displayName": "Monthly",
            "price": 4.99,
            "subscriptionPeriod": "P1M"
        }
    ]
}

Transaction Manager

  • Debug > StoreKit > Transaction Manager
  • Create transactions manually
  • Test expiration, renewal, cancellation

Time Acceleration

  • StoreKit Testing accelerates time
  • 1 month = 5 minutes (default)
  • Test renewals quickly

Anti-Patterns to Avoid

Don't Forget to Finish Transactions

// Bad - transaction never finished
let result = try await product.purchase()
if case .success = result {
    unlockFeature()
}

// Good - always finish
let result = try await product.purchase()
if case .success(let verification) = result {
    let transaction = try checkVerified(verification)
    unlockFeature()
    await transaction.finish()  // Important!
}

Don't Rely Only on Receipt

// Bad - checking receipt locally
if hasReceipt() { unlock() }

// Good - verify transaction
for await result in Transaction.currentEntitlements {
    if case .verified = result {
        unlock()
    }
}

Don't Block UI on Product Load

// Bad - blocking
let products = try await Product.products(for: ids)
// Show UI

// Good - show loading state
struct PaywallView: View {
    @State private var products: [Product] = []
    @State private var isLoading = true

    var body: some View {
        Group {
            if isLoading {
                ProgressView()
            } else {
                ProductList(products: products)
            }
        }
        .task {
            products = try await loadProducts()
            isLoading = false
        }
    }
}

Source: SKILL.md on GitHub

1 warning2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The paywall-generator skill is a development utility for creating StoreKit 2 subscription views. It includes standard SwiftUI templates and management logic. The analysis identified a potential surface for indirect prompt injection as the skill analyzes existing project source files to determine integration points. No malicious code, exfiltration, or unauthorized remote execution was detected.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: MEDIUM · 1 issue

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/paywall-generator