Don't Stop at Shipping StoreKit 2 — Growing Subscription Revenue with Server Notifications, Recovery Offers, and Price Experiments
Subscription revenue is decided after launch. Build Server Notifications v2 handling, cohort churn analysis, Promotional Offers, price experiments, and win-back automation — with production code and hands-on operational lessons.
The day you ship the paywall is day one of operations
When I added StoreKit 2 subscriptions to the apps I have been running as an indie developer since 2014, the first lesson arrived within weeks: shipping the paywall was not the finish line. Subscriber counts crept upward on the dashboard, yet monthly revenue lagged behind what those numbers implied. The gap, I eventually found, was being quietly carved out by failed renewals and first-week cancellations.
Growing subscription revenue means operating three levers simultaneously:
Acquisition: Convert free users to paid through trials and strategic offers
Retention: Reduce churn and maximize each subscriber's lifetime value (LTV)
Win-back: Re-engage churned users with targeted campaigns
The audience here is intermediate-to-advanced iOS developers who already understand StoreKit 2 basics and want to move past them. With Antigravity as a working companion, we will build App Store Server Notifications v2 handling, cohort churn analysis on Firebase Analytics and BigQuery, Promotional Offers, price experiments, and automated win-back — alongside what actually happened when I ran these systems on my own apps.
Churn signals only reach your server — receiving Server Notifications v2
Without server-side visibility into these events, you're flying blind. App Store Server Notifications v2 solves this by having Apple POST these events directly to your server endpoint in real time.
Setting Up the Endpoint with Antigravity
Feed Antigravity the following prompt to scaffold the entire notification endpoint:
Implement an App Store Server Notifications v2 endpoint.
- Use TypeScript + Cloudflare Workers (or Swift + Vapor)
- Process both JWSTransactionDecodedPayload and JWSRenewalInfoDecodedPayload
- Verify JWS signatures against Apple's public keys
- Implement handlers for: SUBSCRIBED, DID_RENEW, EXPIRED, DID_FAIL_TO_RENEW, REFUND
Here's the resulting Cloudflare Workers implementation:
In my own operations, what kept the implementation order from drifting was deciding up front how urgently each notification type deserves a reaction. Here is the map I settled on:
notificationType
Meaning
Recommended action
Priority
DID_FAIL_TO_RENEW
Renewal payment failed (Billing Retry / Grace Period begins)
Keep access alive and prompt the user to update payment details
Highest — left alone, it quietly decays into EXPIRED
DID_CHANGE_RENEWAL_STATUS
Auto-renew toggled off (a declared intent to cancel)
Re-present value or an offer before the expiry date
High — the only window that opens before the subscription actually lapses
EXPIRED
Subscription lapsed
Record as the starting point of the win-back sequence
Medium — from here it becomes a re-engagement design problem
REFUND
Refund granted
Revoke access immediately and log the cause
High — separate fraud signals from dissatisfaction signals
SUBSCRIBED / DID_RENEW
New purchase or successful renewal
Persist state and feed KPI aggregation
Normal
The one that tends to get overlooked is DID_CHANGE_RENEWAL_STATUS. Cancellation does not first become visible at EXPIRED — in most cases it announces itself days or weeks earlier as "auto-renew off." That time gap is the entire budget you have for presenting a retention offer.
Choose the 16-day Grace Period — most payment failures are accidents
App Store Connect lets you pick a Billing Grace Period of 3, 16, or 28 days; I run with 16. What operating my own apps taught me is that the majority of renewal failures are not decisions to leave — they are expired cards and insufficient balances. Simply keeping access alive lets a meaningful share of these subscriptions recover without the user touching anything.
The inverse mistake is treating the Grace Period like EXPIRED and cutting off access. Nothing convinces a user who was about to recover that the subscription is already gone faster than a locked screen. Branch on gracePeriodExpiresDate in the renewal info and keep the two states strictly separate.
✦
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
✦A priority-ranked response map for Server Notifications v2 events, including the one that quietly precedes most cancellations
✦Promotional Offer and win-back implementations grounded in real indie-app subscription operations, not just API references
✦A weekly KPI reporting pipeline driven by the scheduled Antigravity CLI so the improvement loop survives real life
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.
Who leaves, and when — cohort churn analysis with Firebase Analytics
Instrumenting the Client Side
Understanding when users churn and why is the foundation of every retention strategy. Instrument your subscription lifecycle events on the client:
// SubscriptionAnalyticsManager.swiftimport FirebaseAnalyticsimport StoreKitclass SubscriptionAnalyticsManager { static let shared = SubscriptionAnalyticsManager() func logSubscriptionStarted(product: Product, isFreeTrial: Bool) { Analytics.logEvent("subscription_started", parameters: [ "product_id": product.id, "is_free_trial": isFreeTrial, "price_locale": product.priceLocale.identifier, "price": product.price as NSDecimalNumber, ]) } // Track when users navigate toward the cancellation flow func logSubscriptionCancelIntent(source: String) { Analytics.logEvent("subscription_cancel_intent", parameters: [ "source": source, // "settings" | "paywall" | "offboarding_flow" "days_since_start": daysSinceSubscriptionStart(), ]) } func logSubscriptionCancelled(originalTransactionId: String, daysActive: Int) { Analytics.logEvent("subscription_cancelled", parameters: [ "days_active": daysActive, // Categorize churn for funnel analysis "cancel_reason_category": inferCancelCategory(daysActive: daysActive), ]) } private func inferCancelCategory(daysActive: Int) -> String { switch daysActive { case 0...3: return "immediate_churn" // Never found value case 4...14: return "early_churn" // Onboarding failure case 15...60: return "mid_term_churn" // Usage dropped off default: return "long_term_churn" // Likely switching to competitor } }}
BigQuery Cohort Churn Rate Query
Once Firebase exports land in BigQuery, ask Antigravity to write a monthly cohort churn analysis query:
-- Ask Antigravity: "Write a BigQuery query to calculate 30-day cohort churn rate by month"WITH cohorts AS ( SELECT FORMAT_DATE('%Y-%m', DATE(TIMESTAMP_MICROS(event_timestamp))) AS cohort_month, user_pseudo_id, MIN(event_timestamp) AS first_subscribe_ts FROM `your_project.analytics_XXXXXXXX.events_*` WHERE event_name = 'subscription_started' GROUP BY 1, 2),cancellations AS ( SELECT user_pseudo_id, event_timestamp AS cancel_ts FROM `your_project.analytics_XXXXXXXX.events_*` WHERE event_name = 'subscription_cancelled')SELECT c.cohort_month, COUNT(DISTINCT c.user_pseudo_id) AS total_subscribers, COUNT(DISTINCT CASE WHEN TIMESTAMP_DIFF( TIMESTAMP_MICROS(can.cancel_ts), TIMESTAMP_MICROS(c.first_subscribe_ts), DAY) <= 30 THEN c.user_pseudo_id END) AS churned_within_30d, ROUND(100.0 * COUNT(DISTINCT CASE WHEN TIMESTAMP_DIFF( TIMESTAMP_MICROS(can.cancel_ts), TIMESTAMP_MICROS(c.first_subscribe_ts), DAY) <= 30 THEN c.user_pseudo_id END) / COUNT(DISTINCT c.user_pseudo_id), 1 ) AS churn_rate_30d_pctFROM cohorts cLEFT JOIN cancellations can USING (user_pseudo_id)GROUP BY 1ORDER BY 1 DESCLIMIT 24
Paste the results back into Antigravity's chat and ask: "What hypotheses could explain the difference in churn rates across these cohorts?" You'll get structured analysis that correlates churn spikes with product changes, pricing updates, and seasonal patterns.
What to show the user who taps cancel — implementing Promotional Offers
How Promotional Offers Work
Available since iOS 12.2, Promotional Offers let you extend special pricing (discounts, free periods) exclusively to current or lapsed subscribers. They're your most powerful in-app tool for cancellation recovery.
Here's the implementation for detecting offer eligibility and presenting it during the offboarding flow:
// OffboardingFlowManager.swiftimport StoreKitclass OffboardingFlowManager: ObservableObject { @Published var promoOfferState: PromoOfferState = .checking enum PromoOfferState { case checking case available(Product.SubscriptionOffer) case notAvailable case redeemed } func loadPromoOffer(for product: Product) async { guard let subscription = product.subscription else { return } // Check server-side eligibility (based on subscription history) let eligibility = await checkOfferEligibility(for: product) guard eligibility.isEligible else { await MainActor.run { promoOfferState = .notAvailable } return } // Match the eligible offer ID to the available promotional offers // Offer IDs are configured in App Store Connect (e.g., "winback_3months_50pct") if let offer = subscription.promotionalOffers.first(where: { $0.id == eligibility.offerId }) { await MainActor.run { promoOfferState = .available(offer) } } } func redeemOffer(product: Product, offer: Product.SubscriptionOffer) async throws { // Fetch server-generated signature (NEVER generate this client-side) let signature = try await fetchOfferSignature( productId: product.id, offerId: offer.id ) let purchaseOptions: Set<Product.PurchaseOption> = [ .promotionalOffer( offerID: offer.id, keyID: signature.keyId, nonce: signature.nonce, signature: signature.signature, timestamp: signature.timestamp ) ] let result = try await product.purchase(options: purchaseOptions) switch result { case .success: // Server Notification will also update state asynchronously await MainActor.run { promoOfferState = .redeemed } case .userCancelled: break // User declined — proceed to normal cancellation default: break } }}
UX Principles for Cancellation Flows
When prompting Antigravity to generate the offboarding UI, specify these requirements:
Create a SwiftUI offboarding flow with:
- Step 1: Cancellation reason selection (5 options, radio button style)
- Step 2: Promotional offer presentation (visually emphasize savings)
- Step 3: Confirmation dialog if user declines the offer
- Tone: grateful and empathetic, never guilt-tripping or manipulative
- Follow Apple HIG design guidelines
The key principle: don't pressure users to stay — show them renewed value. A concrete offer like "Stay for 3 months at 50% off" outperforms "Are you sure you want to cancel?" by a wide margin.
Hearing why they leave — an offboarding survey that surfaces churn reasons
Beyond detecting when users churn, you also need to understand why. An in-app exit survey presented at the start of the cancellation flow captures qualitative data that no analytics platform can infer automatically.
Ask Antigravity to generate a five-question exit survey with these instructions:
Create a SwiftUI exit survey view that presents before the "Manage Subscriptions"
deeplink opens. Requirements:
- Five multiple-choice cancellation reasons
- One optional free-text field ("Anything else we should know?")
- Store responses in Firebase Firestore with the user's anonymized ID
- Delay presenting the promotional offer until after the survey is complete
The five standard reasons that cover 90%+ of cancellations are:
"I don't use the app enough to justify the price"
"I found a better alternative"
"I'm having technical problems"
"The price is too high"
"I only needed it temporarily"
Each reason maps to a different intervention strategy. "Price too high" → present the discounted Promotional Offer. "Don't use it enough" → trigger a re-engagement campaign highlighting underused features. "Technical problems" → route to customer support before cancellation completes. "Found a better alternative" → ask for the competitor's name in the free-text field; this is competitive intelligence worth its weight in gold.
Grace Period UX — Keeping Subscribers During Payment Failures
Payment failures account for a significant share of involuntary churn — studies suggest 20–40% of subscription losses are involuntary. When you receive a DID_FAIL_TO_RENEW notification, Apple automatically enters a grace period (typically 6–16 days depending on subscription length). Your job is to guide users to update their payment method before the grace period ends.
// GracePeriodBannerView.swift// Surface a non-intrusive reminder when the user's payment has failedstruct GracePeriodBannerView: View { let gracePeriodEndDate: Date @State private var isDismissed = false var daysRemaining: Int { Calendar.current.dateComponents([.day], from: Date(), to: gracePeriodEndDate).day ?? 0 } var body: some View { if !isDismissed && daysRemaining >= 0 { HStack(spacing: 12) { Image(systemName: "creditcard.trianglebadge.exclamationmark") .foregroundStyle(.orange) VStack(alignment: .leading, spacing: 2) { Text("Payment update needed") .font(.subheadline.weight(.semibold)) Text("Update your billing info to keep your subscription — \(daysRemaining) day\(daysRemaining == 1 ? "" : "s") remaining.") .font(.caption) .foregroundStyle(.secondary) } Spacer() Button("Update") { // Deep-link to App Store subscription management if let url = URL(string: "https://apps.apple.com/account/subscriptions") { UIApplication.shared.open(url) } } .buttonStyle(.borderedProminent) .controlSize(.small) } .padding() .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) .padding(.horizontal) } }}
Present this banner non-intrusively — in the app's main navigation or settings page, not as a modal that blocks content access. Blocking content during a grace period accelerates cancellation rather than preventing it.
Deciding tiers and prices — Subscription Group design
Designing a Three-Tier Subscription Structure
App Store Connect's Subscription Groups allow users to hold only one active subscription at a time within a group, with automatic handling of upgrades and downgrades. A well-designed tier structure looks like this:
Tier 3 — Power users ($25–50/year): Everything in Tier 2 + priority support
Annual plans priced at 20–30% off the monthly equivalent typically double LTV compared to monthly subscribers — a strong reason to prominently feature annual options on your paywall.
Price A/B Testing with Firebase Remote Config
Combine App Store Connect's regional pricing with Firebase Remote Config to test paywall variants:
// PricingExperimentManager.swiftimport FirebaseRemoteConfigclass PricingExperimentManager: ObservableObject { private let remoteConfig = RemoteConfig.remoteConfig() func configure() { remoteConfig.setDefaults([ "paywall_variant": "control" as NSObject, // "control" | "annual_emphasis" | "trial_emphasis" "show_promo_badge": false as NSObject, "trial_duration_days": 7 as NSObject, ]) } var paywallVariant: String { remoteConfig.configValue(forKey: "paywall_variant").stringValue ?? "control" } var trialDurationDays: Int { Int(remoteConfig.configValue(forKey: "trial_duration_days").numberValue) } func fetchAndActivate() async throws { try await remoteConfig.fetchAndActivate() Analytics.logEvent("experiment_assigned", parameters: [ "paywall_variant": paywallVariant, ]) }}// PaywallView.swift — render based on assigned variantstruct PaywallView: View { @StateObject private var pricing = PricingExperimentManager() var body: some View { Group { switch pricing.paywallVariant { case "annual_emphasis": AnnualEmphasisPaywallView() case "trial_emphasis": TrialEmphasisPaywallView() default: ControlPaywallView() } } .task { try? await pricing.fetchAndActivate() } }}
Reaching the users who left — automated win-back campaigns
Targeting the 30–90 Day Lapsed Window
Users who cancelled within the past 30–90 days represent your highest win-back potential. Use the EXPIRED event from App Store Server Notifications to trigger an automated re-engagement sequence:
// win-back-campaign.ts (Cloudflare Workers + Queues)export async function triggerWinBackCampaign( originalTransactionId: string, userId: string, productId: string, env: Env) { await env.WIN_BACK_QUEUE.send({ userId, originalTransactionId, productId, expiredAt: new Date().toISOString(), sequence: 0, });}export default { async queue(batch: MessageBatch<WinBackMessage>, env: Env) { for (const message of batch.messages) { const { userId, sequence, expiredAt } = message.body; const daysSinceExpiry = Math.floor( (Date.now() - new Date(expiredAt).getTime()) / (1000 * 60 * 60 * 24) ); if (sequence === 0 && daysSinceExpiry >= 3) { // Day 3: "Welcome back" offer — 1 month free await sendPushNotification(userId, { title: "We'd love to have you back", body: "Come back and get your first month free", data: { type: "win_back", offerId: "winback_1month_free" }, }, env); await scheduleNextSequence(message.body, 1, 14, env); } else if (sequence === 1 && daysSinceExpiry >= 14) { // Day 14: Final offer — 3 months at 40% off await sendPushNotification(userId, { title: "A special offer, just for you", body: "Get 3 months at 40% off — this week only", data: { type: "win_back", offerId: "winback_3months_40pct" }, }, env); // End sequence; hand off to email channel if available } message.ack(); } }};
Ask Antigravity to write the notification copy using this tone directive: "Write using a grateful → empathetic → value-forward structure. Avoid pressure tactics." The resulting messages will feel personal rather than automated.
Where trials leak — optimizing the trial-to-paid conversion flow
The free trial is your single most powerful acquisition tool, but it only works if users reach the "aha moment" before the trial ends. The typical benchmark: if a user doesn't complete two or more meaningful sessions within the first 72 hours, they almost certainly won't convert.
Antigravity-Powered Onboarding Audit
Paste your current onboarding code into Antigravity and ask:
Review this onboarding flow for trial conversion optimization.
Identify:
1. Steps where users commonly drop off (based on these analytics events: [paste data])
2. Missing moments that should trigger "aha moments" for the user
3. Push notification timing recommendations for trial reminder sequences
4. Paywall placement suggestions (when to show, not show)
Antigravity can generate a full analysis and propose a revised onboarding sequence with specific timing for trial reminder push notifications. The most effective schedule for a 7-day trial is typically:
Day 0 (signup): Immediate in-app prompt to complete setup
Day 1: Push notification highlighting the single most valuable feature
Day 3: Midpoint check-in — "You've got 4 days left. Here's what you haven't tried yet."
Day 6: Final reminder — "Your trial ends tomorrow" + soft upsell with specific benefit summary
Day 7: Post-trial offer — if they haven't converted, present a Promotional Offer
Tracking Trial Conversion Funnel
// TrialConversionTracker.swiftimport FirebaseAnalyticsstruct TrialConversionTracker { // Track each meaningful interaction during the trial static func logTrialMilestone(_ milestone: TrialMilestone) { Analytics.logEvent("trial_milestone", parameters: [ "milestone": milestone.rawValue, "trial_day": daysSinceTrialStart(), ]) } // When the paywall is shown, record the context static func logPaywallImpression(trigger: String, trialDaysRemaining: Int) { Analytics.logEvent("paywall_impression", parameters: [ "trigger": trigger, // "trial_expiry" | "feature_gate" | "manual" "trial_days_remaining": trialDaysRemaining, ]) } // When a user converts, capture the full journey static func logTrialConversion(product: Product, daysSinceStart: Int) { Analytics.logEvent("trial_converted", parameters: [ "product_id": product.id, "days_to_convert": daysSinceStart, "converted_during_trial": daysSinceStart <= 7, ]) } enum TrialMilestone: String { case firstSession = "first_session" case coreFeatureUsed = "core_feature_used" case savedOrExported = "saved_or_exported" // "save" action = high intent case returnedNextDay = "returned_next_day" case invitedOther = "invited_other" // Very high conversion signal }}
The savedOrExported and returnedNextDay milestones are the two strongest predictors of trial-to-paid conversion across most app categories. If a user hasn't hit both by Day 4, trigger a personalized push notification showing exactly how to use the relevant feature.
Fewer numbers, checked weekly — the KPI dashboard
Track these metrics weekly to spot problems before they compound:
MRR (Monthly Recurring Revenue): Your baseline health metric
Trial-to-Paid Rate: Target 40–60% for most utility apps
LTV: Compared against your acquisition cost (CAC) — should be 3x+
ARPU: Average revenue per active user; tracks monetization efficiency
Ask Antigravity to generate a TypeScript dashboard script that pulls data from App Store Connect API and Firebase Analytics and outputs a weekly summary to Slack or email. This takes roughly 15 minutes to set up and pays dividends every week thereafter.
Pulling the numbers automatically — App Store Connect API reporting
Rather than logging into App Store Connect each week, automate your subscription KPI reporting using the App Store Connect API. Antigravity can generate the full data pipeline with one prompt:
Create a TypeScript script that:
1. Authenticates with App Store Connect API using JWT (ES256 key from App Store Connect)
2. Fetches the past 30 days of subscription summary data
3. Calculates MRR, churn rate, and trial conversion rate
4. Posts a formatted weekly digest to a Slack webhook
The App Store Connect API endpoint for subscription data is GET /v1/subscriptionGroups/{id}/subscriptions, and subscription summary reports are available via the Sales and Trends reports API. Here's the authentication setup:
Schedule this to run every Monday morning using Cloudflare Workers Cron Triggers (0 9 * * 1). After six weeks of data, you'll have a clear enough baseline to set meaningful targets and detect anomalies.
Let the Antigravity CLI own the weekly report
The place KPI tracking dies is not the query — it is the habit. My own hand-run spreadsheet lasted three weeks. These days a scheduled job invokes the Go-based Antigravity CLI every Monday morning and writes the previous week's numbers to a Markdown report:
# Runs Monday 07:00 via cronantigravity run \ --agent subscription-report \ --prompt "Aggregate last week's MRR, trial-to-paid rate, and 30-day churn, with week-over-week deltas, as Markdown. If any threshold is breached (churn above 5%), prepend a warning." \ --output reports/weekly-$(date +%Y-%m-%d).md
The warning line is the point. Nobody rereads columns of numbers every week, but everyone notices a warning at the top of a file. Designing for "notice anomalies" rather than "read reports" is, in my experience as an indie developer, the difference between a dashboard that survives and one that quietly dies.
Where to start
Begin with the Server Notifications v2 endpoint. Once events land on your server, cancellations stop being something you discover after the fact — payment failures, renewal-status changes, and refunds all arrive as signals you can respond to, and every later system (Grace Period handling, retention offers, win-back) reduces to deciding how to react to them.
The second step I would take is the offer presented on DID_CHANGE_RENEWAL_STATUS: it is a small amount of code, and it works inside the only window that opens before a subscription actually lapses. The numbers will not move overnight. But from the day the events start flowing, subscription revenue stops being something you hope for and becomes something you cultivate. If you are making the same move from "implemented" to "operated," I hope the patterns here shorten the road.
Share
Thank You for Reading
Antigravity 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.