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

MechanismApple featureEffect
Free trialintroductoryOffer.paymentMode = .freeTrialConversion boost; abuse risk
Pay-as-you-go introintroductoryOffer.paymentMode = .payAsYouGoLower price for N periods
Pay-upfront introintroductoryOffer.paymentMode = .payUpFrontSingle discounted period
Promotional offerProduct.PromotionalOffer (signed, server-issued)Win-back, loyalty, retention
Billing grace periodApp Store Connect → Subscription → Grace PeriodLets billing-retry subs keep access 6–16 days
Family SharingPer-subscription toggle in ASCWhole-family access on one purchase
Subscription Offer CodesServer-generated redeem codesMarketing, partnership, churn recovery
manageSubscriptionsSheet()iOS 15+ SwiftUI modifierIn-app subscription management — cancellation prevention surface
Refund request sheetiOS 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:

ModeUse whenRisk
.freeTrial (3/7/14 days)Habit-forming apps where you need short-term proof of valueTrial 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 LTVHigher 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:

  1. Server-side daily job queries lapsed subscribers (EXPIRED event ≥ 24h ago, no resubscribe).
  2. Sends an email/push with a deep link acme://winback?offer=win_back_50_off_3mo.
  3. Client opens paywall pre-loaded with the offer applied.
  4. 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

  1. “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.
  2. “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.
  3. “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.
  4. “Annual subscriptions reduce monthly recurring revenue.” They reduce reported MRR but increase LTV. Move to ARR for reporting.
  5. “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.


Next: 11.6 — External Payments, Stripe & EU DMA