11.5 — Subscriptions: Design & Retention
Opening scenario
Your subscription has a 7-day free trial, $9.99/mo, no annual option. Conversion is 22% trial→paid; churn is 18% month-one, 10% month-two, 7% steady-state. A year in, you launch annual at $59 (50% off monthly) and a “Family” tier at $99. You add an introductory offer (3-day trial → $4.99 first month → $9.99 rolling) on monthly. You add a win-back offer (50% off first 3 months) emailed to lapsed subscribers. Six months later: conversion 34%, month-one churn 9%, ARPU/customer doubled. Same product. The retention design was the product.
Context taxonomy
| Mechanism | Apple feature | Effect |
|---|---|---|
| Free trial | introductoryOffer.paymentMode = .freeTrial | Conversion boost; abuse risk |
| Pay-as-you-go intro | introductoryOffer.paymentMode = .payAsYouGo | Lower price for N periods |
| Pay-upfront intro | introductoryOffer.paymentMode = .payUpFront | Single discounted period |
| Promotional offer | Product.PromotionalOffer (signed, server-issued) | Win-back, loyalty, retention |
| Billing grace period | App Store Connect → Subscription → Grace Period | Lets billing-retry subs keep access 6–16 days |
| Family Sharing | Per-subscription toggle in ASC | Whole-family access on one purchase |
| Subscription Offer Codes | Server-generated redeem codes | Marketing, partnership, churn recovery |
manageSubscriptionsSheet() | iOS 15+ SwiftUI modifier | In-app subscription management — cancellation prevention surface |
| Refund request sheet | iOS 15+ refundRequestSheet(for:) | In-app refund request |
Concept → Why → How → Code
Concept. A subscription is a recurring relationship; retention is the product. Apple provides specific mechanisms — intro offers, promotional offers, grace periods, family sharing, and in-app management surfaces — each of which moves a specific KPI.
Why. Acquisition CAC is paid once; retention compounds. Doubling month-12 retention quadruples LTV. The features that drive retention aren’t user-visible UX — they’re billing surface design.
Tier design — the canonical 3-tier shape
Free ← acquisition top of funnel
Basic ← conversion sweet spot ($)
Pro / Family ← ARPU maximizer ($$)
[Team] ← B2B expansion lane ($$$)
Anchoring matters: people pick the middle tier. Without a high tier, your middle tier looks expensive; with one, it looks reasonable. Notion’s “Business at $18” makes “Plus at $10” feel like a steal.
Introductory offers — three payment modes
import StoreKit
extension Product.SubscriptionOffer {
var displayDescription: String {
switch paymentMode {
case .freeTrial:
return "Free for \(period.value) \(period.unit)"
case .payAsYouGo:
return "\(displayPrice) for \(periodCount) \(period.unit)s"
case .payUpFront:
return "\(displayPrice) for \(period.value) \(period.unit)"
@unknown default:
return ""
}
}
}
// Reading the intro offer attached to a product:
if let intro = product.subscription?.introductoryOffer {
print("Intro offer: \(intro.displayDescription)")
}
Strategy by mode:
| Mode | Use when | Risk |
|---|---|---|
.freeTrial (3/7/14 days) | Habit-forming apps where you need short-term proof of value | Trial abusers, accidental rebill resentment |
.payAsYouGo ($0.99 first 3 months) | Low-friction skin in the game | “Bait-and-switch” complaints if huge jump |
.payUpFront ($9 for first 6 months) | Annual products with high LTV | Higher initial commitment, fewer triallers |
Promotional offers — server-signed win-back
Promotional offers can’t be redeemed organically — your server issues a signed offer to specific user IDs, typically as a win-back for lapsed subscribers.
// Apply a promotional offer at purchase time
let offerSignature: Product.PurchaseOption.PromotionalOffer = .promotionalOffer(
offerID: "win_back_50_off_3mo",
keyID: "L256SYR32L",
nonce: UUID(),
signature: Data(signatureFromServer),
timestamp: Int(Date().timeIntervalSince1970)
)
let result = try await product.purchase(options: [
.appAccountToken(currentUserID),
offerSignature
])
Server-side, you sign offers with your subscription key from App Store Connect:
# Signing a promotional offer (Python)
import hmac, hashlib, base64, uuid, time
def sign_offer(app_bundle_id: str, key_id: str, product_id: str,
offer_id: str, app_account_token: str, key_pem: bytes) -> dict:
nonce = str(uuid.uuid4())
timestamp = str(int(time.time() * 1000))
payload = "\u2063".join([
app_bundle_id, key_id, product_id, offer_id,
app_account_token, nonce, timestamp,
])
# In real code: ECDSA P-256 with subscription key
signature = ecdsa_sign(key_pem, payload.encode())
return {
"offerID": offer_id,
"keyID": key_id,
"nonce": nonce,
"signature": base64.b64encode(signature).decode(),
"timestamp": timestamp,
}
Win-back lifecycle:
- Server-side daily job queries lapsed subscribers (
EXPIREDevent ≥ 24h ago, no resubscribe). - Sends an email/push with a deep link
acme://winback?offer=win_back_50_off_3mo. - Client opens paywall pre-loaded with the offer applied.
- Conversion typically 15–30% — orders of magnitude better than cold acquisition.
Family Sharing toggle
In ASC: each subscription product has a “Family Sharing” toggle. ON means one purchase grants entitlement to all family group members. OFF means each member subscribes individually.
Calm — ON (network value: more meditators = better suggestions)
Apple Music — ON (loss-leader; Apple sells the Family Plan for that reason)
1Password — OFF (separate "Families" tier $7/mo vs Personal $4/mo)
Notion — OFF (B2B; each seat must subscribe)
When ON, family members appear in Transaction.currentEntitlements with ownershipType == .familyShared. Treat them identically for entitlement but track separately for analytics (their churn correlates with the purchaser’s, not their own engagement).
Billing grace period
Configure in ASC under each subscription group. Choices: Off, 6 days, 16 days (varies by subscription period). Effect:
Day 0: renewal fails (expired card)
Without grace: entitlement revoked, user sees paywall immediately
With 16-day grace: entitlement keeps; user is in BILLING_RETRY; banner says "Update payment"
Day 0–15: Apple retries; user fixes card; renewal succeeds → seamless
Day 16: if still failed → entitlement revoked, EXPIRED event
Always enable. Costs you nothing; saves 5–15% of involuntary churn from failed payments.
manageSubscriptionsSheet — your cancellation prevention surface
import SwiftUI
import StoreKit
struct ProfileView: View {
@State private var showManage = false
var body: some View {
Button("Manage subscription") { showManage = true }
.manageSubscriptionsSheet(isPresented: $showManage)
}
}
This sheet is Apple’s UI for cancellation, plan changes, and downgrades. You don’t get to customize it. But you can intercept the moment a user enters it — log the analytic event, show a “Before you go…” retention offer in your own UI before the sheet opens:
Button("Manage subscription") {
Task {
if await shouldShowRetentionOffer() {
// Show your custom offer view first
retentionFlow.start()
} else {
showManage = true
}
}
}
Refund request sheet
.refundRequestSheet(for: transaction.id, isPresented: $showRefund) { result in
// result: .success(.success), .success(.userCancelled), .failure(error)
}
Why surface this proactively? Self-service refund > customer-support ticket. Lower friction = better reviews even when users are unhappy. And users who self-refund vs charge-back have meaningfully better re-subscription rates later.
RevenueCat for cross-platform subscriptions
RevenueCat is the de facto wrapper around StoreKit + Google Play Billing + web (Stripe). It gives you:
- Single SDK for iOS, Android, web, with unified entitlement keys
- Webhook normalization — one webhook format instead of Apple V2 + Google RTDN + Stripe events
- A/B testing via Experiments / Offerings — ship different paywalls to user cohorts without an app update
- Dashboard for cohort retention, MRR, churn, conversion funnels
- Free up to $2.5k MTR; 1% of MTR above (so cheap at small scale, meaningful at $1M ARR+)
import RevenueCat
// In App init:
Purchases.configure(withAPIKey: "appl_xxxx")
Purchases.shared.logIn(currentUserID) { customerInfo, _, _ in
isPro = customerInfo?.entitlements["pro"]?.isActive == true
}
// Purchase:
let offerings = try await Purchases.shared.offerings()
let pkg = offerings.current?.availablePackages.first { $0.identifier == "$rc_annual" }!
let result = try await Purchases.shared.purchase(package: pkg)
isPro = result.customerInfo.entitlements["pro"]?.isActive == true
RevenueCat’s Offerings map to your App Store Connect products. The remote-config layer lets you reshuffle paywall presentations (different products, different ordering, different copy) without an app update — a major superpower for paywall experimentation.
In the wild
- Duolingo Super uses 14-day free trial + aggressive promotional offers for lapsed users (50% off, signed via their backend). Their churn analysis is publicly documented in earnings calls.
- Calm runs A/B paywalls via RevenueCat — different intro offers per cohort, measured weekly.
- Headspace added a “skip the trial” $5.99/month entry-level tier to combat trial abuse; conversion rose 18%.
- Apple TV+ uses Apple’s promotional offer system aggressively — “3 months free with iPhone purchase” is a signed Apple promotional offer.
Common misconceptions
- “Free trials always boost LTV.” They boost trials. They sometimes hurt LTV (trial abusers, post-trial sticker shock). Measure trial→paid conversion and month-3 retention separately.
- “Family Sharing kills revenue.” It can grow it if your retention loops include other family members (Music, Calm). For B2B-leaning products, keep it off.
- “Promotional offers go through Apple’s UI.” They’re applied silently at purchase time when the client passes a valid server-issued signature. The user sees the discounted price; no special UI.
- “Annual subscriptions reduce monthly recurring revenue.” They reduce reported MRR but increase LTV. Move to ARR for reporting.
- “RevenueCat is overkill for small apps.” At < $2.5k MTR it’s free. The dashboard alone justifies it before you ship anything more.
Seasoned engineer’s take
TIP. Always run a 16-day billing grace period. It’s free involuntary-churn prevention. The 5–15% retention bump compounds.
WARNING. Don’t ship promotional offers without server-side rate limiting. A leaked offer ID can be applied unlimited times if your server signs every request. Tie offers to user IDs server-side and reject duplicates.
The non-obvious lesson: subscription design is iterative experimentation, not one-shot config. Ship a paywall, watch conversion + retention for 60 days, change one variable, watch again. RevenueCat or App Store Connect Custom Product Pages give you the substrate; the discipline of running the experiments is what compounds.
Interview corner
Junior — “What’s the difference between a free trial and an introductory offer?” A free trial is one type of introductory offer (paymentMode = .freeTrial). The other two modes are pay-as-you-go (lower price for N periods) and pay-up-front (one discounted lump-sum period).
Mid — “How would you reduce involuntary churn?” Enable billing grace period (16 days). Add an in-app “Update payment method” banner when BILLING_RETRY arrives via Server Notifications. Send a series of emails on day 1, 7, 14 with deep link to update payment.
Senior — “Design a subscription retention system for an app at $2M ARR.” Webhooks land in your server; subscription state machine tracks every user. Three retention surfaces: (1) in-app banner during grace period, (2) push + email sequence on DID_FAIL_TO_RENEW, (3) win-back promotional offers signed server-side for users 7-30 days post-EXPIRED. RevenueCat or equivalent for dashboard/A-B testing of paywalls. Quarterly review of promo-offer cost vs incremental LTV. Refund request sheet exposed prominently to prefer self-service over chargebacks.
Red flag — “We don’t track lifecycle events because our backend has no users.” The moment you ship subscriptions you need a server. Even a tiny one. Without webhooks, you can’t reliably know if a user is still subscribed.
Lab preview
Lab 11.1 wires a 3-tier paywall with introductory free trial, monthly/annual toggle, and restore-purchases, using RevenueCat to abstract StoreKit.