Built on StoreKit 2 and requires iOS 15+ (macOS 14+). Purchases are validated server-side, so entitlements stay trustworthy even if a device is compromised.
iOS SDK
Add in-app purchases and paywalls to your iOS app with Swift and StoreKit 2.
The CashSDK iOS SDK wraps StoreKit 2 in a small, async/await-first API. Run
purchases and read entitlements without touching SKProduct, transactions, or
receipt plumbing directly.
Install#
In Xcode, choose File → Add Package Dependencies… and enter the repository URL:
https://github.com/cashsdk/cashsdk-ios
Or add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/cashsdk/cashsdk-ios.git", from: "1.1.1")
],
targets: [
.target(name: "YourApp", dependencies: [
.product(name: "CashSDK", package: "cashsdk-ios")
])
]
The package has no third-party dependencies.
Configure#
Initialize CashSDK once, as early as possible, typically in your App init or
AppDelegate. Use your publishable key.
import CashSDK
@main
struct MyApp: App {
init() {
CashSDK.configure(publishableKey: "csk_pk_…")
}
var body: some Scene {
WindowGroup { RootView() }
}
}
configure is the only static entry point. Everything else is called on
CashSDK.shared.
Only ship publishable keys (csk_pk_…) in your app. Secret keys (csk_sk_…)
belong on your backend.
Identify the customer#
Link the SDK to your own user ID so entitlements follow the customer across
devices and platforms. The userToken is a short-lived token minted by your
backend. It is the only identity production trusts.
CashSDK.shared.identify(userId: "8841", userToken: tokenFromYourBackend)
// On sign-out:
CashSDK.shared.logout()
Call identify on every launch, not only at sign-in. StoreKit can
redeliver a charged-but-unverified transaction before your app has signed
anyone in, and a persisted token may have expired.
Pass un-padded numeric IDs. Attribution derives from the value of a
numeric ID, so "7", "07" and "007" produce the same token, so a
zero-padded backend will merge two customers onto one account. Use un-padded
IDs, or opaque non-numeric ones.
Check entitlements#
entitlements is a synchronous, offline-valid snapshot backed by an on-disk
cache. Reading it never awaits the network, so it is safe in a View body.
if CashSDK.shared.entitlements.isActive("pro") {
unlockPro()
}
// Highest active entitlement, if you model tiers:
let tier = CashSDK.shared.tier // 0 when free
let name = CashSDK.shared.tierIdentifier // e.g. "pro", or nil
Renewals, refunds, restores and cross-device changes all arrive on one stream:
for await entitlements in CashSDK.shared.entitlementUpdates {
render(tier: entitlements.tierIdentifier ?? "free")
}
You can also set CashSDK.shared.delegate and implement CashSDKDelegate.
Purchase#
Pass a product identifier. CashSDK drives the StoreKit 2 flow, verifies the signed transaction server-side, and returns the customer's updated entitlements.
switch try await CashSDK.shared.purchase("app.example.pro.yearly") {
case .success(let entitlements):
if entitlements.isActive("pro") { unlockPro() }
case .pending:
// Ask-to-Buy or SCA, resolves later via entitlementUpdates.
showPendingNotice()
case .userCancelled:
break
}
A thrown error does not mean the customer was not charged. If
purchase(_:) throws, the transaction is deliberately left unfinished and
StoreKit will redeliver it. Never tell the user "you were not charged";
show a retry instead.
Two errors are worth handling by name:
.purchaseNotAttributed: the server accepted the transaction but could not credit it to anyone (a promoted App Store purchase, or one made beforeidentify). Callidentify; the SDK re-reports and credits it..network/.server: transient, already retried with backoff.
Restore purchases#
Give customers a way to recover access on a new device or after a reinstall.
try await CashSDK.shared.restore()
if CashSDK.shared.entitlements.isActive("pro") {
unlockPro()
}
Consumables#
Balances are held server-side, so they survive reinstalls and follow the customer across devices.
let credits = CashSDK.shared.consumableBalance("credits")
try await CashSDK.shared.spendConsumable(
"credits",
units: 5,
idempotencyKey: "generation:\(requestId)"
)
idempotencyKey must be stable for a given logical spend: the ID of
whatever the spend buys, never a fresh UUID() per attempt. A new key on
retry debits the customer twice.
Paywalls#
Present the paywall your team configured in the dashboard, for a named placement. This is fire-and-forget: if there is no campaign, the device is offline, or the config is bad, it silently advances, so your app is never blocked by a paywall it could not load.
CashSDK.shared.register(placement: "onboarding_finished")
Prefer to build the UI yourself? Read prices straight from StoreKit and call
purchase(_:) with the selected product identifier:
let products = try await Product.products(for: ["app.example.pro.monthly",
"app.example.pro.yearly"])
ForEach(products, id: \.id) { product in
Button {
Task { _ = try? await CashSDK.shared.purchase(product.id) }
} label: {
HStack { Text(product.displayName); Spacer(); Text(product.displayPrice) }
}
}
SwiftUI gating#
import SwiftUI
import CashSDK
struct RootView: View {
@State private var entitlements = CashSDK.shared.entitlements
var body: some View {
Group {
if entitlements.isActive("pro") { ProContent() } else { FreeContent() }
}
.task {
for await update in CashSDK.shared.entitlementUpdates {
entitlements = update
}
}
}
}
Prefer to design paywalls remotely and ship changes without an App Store review? See Paywalls.
Testing#
How you test decides how much of the path you exercise. Both modes are useful; only one reaches our servers.
| Mode | Purchase completes | Reaches CashSDK | Use it for |
|---|---|---|---|
Xcode StoreKit Testing (a .storekit file, simulator or device) | ✅ | ❌ | Paywall layout, offering code, purchase-button states |
| Sandbox (real device, Sandbox Apple ID) | ✅ | ✅ | Verification, entitlements, webhooks, revenue, the whole flow |
StoreKit Testing purchases never reach CashSDK, by design. Apple does not sign them, so there is nothing our servers can verify. They will not appear in your dashboard and they do not grant a server-side entitlement. Local StoreKit entitlements still drive your UI, so paywall work is perfectly testable this way.
The SDK says so in the console the first time it happens (subsystem com.cashsdk.sdk), so
"I bought something and the dashboard is empty" has an answer attached. To exercise the real
path, run on a device signed into a Sandbox Apple ID, under Settings › App Store › Sandbox
Account.
A second silent-looking case, also explained in the console: a purchase that completes
before you call identify(userId:) is held rather than attributed to nobody. It is not
lost. It verifies automatically as soon as you identify. Call identify as early as you know
who the user is.