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: "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.

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

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.

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:

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

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()
}

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.

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

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

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#