DOCS LLMs

Swift SDK

The official Swift SDK for LicenseSeat provides a comprehensive, type-safe API for managing software licenses on Apple platforms.

Current stable release: v0.4.2. Review and commit the resolved Package.resolved version before shipping.

Important: Use only a restricted publishable client key scoped to licenses:validate; assume any credential embedded in an app can be extracted. Never ship an administrator or server-write key. Keep end-user license keys out of URLs, logs, crash reports, analytics, and API responses.

Installation

You can install the Swift SDK using the SPM (Swift Package Manager) or through Xcode:

Swift Package Manager

Add to your Package.swift:

dependencies: [
    .package(url: "https://github.com/licenseseat/licenseseat-swift.git", from: "0.4.2")
]

Then add the dependency to your target:

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "LicenseSeat", package: "licenseseat-swift")
    ]
)

Xcode

  1. File > Add Package Dependencies
  2. Enter: https://github.com/licenseseat/licenseseat-swift.git
  3. Select your version requirements and add to your target

Quick Start

import LicenseSeat

// 1. Configure at app launch
LicenseSeatStore.shared.configure(
    apiKey: "pk_live_xxxxxxxx",
    productSlug: "your-product"  // Required
)

// 2. Activate a license without logging the key or installation identifier
_ = try await LicenseSeatStore.shared.activate("USER-LICENSE-KEY")
print("License activated")

// 3. Check status
switch LicenseSeatStore.shared.status {
case .active:
    print("License active")
case .inactive:
    print("No license activated")
default:
    break
}

Configuration

Basic Configuration

LicenseSeatStore.shared.configure(
    apiKey: "pk_live_xxxxxxxx",
    productSlug: "your-product"
)

Advanced Configuration

let config = LicenseSeatConfig(
    apiBaseUrl: "https://licenseseat.com/api/v1",  // v1 API endpoint
    apiKey: "pk_live_xxxxxxxx",
    productSlug: "your-product",                   // Required for all operations
    storagePrefix: "myapp_",
    autoValidateInterval: 3600,                    // Re-validate every hour
    heartbeatInterval: 300,                        // Heartbeat every 5 minutes
    maxRetries: 3,
    retryDelay: 1,
    telemetryEnabled: true,                        // Optional diagnostics; enabled by default
    offlineFallbackMode: .networkOnly,             // Offline fallback strategy
    maxOfflineDays: 7,                             // Maximum signed-token age
    maxClockSkewMs: 300000,                        // 5-minute clock tolerance
    debug: true
)

let licenseSeat = LicenseSeat(config: config)

Or using the builder-style API:

LicenseSeatStore.shared.configure(
    apiKey: "pk_live_xxxxxxxx",
    productSlug: "your-product"
) { config in
    config.autoValidateInterval = 3600
    config.heartbeatInterval = 300
    config.offlineFallbackMode = .networkOnly
    config.maxOfflineDays = 7
    config.debug = true
}

Configuration Options

Option Type Default Description
apiBaseUrl String https://licenseseat.com/api/v1 v1 API endpoint
apiKey String? nil Your publishable API key
productSlug String? nil Required. Product identifier
storagePrefix String licenseseat_ Prefix for cache keys
deviceIdentifier String? Auto-generated Stable custom installation ID; otherwise a random app-scoped ID is persisted
autoValidateInterval TimeInterval 3600 (1 hour) Background validation interval
heartbeatInterval TimeInterval 300 (5 min) Standalone heartbeat interval (0 = disabled)
networkRecheckInterval TimeInterval 30 Offline connectivity check interval
maxRetries Int 3 API retry attempts
retryDelay TimeInterval 1 Base retry delay (exponential backoff)
telemetryEnabled Bool true Include optional device/application telemetry on supported licensing POST requests
offlineFallbackMode OfflineFallbackMode .networkOnly Offline fallback strategy
offlineTokenRefreshInterval TimeInterval 259200 (72 hours) Legacy offline-token refresh interval
maxOfflineDays Int 0 Maximum signed-token age and offline-authority enablement. 0 disables offline authority; enabled values are 1...36,600, and signed expiry still applies.
maxClockSkewMs TimeInterval 300000 (5 min) Clock tamper tolerance
debug Bool false Enable debug logging

Offline Fallback Modes

Mode Description
.networkOnly Falls back only for transport failures, timeouts, and 5xx responses. Authentication, authorization, and malformed-request errors do not erase a valid cache; authoritative license-state errors do. Recommended.
.always Attempts offline validation for nonterminal failures. Revoked, expired, suspended, not-active, and device-not-activated decisions remain authoritative.

License Lifecycle

Activation

do {
    _ = try await LicenseSeatStore.shared.activate("USER-LICENSE-KEY")
    print("License activated")
} catch let error as APIError {
    print("Activation failed: \(error.code ?? "unknown")")
}

With options:

let license = try await LicenseSeatStore.shared.activate(
    "USER-LICENSE-KEY",
    options: ActivationOptions(
        deviceId: "custom-device-id",
        deviceName: "User's MacBook Pro",
        metadata: ["version": "2.0.0", "environment": "production"]
    )
)

Deactivation

try await LicenseSeatStore.shared.deactivate()

Validation

let result = try await LicenseSeatStore.shared.validate(licenseKey: "USER-LICENSE-KEY")

if result.valid {
    print("License is valid")
    print("Plan: \(result.license.planKey)")
    print("Status: \(result.license.status)")

    // Check entitlements from validation
    for entitlement in result.license.activeEntitlements {
        print("Entitlement: \(entitlement.key)")
        if let expiresAt = entitlement.expiresAt {
            print("  Expires: \(expiresAt)")
        }
    }
} else {
    print("Invalid: \(result.code ?? "unknown")")
    print("Message: \(result.message ?? "")")
}

Status Checking

Get Current Status

switch LicenseSeatStore.shared.status {
case .inactive(let message):
    print("No license: \(message)")
    showActivationScreen()

case .pending(let message):
    print("Validating: \(message)")
    showLoadingIndicator()

case .active:
    print("License active")
    enableFeatures()

case .offlineValid(let details):
    print("Valid offline until next sync")
    enableFeatures()
    showOfflineBanner()

case .invalid(let message):
    print("Invalid: \(message)")
    showErrorScreen(message)

case .offlineInvalid(let message):
    print("Expired offline: \(message)")
    showRenewalScreen()
}

Status Types

Status Description
.inactive No license activated
.pending License pending validation
.active License is valid (online)
.offlineValid License is valid (offline check)
.invalid License is invalid
.offlineInvalid License invalid (offline, e.g., expired)

Entitlements

Check feature access based on license entitlements:

let status = LicenseSeatStore.shared.entitlement("premium-features")

switch status.reason {
case nil where status.active:
    enablePremiumFeatures()
case .expired:
    showRenewalPrompt(expiresAt: status.expiresAt)
case .notFound:
    showUpgradePrompt()
case .noLicense:
    showActivationPrompt()
default:
    disablePremiumFeatures()
}

// Access entitlement details
if let entitlement = status.entitlement {
    print("Entitlement key: \(entitlement.key)")
    if let metadata = entitlement.metadata {
        print("Metadata: \(metadata)")
    }
}

Reactive Entitlement Monitoring

LicenseSeatStore.shared.entitlementPublisher(for: "api-access")
    .receive(on: DispatchQueue.main)
    .sink { status in
        apiAccessEnabled = status.active
        if let expiresAt = status.expiresAt {
            scheduleExpirationWarning(at: expiresAt)
        }
    }
    .store(in: &cancellables)

SwiftUI Integration

Property Wrappers

The SDK provides property wrappers for reactive SwiftUI apps:

import SwiftUI
import LicenseSeat

@main
struct MyApp: App {
    init() {
        LicenseSeatStore.shared.configure(
            apiKey: ProcessInfo.processInfo.environment["LICENSESEAT_API_KEY"] ?? "",
            productSlug: "my-app"
        )
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    @LicenseState private var status           // Auto-updates on license changes
    @EntitlementState("pro") private var hasPro // Feature flag

    var body: some View {
        switch status {
        case .active, .offlineValid:
            MainAppView()
                .environment(\.proEnabled, hasPro)
        case .inactive:
            ActivationView()
        case .invalid(let message):
            ErrorView(message: message)
        case .pending:
            ProgressView("Validating...")
        case .offlineInvalid:
            ExpiredView()
        }
    }
}

struct ActivationView: View {
    @State private var licenseKey = ""
    @State private var isLoading = false
    @State private var error: String?

    var body: some View {
        Form {
            TextField("License Key", text: $licenseKey)

            Button("Activate") {
                Task {
                    isLoading = true
                    defer { isLoading = false }
                    do {
                        try await LicenseSeatStore.shared.activate(licenseKey)
                    } catch let apiError as APIError {
                        self.error = apiError.message
                    } catch {
                        self.error = error.localizedDescription
                    }
                }
            }
            .disabled(licenseKey.isEmpty || isLoading)

            if let error {
                Text(error).foregroundColor(.red)
            }
        }
    }
}

UIKit / AppKit Integration

Use Combine publishers for reactive updates:

import LicenseSeat
import Combine

class LicenseManager: ObservableObject {
    @Published var isLicensed = false
    @Published var hasProFeatures = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        LicenseSeatStore.shared.configure(
            apiKey: "pk_live_xxxxxxxx",
            productSlug: "your-product"
        )

        // React to license status changes
        LicenseSeatStore.shared.$status
            .receive(on: DispatchQueue.main)
            .sink { [weak self] status in
                switch status {
                case .active, .offlineValid:
                    self?.isLicensed = true
                default:
                    self?.isLicensed = false
                }
            }
            .store(in: &cancellables)

        // Monitor specific entitlements
        LicenseSeatStore.shared.entitlementPublisher(for: "pro-features")
            .map { $0.active }
            .receive(on: DispatchQueue.main)
            .assign(to: &$hasProFeatures)
    }

    func activate(_ key: String) async throws {
        try await LicenseSeatStore.shared.activate(key)
    }

    func deactivate() async throws {
        try await LicenseSeatStore.shared.deactivate()
    }
}

Event System

Subscribe to SDK events for analytics, UI updates, or custom logic:

// Subscribe with closure (returns AnyCancellable)
let cancellable = licenseSeat.on("activation:success") { data in
    print("License activated!")
    Analytics.track("license_activated")
}

// Unsubscribe by cancelling
cancellable.cancel()

// Or use Combine publishers
licenseSeat.eventPublisher
    .filter { $0.name.hasPrefix("validation:") }
    .sink { event in
        switch event.name {
        case "validation:success":
            updateUI()
        case "validation:offline-success":
            showOfflineBanner()
        case "license:revoked":
            lockFeatures()
        default:
            break
        }
    }
    .store(in: &cancellables)

Available Events

Event Description
activation:start/success/error License activation lifecycle
validation:start/success/failed/error Online validation
validation:offline-success/offline-failed Offline validation
deactivation:start/success/error License deactivation
license:loaded Cached license loaded at startup
license:revoked License revoked by server
offlineToken:verified Legacy offline token signature verified
offlineToken:verificationFailed Legacy offline token verification failed
heartbeat:success Heartbeat acknowledged by server
heartbeat:error Heartbeat request failed
autovalidation:cycle Auto-validation cycle triggered
network:online/offline Connectivity changes
sdk:reset SDK state cleared

Offline Validation

The SDK provides seamless offline support with Ed25519 cryptographic verification:

Note: The current Swift SDK offline flow still uses signed offline tokens. Machine files are the newer preferred offline artifact at the API level, but the Swift SDK has not migrated to them yet. Treat offline tokens as the current Swift implementation detail, not the long-term product direction.

Offline authority is disabled by default. Set maxOfflineDays to an explicit value in 1...36,600 only after choosing the maximum outage window your product accepts. Zero and out-of-range values fail closed; the signed token, underlying license, and entitlement expirations remain additional upper bounds.

LicenseSeatStore.shared.configure(
    apiKey: "pk_live_xxxxxxxx",
    productSlug: "your-product"
) { config in
    config.offlineFallbackMode = .networkOnly     // Network-first fallback mode
    config.maxOfflineDays = 7                     // Maximum age from signed iat
    config.offlineTokenRefreshInterval = 259200   // Refresh every 72 hours
}

How Offline Validation Works

  1. On activation, the SDK fetches a legacy signed offline token from the server
  2. The token contains license data, entitlements, and an Ed25519 signature
  3. When offline, the SDK verifies the signature locally
  4. Clock tamper detection prevents users from bypassing expiration

Security Features

  • Ed25519 Signatures: Offline licenses are cryptographically signed
  • Clock Tamper Detection: Detects system clock manipulation
  • Constant-Time Comparison: Prevents timing attacks on license keys
  • Signed Expiry: The token and underlying license expiry are always enforced
  • Explicit Offline Authority: maxOfflineDays must be 1...36,600 and imposes an app-side age cap without extending signed expiry; 0 disables offline grants
  • Protected Cache: Apple platforms use AfterFirstUnlockThisDeviceOnly Keychain items and safely migrate 0.4.x plaintext state

Manual Offline Methods

// Sync offline assets (downloads the legacy token + signing key, caches them)
await LicenseSeat.shared.syncOfflineAssets()

// Verify cached offline token (use when offline)
let result = await LicenseSeat.shared.verifyCachedOffline()
// ValidationResponse with valid/code/license fields

API Response Format

The v1 API uses Stripe-style conventions with object fields identifying response types.

Activation Response

{
  "object": "activation",
  "id": 12345,
  "fingerprint": "mac_abc123",
  "device_name": "User's MacBook",
  "license_key": "LICENSE-KEY",
  "activated_at": "2025-01-15T10:30:00Z",
  "license": {
    "object": "license",
    "key": "LICENSE-KEY",
    "status": "active",
    "mode": "hardware_locked",
    "plan_key": "pro",
    "seat_limit": 5,
    "active_seats": 1,
    "active_entitlements": [
      {"key": "premium", "expires_at": null, "metadata": null}
    ],
    "product": {"slug": "my-app", "name": "My App"}
  }
}

Deactivation Response

{
  "object": "deactivation",
  "activation_id": 12345,
  "deactivated_at": "2025-01-15T12:00:00Z"
}

Error Handling

do {
    try await LicenseSeatStore.shared.activate("INVALID-KEY")
} catch let error as APIError {
    print("Error code: \(error.code ?? "unknown")")
    print("Message: \(error.message)")
    print("Details: \(error.details ?? [:])")
}

Common Error Codes

Code Description
license_not_found License key doesn't exist
license_expired License has expired
license_suspended License has been suspended
seat_limit_exceeded No available seats
device_not_activated Installation fingerprint has not been activated for this license
product_mismatch License not valid for this product

Telemetry

The SDK includes the following optional telemetry object by default on supported licensing POST requests:

Field Source
sdk_name Always swift
sdk_version LicenseSeatConfig.sdkVersion
os_name macOS, iOS, tvOS, watchOS, visionOS
os_version ProcessInfo.operatingSystemVersion
platform native
device_model sysctlbyname("hw.model")
app_version CFBundleShortVersionString
app_build CFBundleVersion
device_type desktop, phone, tablet, watch, tv, headset
architecture arm64 or x64 (compile-time)
cpu_cores ProcessInfo.processorCount
memory_gb ProcessInfo.physicalMemory (rounded to nearest GB)
locale Locale.current.identifier
language 2-letter code extracted from locale
timezone TimeZone.current.identifier
screen_resolution Native pixel resolution (macOS/iOS only)
display_scale NSScreen.backingScaleFactor / UIScreen.scale

See Telemetry for the full field reference.

Disable the optional object when the host application does not need LicenseSeat analytics:

LicenseSeatStore.shared.configure(
    apiKey: "pk_live_xxxxxxxx",
    productSlug: "your-product"
) { config in
    config.telemetryEnabled = false
}

Core licensing is not anonymous and is not disabled by this setting. Activation, validation, heartbeat, deactivation, and offline-asset requests still send the license/product context and the app-scoped installation fingerprint. The server also observes the source IP and records successful licensing activity. Swift 0.4.2 creates a random app-scoped installation identifier and protects it in Keychain on Apple platforms; it does not derive the default from a hardware UUID. Treat these persistent identifiers and interactions as potentially linked data in the host application's privacy policy and marketplace disclosures.

Platform Support

Platform Minimum Version Notes
macOS 12.0+ Full support with a Keychain-protected app-scoped installation ID
iOS 13.0+ Full support
tvOS 13.0+ Full support
watchOS 8.0+ Core features (no Network.framework)

Debug Report

Generate a diagnostic report for support:

let report = LicenseSeatStore.shared.debugReport()
print(report)
// Contains: SDK version, status, license key prefix (redacted), timestamps, etc.

Reset SDK

Clear cached licensing state and reset to the initial status:

LicenseSeatStore.shared.reset()

Reset intentionally retains the app-scoped installation identifier so a normal reset/reactivation does not consume a new seat. Supply a deliberate custom deviceIdentifier only when the application owns its persistence and migration contract.

Next Steps