Paywall & purchases
The IAP backend lives behind one protocol — PurchaseProvider — so your UI never changes as you go from demo to StoreKit to RevenueCat.
Configure the paywall
PaywallConfig(
headline: "Reach {goal} faster with Pro", // {goal} is filled from onboarding
subheadline: "Everything you need.",
features: ["Feature one", "Feature two", "No ads"],
productIDs: ["com.myapp.pro.annual", "com.myapp.pro.monthly"],
allowSkip: true, // false = hard paywall
ctaText: "Start free trial",
footnote: "7 days free, then billed. Cancel anytime."
)
Annual default with a "best value" badge, two plans (not six), and an honest offer — trial length, renewal price, and cancellation stated plainly. Dark patterns get refunds, bad reviews, and App Review rejections.
Read subscription state
@EnvironmentObject var purchases: PurchaseController
// purchases.isSubscribed -> gate your Pro features
The providers
PreviewPurchaseProvider()— dev and demo; no App Store Connect needed.StoreKitPurchaseProvider(productIDs:)— real StoreKit 2.RevenueCatPurchaseProvider(apiKey:entitlementID:)— fromDeliciousKitRevenueCat(see below). Nothing else changes.
See Going to production to flip the switch. The paywall craft and pricing strategy live in the Delicious Monetization skill.
RevenueCat
import DeliciousKitRevenueCat to swap in RevenueCat instead of raw StoreKit — no UI changes required, since both conform to the same PurchaseProvider:
LaunchGate(config: .myApp,
provider: RevenueCatPurchaseProvider(apiKey: "appl_xxx", entitlementID: "pro")) {
ContentView()
}
entitlementID must match the entitlement identifier configured in your RevenueCat dashboard exactly — a typo here is one of the most common and hardest-to-spot integration bugs. Run the doctor check in debug builds to catch it before you ship.
Consumable / credit purchases
Not every purchase is a subscription. For AI generations, exports, or anything with a real per-use cost, use CreditLedger alongside your PurchaseProvider:
let ledger = CreditLedger()
// After a successful consumable purchase (StoreKit or RevenueCat):
ledger.grant(50, to: "ai_credits")
// Before running a metered action:
if ledger.spend(1, from: "ai_credits") {
// run the action
} else {
// show the paywall / upsell
}
CreditLedger is client-trusted, local-only — a UserDefaults-backed counter, not server-validated. A determined user could manipulate it directly. This is the right tradeoff for a solo/indie app's soft economy; if credits ever back something with real monetary stakes, mirror the balance server-side.
The doctor check
DeliciousKitDoctor.checkPurchases catches the specific class of config mistakes that cost real developers hours of debugging — a blank or placeholder entitlement ID, empty or malformed product IDs, missing Terms/Privacy links:
#if DEBUG
let findings = DeliciousKitDoctor.checkPurchases(config: myPaywallConfig, entitlementID: "pro")
for finding in findings where finding.severity == .error {
print("⚠️ \(finding.message)")
}
#endif
It also always returns two standing reminders — about the Apple Paid Applications Agreement and the App Store Connect listing text — because those can't be checked locally, but skipping them is just as costly as a config bug.