Built on StoreKit 2 and requires iOS 15+ (macOS 14+, see macOS / Catalyst). 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: "2.6.0")
],
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 your publishable key (csk_pk_…) in your app. There is one per app
and it covers every environment, so the same value works in TestFlight and in
your App Store release. 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.
Promotional offers#
From SDK 2.5.0. An App Store promotional offer, such as a "stay and save" discount for a
customer who just cancelled, must be signed by your server with your In-App Purchase key. Sign
it for the app account token CashSDK stamps on the purchase, then pass the signature to
purchase:
// Server: sign bundleId, keyId, productId, offerId,
// CashSDK.appAccountToken(for: userId)!.uuidString.lowercased(), nonce, timestamp.
let signed = try await backend.signOffer(offerId, productId: product.id)
guard let offer = PromotionalOfferSignature(offerId: signed.offerId, keyId: signed.keyId,
nonce: signed.nonce, base64Signature: signed.signature,
timestamp: signed.timestamp) else { return }
do {
if case .success(let entitlements) = try await CashSDK.shared.purchase(product, promotionalOffer: offer) {
unlock(entitlements)
}
} catch let error as CashSDKError where error.isPromotionalOfferRejection {
// The App Store refused the offer before charging. Do not retry at full price unasked.
}
Your server decides who may redeem an offer: Apple only checks the signature and that the Apple ID subscribed in the group before. Fetch the signature right before the purchase, since Apple refuses one older than 24 hours. Introductory offers, offer codes and win-back offers need no signature.
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()
}
Access bought on the web#
A customer who subscribed through your website with Stripe has the same entitlements here, as long
as identify uses the same user id and the same CashSDK app. Stripe packages in a shared offering are
left out of offerings(), because StoreKit cannot sell them. See
Your iOS and Android apps.
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? Load the StoreKit Products through the SDK and hand the
selected one to purchase(_:). products(for:) returns StoreKit's own Product (localized
price included) and keeps it for five minutes, so the Buy tap opens the sheet without a second
trip to the App Store. Passing a product identifier still works, and so does a Product you
loaded with Product.products(for:) yourself:
let products = try await CashSDK.shared.products(for: ["app.example.pro.monthly",
"app.example.pro.yearly"])
ForEach(products, id: \.id) { product in
Button {
Task { _ = try? await CashSDK.shared.purchase(product) }
} 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.