Skip to content

Tokens & errors

The SDK manages a per-install update token for you, and surfaces every failure as a typed SDKError. You rarely need to touch enrollment directly, but understanding the lifecycle helps you handle errors well.

Enrollment and the token lifecycle

The first time the SDK talks to the backend, it enrolls the install: it exchanges your app secret for a scoped, revocable update token. That token is persisted through a TokenStore (the Keychain by default on Apple platforms) and survives app launches.

Every backend-facing method auto-enrolls if there is no stored token, so you normally never call enroll() yourself:

  • checkForUpdate(...)
  • appMetadata()
  • reportInstall(...)
  • downloadURL(forRelease:)
  • downloadRelease(_:)
  • iconData(for:)

If the stored token is rejected (a uniform 401), the SDK transparently re-enrolls once and retries the call. From your code's point of view the call just succeeds. You can trigger these steps explicitly if you want to:

@discardableResult public func enroll() async throws -> SDKInstallToken
@discardableResult public func refresh() async throws -> SDKInstallToken

enroll() forces an enrollment; refresh() refreshes the current token. Both return the resulting SDKInstallToken.

The token store

public protocol TokenStore: Sendable {
    func load() throws -> SDKInstallToken?
    func save(_ token: SDKInstallToken) throws
    func clear() throws
}

By default the SDK stores the token in the Keychain, marked to persist after first unlock on that device only. It is not synced to iCloud and not included in backups. On platforms without a Keychain the default is an in-memory store. You can pass your own TokenStore to AppGantryUpdates(configuration:session:tokenStore:) if you need custom storage.

Anti-enumeration: the uniform 401

The /sdk/v1 surface returns a uniform 401 whenever it cannot accept a credential: an unknown app key, a missing or malformed Authorization header, a token that has expired or been revoked, and a token that belongs to a different app all answer the same thing. The cases are deliberately indistinguishable, so an attacker cannot probe which app keys exist. The SDK does not try to tell them apart either: on a 401 it simply re-enrolls and retries once. If enrollment itself is unauthorized, the call surfaces the error to you. See Every failure is the same 401.

Errors: SDKError

Every failure is an SDKError. It is Sendable and Equatable, and it conforms to LocalizedError with a user-safe errorDescription.

public enum SDKError: Error, Sendable, Equatable {
    case notConfigured
    case notEnrolled
    case api(status: Int, body: APIErrorBody)
    case unexpectedStatus(Int)
    case decoding(String)
    case invalidURL(String)
    case transport(String)

    public var isUnauthorized: Bool // true when the api status is 401
}

Every /sdk/v1 failure body has the same shape, carried in the .api(status:body:) case:

public struct APIErrorBody: Codable {
    let error: String
    let message: String
}

Handle it like any typed Swift error:

do {
    let info = try await updates.checkForUpdate()
    // ...
} catch let error as SDKError {
    switch error {
    case .api(let status, let body):
        print("server said \(status): \(body.message)")
    case .transport(let detail):
        print("network problem: \(detail)")
    default:
        print(error.localizedDescription)
    }
}

isUnauthorized is a convenience for case .api(status: 401, _). In normal use you should rarely see a 401 reach you, because the SDK re-enrolls transparently; a 401 that survives that means enrollment itself was rejected (for example the app secret was revoked).

Rate limiting: honour 429 and back off

The edge WAF may throttle bursts of requests with an HTTP 429. The shipped client does not implement a retry loop for 429: it surfaces it as SDKError.api(status: 429, ...). When you see a 429, back off before retrying (respect any Retry-After guidance and avoid tight retry loops). Do not hammer checkForUpdate() on a timer.

Troubleshooting

The prompt below assumes the client constructed cleanly. When it didn't, work through these before reading further:

Symptom Cause Fix
SDKError.notConfigured The configuration is missing an app key or app secret Check the values against the channel's SDK app. See Configuration
Every call fails with a 401 that survives a retry The app secret was revoked, or the SDK app was deleted Create a new SDK app on the channel and ship the new key and secret. See Configuration
checkForUpdate() reports no update when a newer build exists The build hasn't been published into the channel this SDK app belongs to — uploading a build is not releasing it Publish the build into that channel as a release
beginInstall(for:) returns false The release has no iOS over-the-air manifest Handle it: fall back to a direct download
The install starts and the icon greys out Ad-hoc signing: the device isn't in the provisioning profile Enroll the device, re-sign, re-upload
SDKError.transport on a real device but not the simulator Network policy, a VPN, or App Transport Security Confirm the device can reach api.appgantry.com over HTTPS
A 429 reaches your code The SDK deliberately doesn't retry throttled calls Back off — see Rate limiting and Rate limits
The token seems to reset on every launch The default Keychain store isn't usable on that platform, so it falls back to memory Pass your own TokenStore. See The token store

Next

  • Configuration: the app secret that enrollment exchanges.
  • Privacy: what the SDK sends and where the token lives.
  • SDK HTTP API: the same lifecycle at the wire level, for a client that isn't the Swift package.
  • API reference: the /sdk/v1 endpoints behind these calls.