How do you design a paywall that is clear, localised and legal, and how do you run experiments on it?

28.5.1 · L3 · Interview

Predict first

What do the per-month price and the saving evaluate to, and does the SwiftUI paywall compile?

import SwiftUI
import StoreKit
import Foundation

struct Paywall: View {
    var body: some View {
        SubscriptionStoreView(groupID: "21234567") {
            VStack { Text("Go Pro").font(.largeTitle); Text("Unlimited exports") }
        }
        .subscriptionStoreControlStyle(.prominentPicker)
        .storeButton(.visible, for: .restorePurchases)
        .subscriptionStorePolicyDestination(url: URL(string: "https://example.com/terms")!, for: .termsOfService)
    }
}

func perMonth(_ price: Decimal, months: Int) -> Decimal {
    var q = price / Decimal(months), r = Decimal()
    NSDecimalRound(&r, &q, 2, .plain)
    return r
}
let annual: Decimal = 59.99, monthly: Decimal = 6.99
let saving = (monthly * 12 - annual) / (monthly * 12) * 100
var s = saving, rounded = Decimal()
NSDecimalRound(&rounded, &s, 0, .down)
let usd = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD")
print("annual per month:", Double(truncating: perMonth(annual, months: 12) as NSNumber).formatted(usd))
print("saving vs monthly:", rounded, "%")
print("Double drift:", 0.1 + 0.2 == 0.3, "| Decimal:", Decimal(string: "0.1")! + Decimal(string: "0.2")! == Decimal(string: "0.3")!)
print("jpy per month:", perMonth(12000, months: 12))
  • $5.00 per month, a 28% saving, Double drift false vs Decimal true, 1000 for yen
  • $5.99 per month and a 14% saving
  • the paywall fails to compile on macOS

Mental model. A good paywall reads like a restaurant menu with prices and the service charge printed on it, not a surprise on the bill.

Start with what the user must understand before paying: the price and billing period from the store (displayPrice plus subscription.subscriptionPeriod), the length of any trial and what it converts to, that it renews automatically until cancelled, how to cancel, a visible Restore Purchases action, and links to terms and privacy. Use the system views where you can: SubscriptionStoreView (iOS 17+) renders the group's products with localised prices, offer text, a restore button, policy links and eligibility-aware trial wording, and ProductView/StoreView cover single products; the check compiles one with a prominent picker, restore button and a terms link (not rendered or run against a store). Where you build your own, derive the secondary numbers from Decimal, never Double: the annual plan at 59.99 is 5.00 per month, a 28 percent saving (rounded down) over twelve months of 6.99, and 0.1 + 0.2 == 0.3 is false for doubles and true for decimals. Currency digits differ (yen has none), so format with the product's priceFormatStyle and compute only ratios yourself.

Localisation covers more than currency: translate the benefit text, handle right-to-left layouts, and let Dynamic Type grow the buttons; the store localises product names and descriptions you configure per language in App Store Connect. For experiments, product page optimisation tests App Store listing assets, not in-app screens, so in-app paywall tests need your own assignment: a stable bucket per user (a hash of a user id, never a random pick per launch), the variant logged with the funnel events, and the same productIDs underneath so the store does the billing. Measure conversion to trial, trial to paid, and retention to the second renewal, not only taps, and run long enough to see renewals. Compliance traps: hiding the price, a trial button that does not state the post-trial price, or dark patterns that make cancelling hard are common rejection reasons and trust killers.

Annual 59.99 divided by 12 is 4.9992, rounded to 5.00; 1 - 59.99/83.88 is about 28.5%, shown as 28 when rounded down.

Common traps

  • Computing savings with Double or a hand-made currency. Use Decimal and the store's format style.
  • Randomising the variant on every launch. A user must see one variant consistently, or the experiment measures noise.

They might follow up with

  • When should a paywall appear? After value is shown (a feature gate, an onboarding step), and never block restore or the free tier's basics.
  • How do I test eligible versus ineligible trials? Use StoreKit test sessions or sandbox accounts; eligibility is the store's decision.

Related questions

  • 28.1.1 Which product types does StoreKit 2 sell, why can Product.products(for:) come back empty, and why must prices…
  • 28.2.2 How do subscription groups and levels govern upgrades, and what are introductory, promotional, offer-code and…
  • 28.5.2 How do you test purchases: StoreKit configuration files, sandbox accounts and TestFlight, and what can each…
  • 25.4.1 How do you design feature flags and remote config for a mobile app: flag kinds, defaults, stable percentage…
  • 2.6.3 How do you localise strings: Localizable.strings, string catalogs, String(localized:)?
  • 2.8.5 Why shouldn’t you use Double for money, and what do you use instead?

How do you test purchases: StoreKit configuration files, sandbox… →