Apple SDK
The client for Keywarden on iOS and macOS: App Store purchases, license keys, spend-tier gifts and metered trials behind one signed bundle that verifies offline. Swift 5.9+, iOS 15+ / macOS 12+, and the Keywarden library has no third-party dependencies — Foundation, CryptoKit and Security only.
Install
The package ships three libraries. Take Keywarden always; add KeywardenStoreKit if you sell through the App Store, and KeywardenTesting in your test target only.
// Package.swift
dependencies: [
.package(url: "https://github.com/dikeckaan/keywarden-apple.git", from: "1.0.0"),
],
targets: [
.target(name: "App", dependencies: [
.product(name: "Keywarden", package: "keywarden-apple"),
.product(name: "KeywardenStoreKit", package: "keywarden-apple"),
]),
.testTarget(name: "AppTests", dependencies: [
.product(name: "KeywardenTesting", package: "keywarden-apple"),
]),
]
| Library | What it is | Depends on |
|---|---|---|
Keywarden | Client, bundle verification, storage, transport | Foundation · CryptoKit · Security |
KeywardenStoreKit | StoreKit 2 bridge (launch scan + Transaction.updates) | Keywarden · StoreKit |
KeywardenTesting | FakeKeywarden, MockTransport | Keywarden |
Configure
Configure once, as early as your app has a run loop. Both values come from the app's Setup page in the dashboard: the public key (pk_live_…) and the signing key's SPKI, keyed by its kid.
import Keywarden
let configuration = try Configuration(
apiKey: "pk_live_…",
baseURL: URL(string: "https://api.keywarden.dev")!,
publicKeys: ["k1": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…"],
appSlug: "your-app",
deviceInfo: .none
)
let client = Keywarden.configure(configuration)
Pinning is not optional. Configuration throws rather than degrading silently:
| Condition | Error | Why it is fatal |
|---|---|---|
| no pinned key | .noPinnedKeys | Without pinning, any server — or anyone between you and it — could mint entitlements. |
plain http (not localhost) | .insecureBaseURL | The API key travels on every request. |
existingInstallId outside 8–128 chars | .invalidInstallId | Short ids are guessable; installId is a capability. |
During a key rotation pass both keys — the active one and the one still inside the overlap window — so bundles signed by either verify: publicKeys: ["k2": newSpki, "k1": oldSpki]. The Setup page prints exactly the set you should ship.
deviceInfo controls what leaves the device: .none sends only the installId, .app adds the app version, .full adds the OS version and hardware model. Nothing else is ever collected.
Sync & entitlement state
Every read is synchronous, offline-safe and never blocks. Call them from SwiftUI bodies freely.
if client.has("pro_unlock") { unlockPro() }
client.entitlements() // Set<String>
client.source("pro_unlock") // .store | .license | .gift
client.expiresAt("pro_unlock") // Date?
client.installId() // this install's secret id
Network calls are async throws. Sync on launch and when the app returns to the foreground:
do {
let verdict = try await client.sync() // .granted
} catch let error as KeywardenError {
// .unknown — a network error is NOT a rejection. Nothing is dropped.
print(error.code, error.verdict)
}
Observe changes with a listener (called on the main thread) and re-render:
client.addListener { entitlements in
self.isPro = entitlements.contains("pro_unlock")
}
Verdicts: GRANTED, REJECTED, UNKNOWN
The whole client obeys one rule: only a rejection takes an entitlement away. Anything the server could not decide leaves local state exactly as it was — a paying user is never locked out by a flaky network, a rate limit or a capacity error.
| Server answer | Verdict | Effect on local entitlements |
|---|---|---|
200 with a bundle | .granted | Replaced by the signed bundle. |
200 without a bundle | .granted | Unchanged — nothing changed server-side. |
402 PURCHASE_INVALID · VOIDED | .rejected | Dropped on the next bundle. |
410 LICENSE_REVOKED · LICENSE_EXPIRED · LICENSE_SUSPENDED | .rejected | Dropped on the next bundle. |
409 NO_SEATS_AVAILABLE · DEVICE_LIMIT_REACHED | .unknown | Unchanged. Show a message; do not lock anyone out. |
400 · 401 · 429 · 500 · 502 · no network | .unknown | Unchanged. |
hasVerifiedBundle() answers a different question: has this install ever been handed authoritative state? A verified bundle with an empty entitlement list answers true here and false from has. Use it only when you must retire a legacy licence store exactly once Keywarden becomes the authority — never as an entitlement check.
StoreKit 2 purchases
One line wires StoreKit to Keywarden. It scans Transaction.currentEntitlements and unfinished transactions at launch, then listens to Transaction.updates, sending each signed transaction to POST /v1/store/verify.
import KeywardenStoreKit let sync = StoreKitSync(client: client, source: StoreKitTransactionSource()) sync.start()
Three behaviours are worth knowing because they are deliberate:
| Behaviour | Why |
|---|---|
finish() is called only after the server accepts the transaction. | Finishing on a network error would destroy a purchase the user paid for; an unfinished transaction is replayed on the next launch. |
A transaction with a revocationDate triggers revokeLocal before the server call. | Even with no network, a refunded entitlement stops working immediately. revokeLocal can only ever take rights away. |
The SDK never sends ownedStoreProducts on Apple. | currentEntitlements returns empty on a device signed out of the App Store, and there is no API to tell that apart from "the query failed" — claiming the list was complete would lock out a paying user. On Apple the refund source is the server: App Store Server Notifications V2, the JWS revocationDate, and a live check against the App Store Server API. |
Server-side setup (the In-App Purchase key and the notification URL) is on the Apple App Store setup page. Until a connection exists, /v1/store/verify answers 400 VALIDATION — an operator mistake is never reported as 402, because the SDK would read that as a rejection and drop entitlements.
License keys in the app
Selling outside the store as well? Activate a KW-… key against this install:
do {
_ = try await client.activate(licenseKey: "KW-XXXX-XXXX-XXXX-XXXX")
} catch let e as KeywardenError where e.verdict == .rejected {
// 410 — revoked, expired or suspended. Tell the user the key is not valid.
} catch {
// 409 NO_SEATS_AVAILABLE or a network error: unknown, nothing is lost.
}
let status = try await client.validate(licenseKey: key) // query, not a claim
let released = try await client.deactivate(licenseKey: key)
activate returns a fresh bundle on success; entitlements are dropped by the next signed bundle, never by the client deciding on its own.
Metered features & trials
"Try it three times" counters live on the server, not in a file the user can reset.
let state = try await client.peek(meter: "flow_run")
if state.allowed {
let after = try await client.consume(meter: "flow_run")
print(after.left, "runs left of", after.limit)
}
A failed consume is .unknown: spend nothing and let the user through, or retry — never treat it as "out of runs".
Testing
KeywardenTesting gives you a double with the same reading surface, so app code is tested without a server, a keychain or a signature.
import KeywardenTesting
let fake = FakeKeywarden(entitlements: ["pro_unlock"])
#expect(fake.has("pro_unlock"))
fake.nextVerdict = .rejected
_ = try await fake.sync()
#expect(!fake.has("pro_unlock")) // a rejection drops rights
fake.nextError = KeywardenError(code: "NETWORK", verdict: .unknown, message: "offline")
fake.grant("pro_unlock")
_ = try? await fake.sync()
#expect(fake.has("pro_unlock")) // unknown drops nothing
Need the real client against scripted responses? MockTransport plugs into KeywardenClient(configuration:storage:transport:clock:) with InMemoryStorage and a fixed Clock, so expiry and clock-rollback paths are testable.
Security checklist
- Pin the signing key. Ship the SPKI in the binary. A bundle whose
kidis not pinned is treated as forged — no other key is tried. - Treat
installIdas a secret. With yourpk_it is enough to act as this device. The SDK keeps it in the keychain withkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly— it does not travel to backups or a new device — and never prints it in anydescriptionordumpoutput. - Do not put it in
appAccountToken. That value goes to Apple and appears in plain text in every JWS and notification. The SDK neither sets nor reads it. - Never gate on the verdict alone. Gate on
has(). A.grantedanswer with no bundle means "nothing changed", not "the user is entitled". - Keep the app slug pinned (
appSlug) unless one binary genuinely serves several apps: it stops a bundle issued for another app from being accepted.
Differences from Android
The two SDKs share their semantics on purpose: the same storage keys, the same three-state model, the same source precedence (store > license > gift), the same clock-rollback floor, and both are tested against the same fixture tokens produced by the server's own signing code. What differs is shaped by the platform:
| Android | Apple |
|---|---|
Callbacks (sync(SyncCallback)) | async throws |
SharedPreferences | Keychain (kSecClassGenericPassword) |
Play Billing sends ownedStoreProducts | Not sent — see StoreKit 2 purchases |
| Play Integrity supported | No equivalent; the signed StoreKit JWS is the proof |
A rule added to one SDK is added to the other. If you find a behaviour that differs and is not in this table, it is a bug — please report it.