Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

StoreKit 2 Transactions on macOS: Verification, Entitlements, and Delivery

Implement StoreKit 2 purchase handling with verified transactions, durable delivery, entitlement reconciliation, updates, and safe restore semantics.

StoreKit 2 turns purchase events into signed transaction values, but an application still owns the entitlement model and the delivery workflow. A purchase callback is not the same thing as content being safely unlocked, and unlocking a product is not the same thing as keeping access synchronized across reinstalls, another device, a refund, or a subscription renewal. Production code should define how transactions are verified, persisted, applied, reconciled, and eventually finished.

An entitlement is an application decision derived from transaction state. A transaction can describe product identity, purchase and expiration details, revocation, or subscription state. Do not reduce this to a single boolean that is set permanently on the first success result. For subscriptions, access can change over time; for non-consumables, refunds and revocations matter; consumable balances require a separate durable accounting design.

Treat verification as a required gate

StoreKit returns verification results. A .verified result passed StoreKit’s automatic validation, while .unverified did not. Do not unlock paid features from an unverified value just because the product identifier looks familiar. Preserve enough diagnostics to investigate validation failures without logging the raw customer transaction or other sensitive material into general telemetry.

import StoreKit

final class PurchaseObserver {
    func observeUpdates() async {
        for await result in Transaction.updates {
            guard case .verified(let transaction) = result else {
                continue
            }

            await deliver(transaction)
            await transaction.finish()
        }
    }

    private func deliver(_ transaction: Transaction) async {
        // Persist the transaction identity and apply the product's entitlement.
    }
}

This observer illustrates the processing order, not a complete ledger. The delivery method must be implemented so repeating the same transaction does not grant duplicate consumables or duplicate side effects. Keep transaction identity in a durable store and make entitlement application idempotent. If delivery can fail, do not finish the transaction first and hope a later launch reconstructs work from UI state.

The App Store cryptographically signs transaction information as JWS. StoreKit wraps its validation result, and a verified result means the information passed its automatic validation for the device. Apps with server-side entitlement decisions can send the JWS representation to a backend and validate it there with Apple’s documented server library or verification APIs. Do not write a home-grown signature parser and call that equivalent to Apple’s server-side validation model.

Understand purchase outcomes

A purchase call can succeed with a verification result, remain pending while requiring customer action, or be cancelled by the user. Pending does not mean failure and should not be translated into access. For family or approval workflows, the app can later observe a transaction update after the purchase interaction has ended. A cancelled result is not an error to retry automatically; respect the user’s decision.

On success, validate before applying the purchase. Persist a durable record or state transition, grant the service or content, update the UI from that durable state, and only then finish the transaction. The transaction’s finish() call tells the App Store that the app delivered the content or enabled the service. The right point to finish depends on the product: a non-consumable can be granted idempotently from its transaction, while a consumable may require an acknowledged credit in a ledger before completion.

Do not use a transient view model as the only delivery record. If the app crashes between the purchase result and local persistence, the transaction may reappear as unfinished. If it crashes after persistence but before finish(), the event can be processed again. Idempotency makes both paths safe. A transaction may be a trigger to reconcile the durable state rather than an instruction to blindly add one more unit to a counter.

Reconcile current entitlements at launch

Transaction.currentEntitlements is a sequence of the latest transactions for products that currently entitle a customer. Apple documents that it emits non-consumable purchases, the latest auto-renewable subscription transaction when its renewal state is subscribed or in grace period, and the latest non-renewing subscription transaction including finished ones. Refunded or revoked products do not appear. Consumables are excluded; use unfinished or all transaction history where appropriate for consumable recovery.

This sequence is not a general-purpose purchase history. Build current access by iterating it, accepting only verified transactions, and applying product-specific rules. Keep transaction IDs and entitlement sources to explain why the app granted access. If your app stores a cached entitlement for offline startup, reconcile it with StoreKit state and define how stale local state is treated. The cache improves responsiveness but must not become a permanent authority after refunds or subscription changes.

The exact entitlement rules depend on product type. A consumable is normally a balance or a set of durable credits, so a verified purchase event needs exactly-once accounting at the application layer. A non-consumable is generally an ownership flag keyed by product and transaction history. An auto-renewable subscription depends on transaction and renewal status, expiration, revocation, and potentially grace-period rules. A non-renewing subscription needs app-defined expiration semantics because currentEntitlements includes the latest transaction, not an automatic guarantee that the product is still within a chosen duration.

Listen for changes outside the purchase screen

Transaction.updates emits new or updated transactions that can happen outside the app, including purchases on another device. Start the listener early in the application lifecycle and retain the task for the lifetime in which updates should be handled. Process updates through the same verification, idempotent delivery, persistence, and finish path as an in-app purchase result. Two separate handlers with subtly different rules create entitlement drift.

Do not assume updates is a streaming feed that replaces launch reconciliation. Use it for live events and currentEntitlements to compute present access. If the process was not running during a change, reconciliation is the way to reconstruct current state. For server systems, also handle Apple’s App Store Server Notifications and periodic or event-driven server reconciliation according to the backend’s supported design; client callbacks alone do not make server entitlements authoritative.

Restore without forcing unnecessary authentication

Provide a discoverable restore mechanism because customers expect to recover eligible purchases. StoreKit 2 automatically keeps transaction information and subscription status current for normal operation, including after reinstall or use on a new device. The app can use currentEntitlements and transaction history without first forcing an authentication prompt. AppStore.sync() is for rare cases where a user explicitly reports missing transactions; Apple says to call it in response to an explicit action because it prompts for App Store authentication.

Do not call sync() on every launch or silently from a settings screen. A restore button should show progress and a clear result, then re-read entitlements. Make the action useful even when there is nothing new to restore. On failure, explain that account state could not be refreshed rather than showing an empty product catalog as proof that the customer owns nothing.

Keep client and server responsibilities explicit

For local-only content, StoreKit verification may be sufficient for the product’s threat model. For a service delivered by a server, the server should validate signed transaction data and maintain the backend entitlement state used to authorize requests. Do not trust a client-supplied productID or a boolean entitlement without transaction evidence. Conversely, server verification does not remove the need for the client to handle pending, cancelled, revoked, expired, and offline states in a user-appropriate way.

Use stable app-account association only when the product and privacy design support it. Avoid sending unnecessary customer data with transaction events. If the app changes bundle identifiers, product identifiers, subscription groups, or server environments, test migration and reconciliation before release. Sandbox and production transaction environments must not be mixed in a way that grants production access from test purchases.

Failure cases and acceptance tests

Test verified and unverified results, pending approval, cancellation, app termination before delivery, termination after durable grant but before finish, duplicate update delivery, subscription renewal, grace period, expiration, refund, revocation, reinstall, second-device purchase, and offline launch. Test consumable recovery separately because it does not appear in currentEntitlements. Verify that each test yields one durable entitlement change and that a repeated transaction does not double-credit a balance.

Instrument transaction processing with a redacted product identifier, transaction-processing stage, verification outcome, and idempotency result. Do not log receipt or JWS data in ordinary logs. Keep the test matrix tied to StoreKit Configuration files and sandbox accounts, while remembering that a local test environment does not prove App Store server notifications, App Review behavior, or production account edge cases.

The reliable design is a pipeline: observe, verify, persist, apply, present, finish, then reconcile again when needed. StoreKit provides signed transaction information and lifecycle sequences. Your application must make delivery repeatable, preserve durable truth, and distinguish current access from purchase history.

Related:

Sources:

Comments