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:

text
https://github.com/cashsdk/cashsdk-ios

Or add it to your Package.swift:

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.

swift
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.

swift
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.

swift
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:

swift
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.

swift
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 before identify). Call identify; 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.

swift
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.

swift
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.

swift
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:

swift
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#

swift
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
      }
    }
  }
}

Testing#

How you test decides how much of the path you exercise. Both modes are useful; only one reaches our servers.

ModePurchase completesReaches CashSDKUse 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.

Next steps#