StoreKit & Monetization
Designing Subscription Infrastructure for a White-Label iOS Engine
Case study overview
- Role
- iOS platform engineer responsible for the subscription infrastructure and its application-facing contract.
- Context
- A white-label iOS engine where each application could expose different products, access levels, authentication states, and advertising rules.
- Challenge
- Reconcile StoreKit transactions with backend-validated entitlements without making feature screens understand receipts, payment queues, or anonymous identity.
- Contribution
- Built the subscription coordination layer, StoreKit adapter, backend receipt-validation flows, anonymous restoration, product reconciliation, and the access APIs consumed by restricted content.
- Outcome
- A single subscription contract for purchasing, restoring, validating, transferring, and applying access consistently across generated applications.
In-app purchases are rarely just about showing a payment sheet.
When I built subscription infrastructure for the iOS engine at GoodBarber, the real challenge was coordinating several systems that describe different parts of the truth. StoreKit owns products, transactions, and the device receipt. The backend validates that receipt and maps purchases to application-specific subscription levels. The app must turn those levels into stable access decisions for both logged-in and anonymous users.
This was one of the projects where platform APIs, backend contracts, product configuration, account identity, and user trust all met in the same workflow.
The White-Label Constraint
GoodBarber is a white-label app builder. The same iOS engine powers many applications, each with its own product catalog, subscription order, account policy, restricted content, design, and advertising rules.
In a single app, product identifiers and entitlement rules can be compiled into the code. In a white-label engine, those assumptions are data. A configured subscription can have an internal platform identifier, a separate App Store product identifier, one or more access levels, localized StoreKit metadata, and application-specific behavior such as removing ads or requiring account creation after purchase.
The infrastructure therefore had to answer more than “did StoreKit return success?” It had to answer:
- Which configured products actually exist in the App Store?
- Does the current receipt represent a purchase that the backend accepts?
- Should access belong to an authenticated user or an anonymous identity?
- Can an existing purchase be restored or transferred before asking the user to pay again?
- Which subscription levels unlock this specific content?
- When should account creation, price consent, or subscription management be presented?
If those answers drift apart, a paying user can remain blocked, a purchase can be linked to the wrong identity, or the paywall can offer a product that is not actually available.
Three Explicit Boundaries
I structured the system around three components with different responsibilities.
The StoreKit adapter isolates the StoreKit 1 API surface: product requests, SKPaymentQueue, transaction observation, receipt refresh, localized prices and periods, introductory trials, price consent, and Apple’s subscription-management screen.
The backend API wrapper owns the server contract: authentication headers, receipt-validation requests, restorable-purchase checks, anonymous restoration, and subscription transfer.
The subscription coordinator applies the product rules above both boundaries. It reconciles configured and App Store products, chooses an authenticated or anonymous flow, prevents duplicate purchase attempts, validates completed purchases with the backend, refreshes account state, exposes subscription levels, and provides the access state consumed by the rest of the app.
This separation prevented payment mechanics from leaking into feature screens. It also made the distinction between a successful Apple transaction and an application entitlement explicit: the first produces evidence; the second is established after backend validation and state refresh.
Reconciling the Product Catalog
The paywall could not blindly display every subscription from the app configuration.
The configuration defines the platform’s subscription order and maps internal identifiers to Apple product identifiers. The subscription coordinator first preserves that configured order, extracts the corresponding Apple identifiers, and asks the StoreKit adapter to fetch the products from App Store Connect. It then intersects the configured products with the identifiers StoreKit actually returned.
Only products present in both systems become purchasable. This protects the UI from stale configuration, products that are not yet approved, regional availability differences, and identifier mistakes. At the same time, preserving the configured order means App Store responses do not unexpectedly rearrange the commercial presentation.
The StoreKit wrapper retains the returned SKProduct values and exposes normalized metadata to the manager: localized title, description, formatted price, subscription period, and free-trial duration. The rest of the app does not need to understand SKProductSubscriptionPeriod, SKProductDiscount, or locale-specific currency formatting.
The Transaction Observer Is Infrastructure
The payment queue is application infrastructure, not paywall-screen state. Transactions can be delivered after a screen disappears or when the app launches with unfinished queue activity, so the observer is started independently of the purchase UI.
The StoreKit adapter owns the SKPaymentTransactionObserver lifecycle and translates queue states into a smaller completion contract. Purchasing and deferred transactions remain pending without blocking the app. Purchased, failed, and restored transactions are finished and routed to the corresponding completion path.
The wrapper also rejects overlapping purchase attempts. A retained purchase completion represents the active request; starting another purchase before it resolves returns a dedicated “purchase in progress” error instead of creating ambiguous UI state.
Before adding the payment to the queue, the wrapper verifies that payments are authorized, resolves the configured identifier to a fetched SKProduct, and passes through the parental gate. This keeps child-protection behavior close to the operation that can spend money rather than relying on every paywall entry point to remember it.
The End-to-End Purchase Sequence
A purchase crosses several asynchronous boundaries:
- The subscription coordinator determines whether the current identity is authenticated or anonymous.
- For a logged-in user, it checks whether the receipt already contains a restorable purchase before starting another payment.
- The StoreKit adapter resolves the product, runs the parental gate, and submits an
SKPaymentto the queue. - The transaction observer reports the terminal StoreKit state and finishes the transaction.
- The coordinator reads the App Store receipt and sends it through the backend API wrapper for validation.
- The API wrapper attaches either the authenticated user’s JWT or the anonymous token context.
- After validation, the manager refreshes the authenticated user or requests a fresh anonymous token so the resulting subscription levels become the new application state.
The purchase is therefore not considered complete at the application level merely because StoreKit produced a purchased transaction. The success path continues through receipt validation and identity refresh. That distinction is what keeps restricted content, account state, and ad removal synchronized.
Receipt Lifecycle and Validation
The app receipt is treated as evidence to validate, not as the final entitlement model. The coordinator reads the receipt from the application bundle and Base64-encodes it for the backend API. If no receipt is available when a purchase or restore flow needs one, the StoreKit adapter starts an SKReceiptRefreshRequest.
Receipt refresh has its own concurrency problem: several features can discover a missing receipt at nearly the same time. Starting multiple refresh requests would be unnecessary and could make completion ordering difficult to reason about. The wrapper queues pending refresh completions behind one active request, then resolves all waiting callers when that request succeeds or fails.
The backend API wrapper sends the receipt with the current identity context. For a logged-in user, that is the user JWT and identifier. Without a logged-in user, it uses the anonymous token managed by the subscription layer. The request path can obtain a token when one is missing and retry authentication failures within a bounded number of attempts.
The backend can then answer questions a local receipt alone cannot answer cleanly for this platform: which GoodBarber subscription levels are active, whether a purchase is already linked to another account, whether it can be transferred, and which anonymous purchases are restorable.
Anonymous Identity as a Real State
Anonymous purchase support was not implemented as a temporary boolean. It has an identity token and a set of subscription levels, both persisted locally so access can survive application launches before account creation.
When those levels change, the manager publishes a subscription-level notification. Feature code can react to the entitlement change without knowing whether it came from a purchase, restore, login, token refresh, or transfer.
If the application requires account registration after subscribing, the anonymous purchase can still finish first. The app then presents login or registration and asks the backend whether the anonymous purchases can be transferred. On success, the purchase identifiers are attached to the authenticated account, the anonymous state is cleared, and the user object is refreshed.
This sequence avoids two damaging experiences: forcing account creation before the user has committed to subscribing, and asking a newly authenticated user to purchase again after already paying anonymously.
Restore as Reconciliation
Restore was designed as a state-reconciliation workflow rather than a single StoreKit button callback.
The manager first avoids restoring when active subscription levels are already present. It then uses the existing receipt or refreshes it, asks the backend which purchases are restorable, and distinguishes among authenticated purchases, anonymous purchases, and purchases already linked to another account.
For authenticated users, a restorable purchase may need to be transferred to the current account. If it is linked elsewhere, the flow can surface that account context and ask before moving it. For anonymous users, the backend validates the receipt through the anonymous restoration endpoint and the manager refreshes the anonymous token and levels.
The same reconciliation runs before a logged-in purchase. If the receipt already represents something the backend can restore, the system restores and refreshes the user instead of submitting another payment. “Restore before purchase” is a small phrase, but it prevents duplicate-payment prompts and is one of the most important trust guarantees in the flow.
Entitlements at the UI Boundary
Feature screens do not inspect transactions or parse receipts. They ask the subscription coordinator for the current subscription levels. For a logged-in user, those levels come from the refreshed user model. Otherwise, they come from the persisted anonymous state.
Restricted content compares its required levels with the current levels and presents the configured access experience when there is no intersection. Advertising uses the same central state to decide whether a subscriber should see ads. The subscription page uses the reconciled product catalog and normalized StoreKit metadata.
This gives the UI a deliberately small contract:
- available products and localized commercial metadata
- subscribed or not subscribed
- current application subscription levels
- whether subscriber advertising is disabled
- purchase, restore, transfer, and account-required outcomes
StoreKit remains behind the platform wrapper, backend details remain behind the API wrapper, and content access remains expressed in the application’s own domain language.
Designing the Failure Paths
Several production details exist specifically to keep failures understandable.
User cancellation is sanitized so it does not produce a technical error alert. Missing products are filtered before display and have a dedicated failure path if purchase is attempted anyway. Restore errors are converted into user-facing outcomes such as already subscribed, nothing valid to restore, or a purchase linked to another account. Price-consent presentation is checked when the app becomes active, and subscription management delegates to Apple’s system UI.
These are not additions around the “real” purchase logic. They are the purchase logic. Payment infrastructure earns trust by making partial and asynchronous states predictable, especially when the App Store, network, backend identity, and application UI do not update at the same moment.
The Reliability Contract
The resulting infrastructure gives the engine a stable boundary: Apple supplies product and transaction evidence, the backend resolves application entitlements, the manager coordinates identity and state transitions, and feature screens consume a small access API.
That architecture scales better than embedding StoreKit in each paywall because it centralizes the difficult decisions: product reconciliation, receipt refresh, backend validation, anonymous continuity, restore-before-purchase, transfer, and entitlement propagation. A paying user should never need to understand any of those layers. They should simply retain the access they purchased.
Continue reading
Related engineering work
Platform Architecture
Evolving Configurable Content Sections into a Reusable Layout Engine
The end-to-end architecture connecting typed content configurations, reusable content views, compositional layouts, legacy content sections, and Native Ads in a white-label iOS engine.
Privacy Engineering
Building Privacy-Aware Feature Gates in a White-Label iOS Engine
Privacy-aware feature gates for age declaration, child protection, consent interpretation, and tracking-safe behavior across a white-label iOS engine.