Integrate UnifiedLicensing license validation into your iOS or iPadOS app using native Swift and URLSession. No third-party SDK required — everything works with the standard library.
The fastest way to validate a license is a single async call with URLSession:
import Foundation
let url = URL(string: "https://api.unifiedlicensing.com/api/v1/validate-license")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("ul_YOUR_KEY", forHTTPHeaderField: "x-vendor-api-key")
let body = [
"license_key": "XXXX-XXXX-XXXX-XXXX",
"product_key": "my-product",
"platform": "mobile"
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, _) = try await URLSession.shared.data(for: request)
let result = try JSONDecoder().decode(ValidationResponse.self, from: data)
print("Valid:", result.success)
Before writing any code you need two identifiers:
ul_.You will pass both values on every request: the API key goes in the x-vendor-api-key header, and the product key goes in the JSON body as product_key.
No special configuration is needed:
NSAppTransportSecurity exceptions required.LicenseManager.swift) and paste the manager class below.// File: LicenseManager.swift // Add via File > New > File... > Swift File in Xcode import Foundation
Here is a complete LicenseManager class using modern Swift concurrency (async/await) that wraps all four endpoints:
import Foundation
struct ValidationResult: Codable {
let success: Bool
let quotaRemaining: Int?
enum CodingKeys: String, CodingKey {
case success
case quotaRemaining = "quota_remaining"
}
}
struct TrialResult: Codable {
let valid: Bool
let expiresAt: String?
enum CodingKeys: String, CodingKey {
case valid
case expiresAt = "expires_at"
}
}
enum LicenseError: LocalizedError {
case invalidResponse
case http(Int)
case quotaExceeded
case network(Error)
var errorDescription: String? {
switch self {
case .invalidResponse: return "Unexpected response from license server."
case .http(let code): return "Server returned HTTP \(code)."
case .quotaExceeded: return "Validation quota exhausted. Try again later."
case .network(let err): return err.localizedDescription
}
}
}
final class LicenseManager {
static let shared = LicenseManager()
private let baseURL = "https://api.unifiedlicensing.com/api/v1"
// Load these from Keychain or config - never hardcode in source.
private let apiKey = Secrets.vendorAPIKey // "ul_..."
private let productKey = Secrets.productKey // e.g. "my-ios-app"
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
// MARK: - Core request helper
private func post<T: Decodable>(
_ path: String,
body: [String: Any]
) async throws -> T {
guard let url = URL(string: baseURL + path) else {
throw LicenseError.invalidResponse
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "x-vendor-api-key")
request.timeoutInterval = 15
request.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LicenseError.invalidResponse
}
switch http.statusCode {
case 200...299:
return try JSONDecoder().decode(T.self, from: data)
case 429:
throw LicenseError.quotaExceeded
default:
throw LicenseError.http(http.statusCode)
}
} catch let error as LicenseError {
throw error
} catch {
throw LicenseError.network(error)
}
}
// MARK: - Public API
func validate(_ licenseKey: String) async throws -> ValidationResult {
let result: ValidationResult = try await post(
"/validate-license",
body: [
"license_key": licenseKey,
"product_key": productKey,
"platform": "mobile"
]
)
if result.success {
OfflineCache.storeLastValidation(Date())
}
return result
}
func checkTrial(_ trialToken: String) async throws -> TrialResult {
try await post(
"/check-trial",
body: [
"trial_token": trialToken,
"product_key": productKey
]
)
}
func activateDevice(_ licenseKey: String, deviceId: String) async throws {
struct Empty: Codable {}
let _: Empty = try await post(
"/activate-device",
body: [
"license_key": licenseKey,
"product_key": productKey,
"device_id": deviceId
]
)
}
func deactivateDevice(_ licenseKey: String, deviceId: String) async throws {
struct Empty: Codable {}
let _: Empty = try await post(
"/deactivate-license",
body: [
"license_key": licenseKey,
"product_key": productKey,
"device_id": deviceId
]
)
}
}
Persist the license key locally so users only activate once. Validate automatically when the app launches:
import SwiftUI
@main
struct MyAppApp: App {
@StateObject private var licenseVM = LicenseViewModel()
init() {
// Auto-validate stored license at launch
Task { @MainActor in
await licenseVM.restoreAndValidate()
}
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(licenseVM)
}
}
}
@MainActor
final class LicenseViewModel: ObservableObject {
@Published var isLicensed = false
@Published var isLoading = false
@Published var errorMessage: String?
private let defaults = UserDefaults.standard
private let licenseKeyDefaultsKey = "com.myapp.licenseKey"
var savedLicenseKey: String? {
get { defaults.string(forKey: licenseKeyDefaultsKey) }
set { defaults.set(newValue, forKey: licenseKeyDefaultsKey) }
}
func restoreAndValidate() async {
guard let key = savedLicenseKey else { return }
isLoading = true
defer { isLoading = false }
do {
let result = try await LicenseManager.shared.validate(key)
isLicensed = result.success
if !result.success {
errorMessage = "Your license is no longer active."
}
} catch {
// Fall back to cached state when offline (see section 7)
isLicensed = OfflineCache.wasRecentlyValid()
}
}
}
UserDefaults — see section 11.Bind each activation to a device so one license cannot be shared across unlimited devices. On iOS, UIDevice.current.identifierForVendor is the natural device ID:
import UIKit
extension UIDevice {
/// Stable per-vendor device identifier. Persists across app launches;
/// resets only if every app from this vendor is uninstalled.
var deviceId: String {
identifierForVendor?.uuidString ?? UUID().uuidString
}
}
// Usage during activation:
func activate(licenseKey: String) async throws {
let deviceId = UIDevice.current.deviceId
try await LicenseManager.shared.activateDevice(licenseKey, deviceId: deviceId)
// Save only after successful activation
licenseVM.savedLicenseKey = licenseKey
}
When a user wants to move their license to a new phone, call /deactivate-license first:
func deactivate() async throws {
guard let key = licenseVM.savedLicenseKey else { return }
try await LicenseManager.shared.deactivateDevice(key, deviceId: UIDevice.current.deviceId)
licenseVM.savedLicenseKey = nil
licenseVM.isLicensed = false
}
Users may launch your app without connectivity. Cache the last successful validation and allow a grace window:
import Foundation
enum OfflineCache {
private static let key = "com.myapp.lastValidatedAt"
private static let gracePeriod: TimeInterval = 72 * 3600 // 72 hours
static func storeLastValidation(_ date: Date) {
UserDefaults.standard.set(date.timeIntervalSince1970, forKey: key)
}
/// True if the app validated successfully within the grace period.
static func wasRecentlyValid() -> Bool {
let last = UserDefaults.standard.double(forKey: key)
guard last > 0 else { return false }
let elapsed = Date().timeIntervalSince1970 - last
return elapsed < gracePeriod
}
}
In your view model, validate online on launch but fall back to the cache when the network fails:
func validateOnLaunch() async {
guard let key = savedLicenseKey else { return }
do {
let result = try await LicenseManager.shared.validate(key)
isLicensed = result.success
} catch {
// Network unreachable - trust the offline cache
isLicensed = OfflineCache.wasRecentlyValid()
if !isLicensed {
errorMessage = "License could not be verified while offline."
}
}
}
If your product offers trials, exchange a trial token for its expiry date and show a countdown in-app:
struct TrialStatusView: View {
@State private var expiresAt: Date?
@State private var isValid = false
@State private var errorText: String?
var body: some View {
VStack(spacing: 12) {
if let expiry = expiresAt, isValid {
let remaining = Calendar.current.dateComponents(
[.day, .hour], from: Date(), to: expiry
)
Text("Trial ends in \(remaining.day ?? 0)d \(remaining.hour ?? 0)h")
.font(.headline)
} else if let errorText {
Text(errorText).foregroundColor(.red)
} else {
ProgressView("Checking trial...")
}
}
.task { await checkTrial() }
}
private func checkTrial() async {
do {
let token = UserDefaults.standard.string(forKey: "trialToken") ?? ""
let result = try await LicenseManager.shared.checkTrial(token)
isValid = result.valid
if let iso = result.expiresAt {
let formatter = ISO8601DateFormatter()
expiresAt = formatter.date(from: iso)
}
} catch {
errorText = error.localizedDescription
}
}
}
Use BGTaskScheduler to re-validate licenses periodically, even when the user does not open the app daily:
import BackgroundTasks
// In Info.plist add:
// BGTaskSchedulerPermittedIdentifiers = ["com.myapp.license-refresh"]
@main
struct MyAppApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.myapp.license-refresh", using: nil
) { task in
Self.handleRefresh(task: task as! BGAppRefreshTask)
}
}
var body: some Scene {
WindowGroup { RootView() }
.onChange(of: scenePhase) { phase in
if phase == .background {
scheduleLicenseRefresh()
}
}
}
private func scheduleLicenseRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.myapp.license-refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 24 * 3600) // daily
try? BGTaskScheduler.shared.submit(request)
}
private static func handleRefresh(task: BGAppRefreshTask) {
scheduleNext()
task.expirationHandler = { task.setTaskCompleted(success: false) }
Task {
if let key = UserDefaults.standard.string(forKey: "com.myapp.licenseKey") {
let result = try? await LicenseManager.shared.validate(key)
OfflineCache.storeLastValidation(Date())
task.setTaskCompleted(success: result?.success == true)
} else {
task.setTaskCompleted(success: false)
}
}
}
private static func scheduleNext() {
let request = BGAppRefreshTaskRequest(identifier: "com.myapp.license-refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 24 * 3600)
try? BGTaskScheduler.shared.submit(request)
}
}
Handle these cases explicitly in production code:
| Situation | Detection | Recommended action |
|---|---|---|
| Invalid license | success == false on 200 | Show activation screen again |
| Bad API key / auth | HTTP 401/403 | Check the x-vendor-api-key header value |
| Quota exhausted | HTTP 429 | Fall back to offline cache; retry later |
| Network failure | URLError thrown | Use cached validation result |
| Malformed response | DecodingError | Treat as transient; retry with backoff |
func robustValidate(_ key: String) async -> Bool {
do {
let result = try await LicenseManager.shared.validate(key)
return result.success
} catch LicenseError.quotaExceeded {
// Server-side rate limit hit - degrade gracefully
return OfflineCache.wasRecentlyValid()
} catch LicenseError.network {
return OfflineCache.wasRecentlyValid()
} catch {
return false
}
}
xcconfig + Info.plist) or fetched at runtime — not pasted into a Swift file that ships in the binary.UserDefaults. The Keychain survives reinstalls and is encrypted at rest.import Security
enum KeychainStore {
private static let service = "com.myapp.licenses"
static func save(_ value: String, forKey key: String) {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
var attrs = query
attrs[kSecValueData as String] = data
SecItemAdd(attrs as CFDictionary, nil)
}
static func load(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(forKey key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
// Replace the UserDefaults-backed property from section 5:
var savedLicenseKey: String? {
get { KeychainStore.load(forKey: "licenseKey") }
set {
if let newValue { KeychainStore.save(newValue, forKey: "licenseKey") }
else { KeychainStore.delete(forKey: "licenseKey") }
}
}
A full activation screen with progress state, error display, and gated content:
import SwiftUI
struct ContentView: View {
@StateObject private var vm = ActivationViewModel()
var body: some View {
Group {
if vm.isLicensed {
MainAppView()
} else {
ActivationView(vm: vm)
}
}
.task { await vm.restoreAndValidate() }
}
}
struct ActivationView: View {
@ObservedObject var vm: ActivationViewModel
@State private var input = ""
var body: some View {
VStack(spacing: 20) {
Image(systemName: "key.fill")
.font(.system(size: 48))
.foregroundColor(.yellow)
Text("Activate License")
.font(.title.bold())
Text("Enter the license key you received after purchase.")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("XXXX-XXXX-XXXX-XXXX", text: $input)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.padding(.horizontal)
if vm.isLoading {
ProgressView("Validating...")
}
if let error = vm.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.footnote)
}
Button {
Task { await vm.activate(input.trimmingCharacters(in: .whitespaces)) }
} label: {
Label("Activate", systemImage: "checkmark.seal.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(input.isEmpty || vm.isLoading)
.padding(.horizontal)
}
.padding()
}
}
struct MainAppView: View {
var body: some View {
Text("Welcome! Your license is active.")
}
}
@MainActor
final class ActivationViewModel: ObservableObject {
@Published var isLicensed = false
@Published var isLoading = false
@Published var errorMessage: String?
func restoreAndValidate() async {
guard let key = KeychainStore.load(forKey: "licenseKey") else { return }
isLoading = true
defer { isLoading = false }
do {
let result = try await LicenseManager.shared.validate(key)
isLicensed = result.success
} catch {
isLicensed = OfflineCache.wasRecentlyValid()
}
}
func activate(_ key: String) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let result = try await LicenseManager.shared.validate(key)
guard result.success else {
errorMessage = "That license key is invalid or expired."
return
}
try await LicenseManager.shared.activateDevice(
key, deviceId: UIDevice.current.deviceId
)
KeychainStore.save(key, forKey: "licenseKey")
isLicensed = true
} catch {
errorMessage = error.localizedDescription
}
}
}
Yes. UnifiedLicensing uses standard HTTPS networking with no private APIs, no embedded interpreters, and no dynamic code loading — all of which are fully compliant with App Store guidelines. License validation itself is a common and accepted pattern.
Yes. Create a test product in the vendor dashboard, generate a test license, and point your debug builds at it. Test keys are clearly separated from production usage in the dashboard so you can validate end-to-end flows without affecting real customers.
It stays constant while at least one app from your vendor is installed. If the user deletes all of your apps and reinstalls, a new UUID is generated — they would simply re-enter their license key, which is expected behavior.
Your app controls the grace period. With the 72-hour cache shown above, a permanently offline device stops working after three days unless it reconnects. Tune OfflineCache.gracePeriod to match your policy.
Seat limits are configured per product in the dashboard. When a device exceeds the limit, activation fails and you can prompt the user to deactivate another device via /deactivate-license.