●RORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●APPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystem●EXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working on●FUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/month●CROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweaking●RORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●APPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystem●EXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working on●FUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/month●CROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweaking
Rork × Subscription Groups and Intro Offers — Implementation Patterns That Lift Subscription Revenue
If you shipped a monthly subscription with your Rork-built app and watched first-month churn climb past 50%, the fix usually lives in two places: how your Subscription Group is structured, and which intro offer format you picked. This guide walks through both, with production-ready StoreKit 2 code.
I keep hearing the same story from indie developers who ship monthly subscriptions with Rork: the app launches, the first wave of users converts, and then 60% of them churn within the first month. I have lived through that exact scenario more than once. After digging into the data each time, the root cause almost always traces back to two implementation choices: how the Subscription Group is structured, and which intro offer format the developer picked.
This article is for developers using Rork-generated StoreKit 2 scaffolding who want to push their conversion and retention numbers up by treating Subscription Groups and intro offers as a deliberate design decision. I will share the implementation patterns I have settled on after several years of trial and error, with the actual Swift code you can drop into a Rork project today.
The official Apple documentation covers each piece in isolation, but it does not address the practical question every solo developer asks: "Given that I built my app with Rork and want to ship a monthly plan, what should the structure actually look like?" Let me walk through that.
Why Subscription Groups Quietly Decide Your Revenue Ceiling
A Subscription Group is Apple's mechanism for bundling related subscription plans so that a user can have only one of them active at a time. Plans inside the same group are mutually exclusive, and StoreKit handles upgrades and downgrades between them automatically.
What I want to call out is that the structure of your Subscription Group is not just a technical taxonomy. It is the shape of your user's purchase journey. Rork-generated StoreKit 2 code is intentionally simple, which makes it easy to redesign your group structure early on. But once you have active paying subscribers, restructuring is a migration project — you have to think about price grandfathering, plan transitions, and user communication. Getting the structure right at launch saves you months of headaches later.
The most common mistake I see (and have made myself) is dropping every plan into a single group: monthly, yearly, lifetime, and a "pro" tier with extra features all in the same bucket. The result is that users cannot mentally compare options, default to "monthly" because it has the lowest perceived commitment, and then churn the moment their first bill hits.
Three group structures that actually work
These are the three patterns I now recommend, in order of complexity:
Simple: One group, two plans (monthly and yearly). No feature differences — just a price gap. Good for new apps and first releases
Upsell: One group, four plans (monthly, yearly, monthly Pro, yearly Pro). The "Pro" plans unlock additional features. Good once you have validated the basic plan
Multi-group: Two groups. For example, Group A (core subscription) plus Group B (add-on features that stack on top). Best for apps that have grown out of the simple model
For a first release, start with the simple model. You can always add a Pro tier later — either to the same group or as a separate group — once you have data to make that decision.
The Three Intro Offer Formats and Where Each One Wins
Apple offers three intro offer formats. Rork's generated code can handle all of them, but the docs do not tell you when to use which. Here is the breakdown that I use in practice.
Free Trial: Full access for a fixed period, then auto-converts to the regular price. Strongest for top-of-funnel acquisition, but also the highest churn rate
Pay As You Go: A discounted recurring price for a fixed period. Example: $9.99/month regular price, $4.99/month for the first 3 months. Better retention because the price step is gentler
Pay Up Front: A discounted lump-sum payment for a fixed period. Example: 6 months for $29 (vs. $39 normally). Highest retention because users have already committed financially, but a higher initial barrier
Mapping these to app categories, here is what I have seen work:
Habit-forming apps (fitness, meditation, learning): Pay As You Go is the strongest fit. A discounted introductory price gets users into the daily-use loop
Content consumption apps (news, video, ebooks): Free Trial is the standard. One or two weeks is enough for a user to know if they value the content
Productivity tools (task management, photo editing, note-taking): Pay Up Front often wins. Power users tend to evaluate over a longer horizon and prefer to lock in at a discount
Niche professional tools: Sometimes the best move is no intro offer at all. The users who pay full price from day one tend to have much higher LTV
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Cut first-month churn dramatically by restructuring your Subscription Group hierarchy to match how readers actually decide to pay.
✦Get production-ready code for picking the right intro offer (Free Trial vs Pay As You Go vs Pay Up Front) for each subscription type.
✦Use StoreKit 2 eligibility checks to prevent intro-offer abuse while still showing the optimal offer to each user.
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Implementation 1: Loading Subscription Groups in a Rork App
Let's get into code. The assumption here is that Rork has already generated StoreKit 2 scaffolding for you, and we are going to extend it to production quality.
The first step is to load all your products at app launch and group them by Subscription Group ID. Rork's generated code typically loads products from an array of IDs, but it does not group them — that's what we add.
// SubscriptionGroupManager.swift// Purpose: Load all products from App Store Connect and partition them// by Subscription Group ID, so the UI can render group-aware paywalls.import StoreKit@MainActorfinal class SubscriptionGroupManager: ObservableObject { /// Subscription Group ID -> products belonging to that group @Published private(set) var groupedProducts: [String: [Product]] = [:] /// All product IDs we want to load (e.g. from a Rork .env constant) private let allProductIDs: [String] init(productIDs: [String]) { self.allProductIDs = productIDs } /// Call this on app launch or just before showing a paywall func loadProducts() async { do { let products = try await Product.products(for: allProductIDs) // Filter out non-subscription products (e.g. consumable IAPs) let subscriptionProducts = products.filter { $0.subscription != nil } // Partition by Subscription Group ID let grouped = Dictionary(grouping: subscriptionProducts) { product in product.subscription?.subscriptionGroupID ?? "unknown" } // Within each group, sort by price ascending self.groupedProducts = grouped.mapValues { products in products.sorted { $0.price < $1.price } } } catch { // Network failure or App Store outage — log and show retry UI print("[SubscriptionGroupManager] Failed to load products: \(error)") self.groupedProducts = [:] } }}// Expected output:// After loadProducts() succeeds, groupedProducts looks like:// [// "21482710": [Product(monthly $9.99), Product(yearly $89.99)], // Core group// "21482711": [Product(pro monthly $19.99), Product(pro yearly $199)] // Pro group// ]
The subscriptionGroupID is a string that Apple auto-assigns when you create a subscription in App Store Connect. I recommend storing these IDs as named constants in something like Constants.swift so your UI can filter cleanly: "show only products from the core group" or "show only the Pro group on this screen."
Why partition into a dictionary first? Because the intro offer eligibility check — coming up next — operates at the group level, not the individual product level. Thinking in groups from the start matches how StoreKit 2 actually works.
Intro offers should only show to first-time subscribers (or users who meet specific eligibility rules). If you handle this naively on the server side, you'll either let users abuse free trials by canceling and re-subscribing, or your app will get rejected during App Review for inaccurate eligibility checks.
StoreKit 2 provides Product.SubscriptionInfo.isEligibleForIntroOffer(for:) for this. Here is the wrapper I use.
// IntroOfferEligibilityChecker.swift// Purpose: Decide whether the intro offer for a given product// should be shown to the current user.import StoreKitstruct IntroOfferEligibilityChecker { /// Returns true if the user is eligible for this product's intro offer. static func isEligible(for product: Product) async -> Bool { guard product.subscription != nil else { // Not a subscription product (e.g. consumable IAP) return false } guard let groupID = product.subscription?.subscriptionGroupID else { return false } // Eligibility is checked at the Subscription Group level — if the user // has ever subscribed to anything in this group, they are NOT eligible. let isEligible = await Product.SubscriptionInfo.isEligibleForIntroOffer( for: groupID ) return isEligible }}// Usage from a Paywall viewstruct PaywallView: View { @StateObject private var groupManager = SubscriptionGroupManager( productIDs: ["com.example.app.monthly", "com.example.app.yearly"] ) @State private var introEligibility: [String: Bool] = [:] var body: some View { VStack { ForEach(groupManager.groupedProducts.values.flatMap { $0 }, id: \.id) { product in PlanRow( product: product, showIntroOffer: introEligibility[product.id] ?? false ) } } .task { await groupManager.loadProducts() // Resolve eligibility for every product in parallel for product in groupManager.groupedProducts.values.flatMap({ $0 }) { introEligibility[product.id] = await IntroOfferEligibilityChecker.isEligible(for: product) } } }}// Expected behavior:// New user: introEligibility = ["...monthly": true, "...yearly": true] -> show intro offers// Returning user: introEligibility = ["...monthly": false, ...] -> show regular price only
A subtle but important point: isEligibleForIntroOffer evaluates eligibility per Subscription Group. If the user previously subscribed to your monthly plan, they are also ineligible for the yearly plan's intro offer because both plans live in the same group. This is by Apple's design and you cannot — and should not — override it on your server.
A common follow-up question is: "Can users abuse this by sharing accounts in Family Sharing?" The answer is no — StoreKit evaluates eligibility at the Apple ID level (or more precisely, at the Family Sharing subscription level), so the loophole is much narrower than people fear.
Implementation 3: Showing the Right Offer Dynamically in the Paywall
Now we have eligibility. The next step is rendering the intro offer in a way that humans can read at a glance.
One important constraint: a single subscription product can have only one introductory offer at a time. The introductoryOffer property on SubscriptionInfo is a single Optional, not a list. If you want to A/B test multiple offers per plan, you need Promotional Offers, which require a server-side signing key and are too much to wire into a freshly-generated Rork app. This article focuses on Introductory Offers only.
// PlanRow.swift// Purpose: Render a single plan card. If the user is eligible for the// intro offer, format it in human-readable language.import StoreKitstruct PlanRow: View { let product: Product let showIntroOffer: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { Text(product.displayName) .font(.headline) if showIntroOffer, let intro = product.subscription?.introductoryOffer { introOfferLabel(for: intro) .foregroundColor(.accentColor) Text("then \(product.displayPrice)/\(periodSuffix(for: product.subscription))") .font(.caption) .foregroundColor(.secondary) } else { Text("\(product.displayPrice)/\(periodSuffix(for: product.subscription))") .font(.subheadline) } } .padding() .background(Color(uiColor: .secondarySystemBackground)) .cornerRadius(12) } /// Render the intro offer in plain English ("free", "discounted", "prepaid") @ViewBuilder private func introOfferLabel(for intro: Product.SubscriptionOffer) -> some View { switch intro.paymentMode { case .freeTrial: Text("First \(periodDescription(intro.period)) free") .font(.subheadline) .bold() case .payAsYouGo: Text("First \(periodDescription(intro.period)) at \(intro.displayPrice)/\(periodSuffix(intro.period))") .font(.subheadline) .bold() case .payUpFront: Text("\(periodDescription(intro.period)) prepaid for \(intro.displayPrice)") .font(.subheadline) .bold() @unknown default: EmptyView() } } private func periodDescription(_ period: Product.SubscriptionPeriod) -> String { let unit: String switch period.unit { case .day: unit = "day" case .week: unit = "week" case .month: unit = "month" case .year: unit = "year" @unknown default: unit = "" } let plural = period.value == 1 ? "" : "s" return "\(period.value) \(unit)\(plural)" } private func periodSuffix(for subscription: Product.SubscriptionInfo?) -> String { guard let period = subscription?.subscriptionPeriod else { return "" } return periodDescription(period) } private func periodSuffix(_ period: Product.SubscriptionPeriod) -> String { return periodDescription(period) }}// Expected output:// Free Trial:// "First 7 days free"// "then $9.99/month"// Pay As You Go:// "First 3 months at $4.99/month"// "then $9.99/month"// Pay Up Front:// "6 months prepaid for $29.99"// "then $9.99/month"
Branching on paymentMode is not optional — it's required by the API design. Skip it and you risk displaying "First 3 months free" while actually charging the user a discounted price. That kind of bug shows up in your App Store reviews as one-star complaints within 24 hours of release. I shipped that exact bug once and had to push a hotfix the same day.
Common Mistakes and How to Avoid Them
Three pitfalls that are not obvious from the docs and that I have either hit personally or watched fellow Rork developers hit.
Pitfall 1: Sandbox always returns "eligible"
When you test in Sandbox, isEligibleForIntroOffer often returns true regardless of purchase history. Developers think their eligibility logic is bulletproof, ship to production, and then watch their paywall break for returning users.
Two safeguards:
Always test in TestFlight (which has different behavior from Sandbox) with a real Apple ID, walking through purchase → cancel → re-display
Recruit one or two friends or family members to do a real-world test before launch. Their actual Apple IDs will surface eligibility quirks that your Sandbox account hides
Pitfall 2: Currency formatting breaks across locales
product.displayPrice is fully localized by Apple. The trouble starts when you try to compose your own strings ("First 3 months at $4.99"). If you do this with String(format:) and a hard-coded currency symbol, you'll see "JPY" instead of "¥" in the US locale, or broken decimal separators in European locales.
// WrongText("\(intro.price) yen")// RightText(intro.price.formatted(product.priceFormatStyle))// Or, even simpler:Text(intro.displayPrice)
displayPrice returns a fully localized string, including the correct currency symbol and decimal separator. Use it whenever you can.
Pitfall 3: Splitting a group later breaks auto-upgrades
After you launch, you might be tempted to split your single group into "core" and "pro" groups for cleaner organization. This looks good on paper but breaks something subtle: upgrades between the new groups stop working as upgrades.
When two plans are in the same group, StoreKit handles the price difference and prorates the existing subscription automatically. Once they live in different groups, the user ends up with two active subscriptions running in parallel — the original one plus the new one — and your support inbox fills up.
Mitigation: avoid splitting groups after launch unless absolutely necessary. If you need a Pro tier, add it to the same group as the basic plans and differentiate by features unlocked.
Testing the Whole Flow Before You Ship
A solid Subscription Group implementation can still fail in production if you skip the testing layer. The order I run through, every release:
Local Sandbox test: A quick smoke check that the products load and the paywall renders. Sandbox is unreliable for eligibility, but it catches gross failures (wrong product IDs, missing entitlements, misconfigured group membership)
Local StoreKit configuration file: Xcode lets you ship a .storekit config file that mocks the App Store responses with whatever products and offers you want. This is the only way to deterministically test "what happens to a user with exactly these past purchases" without setting up a fresh Apple ID each time
TestFlight with a real Apple ID: The closest analog to production behavior. Run the full purchase → cancel → re-open flow at least once per release
Receipt validation under unstable network: Toggle airplane mode mid-purchase and check that your error UI is sensible. Many apps show "thank you for subscribing" even when the transaction silently failed
Restore Purchases path: Long-pressed by users on new devices and after reinstalls. If your Transaction.currentEntitlements loop does not handle expired transactions gracefully, restore will silently grant nothing
A dedicated .storekit configuration is the single highest-ROI testing change you can make. It transforms intro offer testing from "set up a brand-new Apple ID, log out, log in, wait" into "edit the JSON, hit run."
Metrics to Track After You Ship
Once your design is live, the data you collect tells you whether the design is actually working. Three numbers I track first, in App Store Connect's Subscription Reports:
Paid conversion rate from intro offer: What percentage of users who started the intro offer ended up paying the full price after it ended? For Free Trials, anything below 30% suggests the trial period is too short or the value is not landing. For Pay As You Go, the same number should be much higher (50%+) — if it isn't, your full price might be too steep relative to the discounted runway
First-month churn for users who never had an intro offer: This is your "raw acquisition quality" signal. If users who pay full price from day one are also churning at 50%+, the issue is product-market fit, not the offer
Plan distribution within the group: How does your monthly-to-yearly split look? A healthy ratio is roughly 60-70% monthly, 30-40% yearly. If yearly is much lower, your paywall might not be communicating the savings well. If yearly is much higher, you might be leaving money on the table by not promoting the monthly plan more prominently to users who would prefer flexibility
For a deeper layer of analysis — cohort retention curves, LTV by acquisition source, win-back rate after cancellation — you eventually want a tool like RevenueCat. App Store Connect alone is fine for the first 6 to 12 months, but starts to feel limiting once you have multiple plans, multiple intro offers, and multiple acquisition channels running concurrently.
Designing the Price Ladder Inside a Group
Before you finalize your subscription structure, you need to decide the actual prices. The relationship between plans inside a Subscription Group should communicate value at a glance, and there are a few rules I keep coming back to.
Yearly should look like a discount, not a price hike. If your monthly plan is $9.99, a yearly plan at $89.99 ($7.50/month equivalent) reads as a 25% discount and tends to convert. A yearly plan at $99.99 ($8.33/month) is a smaller discount and noticeably underperforms in side-by-side tests I have run. The psychological threshold for "clearly worth it" sits around 20-30% off the monthly equivalent.
Avoid round numbers that match competitors. If every productivity app charges $9.99/month, breaking the pattern with $7.99 or $11.99 helps you stand out in the paywall and signals deliberate pricing. The number you pick is less important than not blending into the crowd.
Charm pricing still works on iOS. Apple shows your prices in the platform-localized format, but $9.99 still feels meaningfully cheaper than $10.00 to most users. Use it.
Country pricing matters more than people think. App Store Connect lets you set per-territory pricing tiers. The defaults that Apple suggests when you set a US base price are usually fine for English-speaking markets but tend to be too high for emerging economies. If you have non-trivial install volume from Brazil, India, or Southeast Asia, manually lower those tiers to roughly 30-50% of the US price. The conversion rate lift more than compensates for the lower per-transaction revenue.
How the price ladder interacts with your intro offer
If you set the monthly base price too high relative to your trial expectations, you will see strong intro-offer signups followed by a wall of churn. The math is simple: a 7-day trial for a $19.99/month app means the user has to feel "$19.99 worth of value" within 7 days. For a habit-forming app, that is almost never possible — the value compounds over weeks.
Two adjustments I commonly recommend:
Lower the monthly price, if it leaves enough margin. Going from $14.99 to $9.99 monthly often improves trial-to-paid conversion by 15-20% in my experience. The lost per-user revenue is recovered by the higher conversion rate
Lengthen the trial or switch to Pay As You Go, if you cannot lower the price. A 14-day trial gives users twice the chance to form the habit. A Pay As You Go intro at half the regular price for 2-3 months gives them a financial reason to keep using the app
Server-Side Considerations You Cannot Skip
StoreKit 2 handles a remarkable amount of transaction logic on-device, and Rork projects can ship with no backend at all for the first iteration. But there are a few server-side concerns that catch up with you eventually, and getting ahead of them is worth a section.
Server-side receipt validation prevents jailbroken devices from spoofing entitlements. For a hobby app this is overkill, but if your subscription revenue exceeds a few thousand dollars per month, the cost of being a target rises. The simplest path is to forward each verified Transaction to a small Cloudflare Worker that calls Apple's verifyReceipt endpoint and stores the result in a key-value store keyed by the user's app account token.
App Store Server Notifications V2 are webhooks that Apple sends to your server when a subscription changes state — renewal, cancellation, refund, billing retry. Wiring these up means your backend always knows the truth about each user's subscription state, even if the user never opens the app again. For win-back campaigns and churn analysis, V2 notifications are essential.
An app account token is a UUID that you generate client-side and attach to every purchase request. It links the StoreKit transaction to your own user record. Without it, you cannot reliably reconcile a user's account on your server with their subscription state on Apple's side. This is one of those one-line additions that pays back its cost the first time you need to do customer support.
These three pieces — server validation, V2 webhooks, and account tokens — form the production-grade backbone for any non-trivial subscription business. Defer them only as long as your revenue stays low; once you cross the line where a single fraudulent refund hurts, get them in place.
Putting It All Together: A Health-Tracking App
To make this concrete, let me sketch how I would structure the subscription for a hypothetical health-tracking app built with Rork. The app records daily exercise and meals, with paid features for chart visualizations, data export, and AI-powered advice.
The app's characteristics:
Habit-forming (we want users back daily)
Free competitors exist, so the price barrier is real
Solo developer, minimal server infrastructure
Paid users get visualization, export, and AI features
For this profile, I would design:
Subscription Group: One group only (the "simple" pattern)
Plans: Monthly $5.99, yearly $39 (clearly cheaper per month)
Intro offers: Pay As You Go on monthly ($2.99/month for the first 2 months); Free Trial on yearly (1 week)
The reasoning:
Habit-forming apps benefit from a discounted runway that gets users into the daily routine before they hit full price. A 1-week free trial is too short for a habit to form
Yearly buyers are higher-intent and tend to evaluate quickly. A 1-week trial is enough for them to commit
Different intro offers per plan inside the same group is fully allowed by Apple's policies — this is documented but rarely highlighted
In the UI, position the yearly plan (with Free Trial) as "Recommended" at the top of the paywall, and put the monthly plan (with the discount) below it. This nudges revenue toward the higher-LTV plan while still giving cost-conscious users a path in.
If your app is in a different category — say, a creative tool or a B2B utility — the same logical exercise applies. Define the user's commitment profile, pick the intro offer format that matches, and avoid the urge to offer "all three" formats simultaneously. Choosing one means you can tell a clear story about value; offering all three usually means none of them perform.
Related Reading
This article focuses on Subscription Group and intro offer design. For the surrounding pieces, here are the most useful companion guides:
If you only do one thing after reading this, count the number of plans in your current Subscription Group and consider trimming if there are four or more.
Pull your past 30-day App Store Connect report and check whether any plan barely sells. If so, that plan is most likely just adding decision fatigue without contributing revenue. In my experience, two or three plans per group converts most reliably.
After that, look at your app category — habit-forming, content, or tools — and pick the intro offer format that fits best. Change one thing at a time. It is much easier to learn what actually moved the needle when you isolate variables instead of redesigning everything at once.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.