Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4a76c2c
feat(customer-center): add CustomerCenterConfiguration model and Supe…
DreamingInBinary Aug 20, 2026
ed2929e
feat(customer-center): add CustomerCenterAction, refund status, ObjC …
DreamingInBinary Aug 20, 2026
7d57865
feat(customer-center): add Customer Center analytics events
DreamingInBinary Aug 20, 2026
77beedd
feat(customer-center): add AppVersionComparator
DreamingInBinary Aug 20, 2026
2c9a973
feat(customer-center): add SupportEmailComposer
DreamingInBinary Aug 20, 2026
8bb052b
feat(customer-center): add PurchasePresentation model and builder
DreamingInBinary Aug 20, 2026
8065120
feat(customer-center): add CustomerCenterPathResolver
DreamingInBinary Aug 20, 2026
c89bd2e
feat(customer-center): add StoreKit transaction lookup
DreamingInBinary Aug 20, 2026
722772d
feat(customer-center): add localized strings
DreamingInBinary Aug 20, 2026
6f63e9a
feat(customer-center): add view-model dependencies and live adapters
DreamingInBinary Aug 20, 2026
d3dea04
feat(customer-center): add CustomerCenterViewModel
DreamingInBinary Aug 20, 2026
79feed6
feat(customer-center): add core SwiftUI views and StoreKit sheets
DreamingInBinary Aug 20, 2026
3616997
feat(customer-center): add survey, history, account, update, duplicat…
DreamingInBinary Aug 20, 2026
1f070fc
feat(customer-center): add delegate protocols and UIKit view controller
DreamingInBinary Aug 20, 2026
91f3e89
feat(customer-center): add CustomerCenterManager and Superwall.presen…
DreamingInBinary Aug 21, 2026
412a10f
fix(customer-center): lazily create CustomerCenterManager on the main…
DreamingInBinary Aug 21, 2026
2dc9837
feat(customer-center): add SwiftUI presentation and callback modifiers
DreamingInBinary Aug 21, 2026
417e0a2
feat(customer-center): examples, docs, changelog
DreamingInBinary Aug 21, 2026
52e4f98
fix(customer-center): final review fixes — dedupe renewals, sheet han…
DreamingInBinary Aug 21, 2026
b2244d9
refactor(customer-center): rename noActiveScreen to noPurchasesScreen
DreamingInBinary Aug 21, 2026
9b19ab3
revert(customer-center): restore authorship headers on unrelated files
DreamingInBinary Aug 21, 2026
e8b454f
fix(customer-center): rename the SwiftUI modifier to presentSuperwall…
DreamingInBinary Aug 21, 2026
23559e3
fix(customer-center): label the cancel path "Cancel subscription"
DreamingInBinary Aug 21, 2026
4558419
fix(customer-center): stop the manage-subscriptions sheet being dropped
DreamingInBinary Aug 21, 2026
2ee92f9
fix(customer-center): drop the disclosure chevron from action rows
DreamingInBinary Aug 21, 2026
6473567
fix(customer-center): stop the SDK restore alert doubling with the Cu…
DreamingInBinary Aug 21, 2026
6a1e1c9
fix(customer-center): animate the update banner's dismissal
DreamingInBinary Aug 21, 2026
12155dc
fix(customer-center): fire didDismiss from a visibility count, not th…
DreamingInBinary Aug 21, 2026
2001d65
fix(customer-center): widen the dismissal debounce past a nav transition
DreamingInBinary Aug 21, 2026
8c763af
chore(release): bump version to 4.17.0
yusuftor Aug 24, 2026
97702aa
fix(customer-center): review fixes — dismissal delivery, locale, diag…
yusuftor Aug 24, 2026
7ab4a98
test: run the suite in parallel with per-instance cache isolation
yusuftor Aug 24, 2026
712eee1
fix(customer-center): stop hiding purchases the user cannot reach
DreamingInBinary Aug 25, 2026
a5b8fc1
feat(customer-center): support pushing onto a navigation stack
DreamingInBinary Aug 25, 2026
28691d5
fix(customer-center): close the review gaps in the pushed presentatio…
DreamingInBinary Aug 25, 2026
0b90c4d
feat(customer-center): look the latest app version up from the App Store
DreamingInBinary Aug 27, 2026
baa2a95
docs(customer-center): record the phased-release limitation
DreamingInBinary Aug 27, 2026
d75c8ce
fix(customer-center): make the web subscription path make sense
DreamingInBinary Aug 27, 2026
64f2464
feat(customer-center): show prices for web purchases
DreamingInBinary Aug 27, 2026
1ef95b4
feat(customer-center): read a title out of the product identifier
DreamingInBinary Aug 27, 2026
83bd0e4
refactor(customer-center): read a web product's name instead of inven…
DreamingInBinary Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@

The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.

## 4.16.4
## 4.17.0

### Enhancements

- Adds the Customer Center: a native, self-service screen where users can view their subscriptions and purchases, restore purchases, manage or cancel a subscription, request a refund, change plans, contact support, answer exit surveys and browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). Requires iOS 15+.
- Adds `CustomerCenterDelegate` callbacks and the `customerCenterOpen`, `customerCenterClose`, `customerCenterAction`, `customerCenterSurveyResponse` and `customerCenterRefundRequest` events.
- The Customer Center's update banner now finds the published version itself, by looking the app up on the App Store, so `latestAppVersion` no longer has to be kept current by hand. Set `SuperwallOptions.customerCenter.support.checksAppStoreForUpdates = false` to opt out, or keep setting `latestAppVersion` — a configured version always wins and skips the lookup. The check is skipped on TestFlight, sandbox and simulator builds, whose version is normally ahead of the App Store. Note that Apple phases releases in over seven days while the lookup sees a new version immediately, so early in a release some customers may be prompted to update before the build reaches them.
- The Customer Center now shows prices for subscriptions bought on the web (Stripe, Paddle). StoreKit can't resolve a web product, so those cards previously rendered with no price; the price is now read from the Superwall product catalogue when StoreKit returns nothing. Titles still show the product identifier, since the catalogue doesn't return a display name yet; the SDK reads one as soon as it does.
- Improved the Customer Center for subscriptions bought on the web (Stripe, Paddle). The management row is now labelled "Manage subscription" rather than "Cancel subscription", since a web management page does more than cancel; it stays visible when no management URL is configured, explaining that the link is in the customer's emailed receipt, instead of disappearing and leaving them with no action at all; and feedback surveys are skipped for web flows, which hand off to a browser rather than completing in the app.
- `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible.

### Fixes

- Fixes the Customer Center's dismissal callback and close event sometimes not firing.
- Formats Customer Center dates using the locale set in the SDK options instead of the device locale.
- Lists active entitlements instead of product identifiers in the Customer Center support email.
- Fixes equal Customer Center configurations hashing differently.
Comment on lines +18 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These four entries sit under ### Fixes in the same release whose ### Enhancements introduces the Customer Center, so they read as fixes to a feature nobody has shipped against. The second one is also inaccurate: environment.locale resolves to DeviceHelper.preferredLocaleIdentifier, which is Locale.preferredLanguages.first — the device language — not SuperwallOptions.localeIdentifier, so setting that option still changes neither the dates nor the strings.

- Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately.
- Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them.

Expand Down
4 changes: 4 additions & 0 deletions Examples/Advanced/Advanced.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
888F48DB27DBA8A9009C74A3 /* Rubik-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 888F48DA27DBA8A9009C74A3 /* Rubik-Bold.ttf */; };
88A801D028EAE717004244CA /* SuperwallSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A801CF28EAE717004244CA /* SuperwallSubscriptionView.swift */; };
88C49B712D9C2B2300DFE335 /* Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C49B702D9C2B2300DFE335 /* Delegate.swift */; };
0397C21A03B38109D5BA5BEA /* CustomerCenterExampleDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D68675E18A19CC047DCB958 /* CustomerCenterExampleDelegate.swift */; };
88EE539627DF6D0A00F1FFFB /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88EE539527DF6D0A00F1FFFB /* WelcomeView.swift */; };
88F9F1E327E21718004FCE83 /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F9F1E227E21718004FCE83 /* InfoView.swift */; };
/* End PBXBuildFile section */
Expand All @@ -47,6 +48,7 @@
888F48DA27DBA8A9009C74A3 /* Rubik-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Rubik-Bold.ttf"; sourceTree = "<group>"; };
88A801CF28EAE717004244CA /* SuperwallSubscriptionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SuperwallSubscriptionView.swift; sourceTree = "<group>"; };
88C49B702D9C2B2300DFE335 /* Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Delegate.swift; sourceTree = "<group>"; };
9D68675E18A19CC047DCB958 /* CustomerCenterExampleDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterExampleDelegate.swift; sourceTree = "<group>"; };
88EE539127DF64BB00F1FFFB /* Superwall_Advanced-Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Superwall_Advanced-Products.storekit"; sourceTree = "<group>"; };
88EE539527DF6D0A00F1FFFB /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = "<group>"; };
88F9F1E227E21718004FCE83 /* InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -104,6 +106,7 @@
888F488727DA6B0F009C74A3 /* SuperwallAdvancedApp.swift */,
88EE539527DF6D0A00F1FFFB /* WelcomeView.swift */,
888F48C227DB8584009C74A3 /* HomeView.swift */,
9D68675E18A19CC047DCB958 /* CustomerCenterExampleDelegate.swift */,
);
path = Advanced;
sourceTree = "<group>";
Expand Down Expand Up @@ -271,6 +274,7 @@
887A26712D118388002B8E9B /* RCPurchaseController.swift in Sources */,
887A266F2D11832A002B8E9B /* SWPurchaseController.swift in Sources */,
888F488827DA6B0F009C74A3 /* SuperwallAdvancedApp.swift in Sources */,
0397C21A03B38109D5BA5BEA /* CustomerCenterExampleDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
36 changes: 36 additions & 0 deletions Examples/Advanced/Advanced/CustomerCenterExampleDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// CustomerCenterExampleDelegate.swift
// Advanced
//
// Created by Claude on 20/08/2026.
//

import Foundation
import SuperwallKit

/// An example `CustomerCenterDelegate` that prints each callback it receives.
///
/// `Superwall.shared.presentCustomerCenter(delegate:)` retains this for the duration of the
/// presentation, so it's safe to create a fresh instance each time you present.
final class CustomerCenterExampleDelegate: CustomerCenterDelegate {
func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) {
print("[Customer Center] shouldRestorePurchases")
resume(true)
}

func customerCenter(didSelect action: CustomerCenterAction, for purchase: SubscriptionTransaction?) {
print("[Customer Center] didSelect action: \(action), purchase: \(String(describing: purchase))")
}

func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterAction) {
print("[Customer Center] didCompleteSurvey: \(surveyId), optionId: \(optionId), action: \(action)")
}

func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) {
print("[Customer Center] didCompleteRefundRequestFor: \(productId), status: \(status)")
}

func customerCenterDidDismiss() {
print("[Customer Center] didDismiss")
}
}
28 changes: 28 additions & 0 deletions Examples/Advanced/Advanced/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ struct HomeView: View {
Superwall.shared.userAttributes["firstName"] as? String
}

/// Presents the Customer Center with a code-built configuration and a delegate that prints
/// each callback it receives. See `CustomerCenterExampleDelegate`.
private func presentCustomerCenter() {
let configuration = CustomerCenterConfiguration(
managementScreen: .init(
paths: [
.init(id: "restore", type: .restore),
.init(id: "change_plan", type: .changePlan()),
.init(id: "refund", type: .refund()),
.init(id: "manage_subscription", type: .manageSubscription),
.init(id: "faq", type: .url(URL(string: "https://superwall.com/faq")!, openMethod: .inApp)),
.init(id: "contact_support", type: .contactSupport)
]
),
noPurchasesScreen: .init(
paths: [.init(id: "restore", type: .restore)]
),
support: .init(email: "support@superwall.com")
)
Superwall.shared.presentCustomerCenter(
configuration: configuration,
delegate: CustomerCenterExampleDelegate()
)
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Expand Down Expand Up @@ -82,6 +107,9 @@ struct HomeView: View {
page = .diamond
}
}
BrandedButton(title: "Customer Center") {
presentCustomerCenter()
}
}
.padding(.horizontal)
}
Expand Down
3 changes: 3 additions & 0 deletions Examples/Basic/Basic/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ struct HomeView: View {
page = .gated
}
}
BrandedButton(title: "Customer Center") {
Superwall.shared.presentCustomerCenter()
}
}
.padding(.horizontal)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,73 @@ enum InternalSuperwallEvent {
}
}

struct CustomerCenterOpen: TrackableSuperwallEvent {
let screen: String
/// How the Customer Center was presented: `"sheet"` or `"embedded"`.
let presentation: String
var superwallEvent: SuperwallEvent { .customerCenterOpen(screen: screen) }
var audienceFilterParams: [String: Any] = [:]
func getSuperwallParameters() async -> [String: Any] {
["screen": screen, "presentation": presentation]
}
}

struct CustomerCenterClose: TrackableSuperwallEvent {
let superwallEvent: SuperwallEvent = .customerCenterClose
var audienceFilterParams: [String: Any] = [:]
func getSuperwallParameters() async -> [String: Any] { [:] }
}

struct CustomerCenterAction: TrackableSuperwallEvent {
let action: SuperwallKit.CustomerCenterAction
let pathId: String
let productId: String?
var superwallEvent: SuperwallEvent { .customerCenterAction(action: action, pathId: pathId, productId: productId) }
var audienceFilterParams: [String: Any] = [:]
func getSuperwallParameters() async -> [String: Any] {
var params: [String: Any] = ["action": action.analyticsName, "path_id": pathId]
if let productId { params["product_id"] = productId }
if case .url(let url) = action { params["url"] = url.absoluteString }
if case .custom(let identifier) = action { params["custom_identifier"] = identifier }
return params
}
}

struct CustomerCenterSurveyResponse: TrackableSuperwallEvent {
let surveyId: String
let optionId: String
let action: SuperwallKit.CustomerCenterAction
let pathId: String
let productId: String?
var superwallEvent: SuperwallEvent {
.customerCenterSurveyResponse(
surveyId: surveyId,
optionId: optionId,
action: action,
pathId: pathId,
productId: productId
)
}
var audienceFilterParams: [String: Any] = [:]
func getSuperwallParameters() async -> [String: Any] {
var params: [String: Any] = [
"survey_id": surveyId, "option_id": optionId, "action": action.analyticsName, "path_id": pathId
]
if let productId { params["product_id"] = productId }
return params
}
}

struct CustomerCenterRefundRequest: TrackableSuperwallEvent {
let productId: String
let status: CustomerCenterRefundStatus
var superwallEvent: SuperwallEvent { .customerCenterRefundRequest(productId: productId, status: status) }
var audienceFilterParams: [String: Any] = [:]
func getSuperwallParameters() async -> [String: Any] {
["product_id": productId, "status": status.analyticsName]
}
}

enum PaywallPreloadState {
case start
case complete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,28 @@ public enum SuperwallEvent {
/// When the test mode modal is closed.
case testModeModalClose

/// When the Customer Center is presented. `screen` is `management` or `no_purchases`.
case customerCenterOpen(screen: String)

/// When the Customer Center is dismissed.
case customerCenterClose

/// When the user taps a path in the Customer Center.
case customerCenterAction(action: CustomerCenterAction, pathId: String, productId: String?)

/// When the user answers a Customer Center survey.
// swiftlint:disable:next enum_case_associated_values_count
case customerCenterSurveyResponse(
surveyId: String,
optionId: String,
action: CustomerCenterAction,
pathId: String,
productId: String?
)

/// When a refund request started from the Customer Center completes.
case customerCenterRefundRequest(productId: String, status: CustomerCenterRefundStatus)

/// When a user navigates to a page in a multi-page paywall.
case paywallPageView(
paywallInfo: PaywallInfo,
Expand Down Expand Up @@ -564,6 +586,16 @@ extension SuperwallEvent {
return .init(objcEvent: .testModeModalOpen)
case .testModeModalClose:
return .init(objcEvent: .testModeModalClose)
case .customerCenterOpen:
return .init(objcEvent: .customerCenterOpen)
case .customerCenterClose:
return .init(objcEvent: .customerCenterClose)
case .customerCenterAction:
return .init(objcEvent: .customerCenterAction)
case .customerCenterSurveyResponse:
return .init(objcEvent: .customerCenterSurveyResponse)
case .customerCenterRefundRequest:
return .init(objcEvent: .customerCenterRefundRequest)
case .paywallPageView:
return .init(objcEvent: .paywallPageView)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//
// Created by Yusuf Tör on 07/11/2022.
//
// swiftlint:disable file_length
// swiftlint:disable file_length type_body_length

import Foundation

Expand Down Expand Up @@ -263,6 +263,17 @@ public enum SuperwallEventObjc: Int, CaseIterable {
/// When install attribution is resolved or fails to resolve.
case attributionMatch

/// When the Customer Center is presented.
case customerCenterOpen
/// When the Customer Center is dismissed.
case customerCenterClose
/// When the user taps a path in the Customer Center.
case customerCenterAction
/// When the user answers a Customer Center survey.
case customerCenterSurveyResponse
/// When a refund request started from the Customer Center completes.
case customerCenterRefundRequest

public init(event: SuperwallEvent) {
self = event.backingData.objcEvent
}
Expand Down Expand Up @@ -427,6 +438,16 @@ public enum SuperwallEventObjc: Int, CaseIterable {
return "testModeModal_open"
case .testModeModalClose:
return "testModeModal_close"
case .customerCenterOpen:
return "customerCenter_open"
case .customerCenterClose:
return "customerCenter_close"
case .customerCenterAction:
return "customerCenter_action"
case .customerCenterSurveyResponse:
return "customerCenter_surveyResponse"
case .customerCenterRefundRequest:
return "customerCenter_refundRequest"
case .paywallPageView:
return "paywall_page_view"
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public final class SuperwallOptions: NSObject, Encodable {
/// Configures the appearance and behaviour of paywalls.
public var paywalls = PaywallOptions()

/// Configures the Customer Center presented via ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)``.
public var customerCenter = CustomerCenterConfiguration.default

/// A mapping of local resource IDs to ``AssetResource`` values.
///
/// Use this to serve paywall assets (images, videos, Lottie animations) from the app
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// StoreKitTransactionLookup.swift
// SuperwallKit
//
// Created by Jordan Morgan on 20/08/2026.
//

import Foundation
import StoreKit

protocol StoreKitTransactionLooking: Sendable {
func latestTransactionID(for productId: String) async -> UInt64?
func isFamilyShared(productId: String) async -> Bool
}

@available(iOS 15.0, *)
struct StoreKitTransactionLookup: StoreKitTransactionLooking {
func latestTransactionID(for productId: String) async -> UInt64? {
guard case .verified(let transaction)? = await Transaction.latest(for: productId) else {
return nil
}
return transaction.id
}

func isFamilyShared(productId: String) async -> Bool {
guard case .verified(let transaction)? = await Transaction.latest(for: productId) else {
return false
}
return transaction.ownershipType == .familyShared
}
}
Loading
Loading