Account identity, guests, and logout

Use your existing, stable backend account ID as externalId, converted to a string. This includes a guest account if your backend has already assigned it an ID. Keep email as a user property; changing email must never create another identity.

Release availability: the completion APIs, identity observers, token renewal providers, native profile updates, and installation-scoped logout described below require 0.1.4 or later for Web, native iOS, Android, Flutter, and stable React Native, or 0.2.0-beta.2 or later for React Native/Expo on next. Native iOS 0.1.3 exposes identify and reset without completion callbacks; it does not provide identifyAsync or resetAsync. Check the installed SDK's release notes before using these examples. Do not wrap a synchronous call in an immediately resolved promise and treat that as server confirmation.

Upgrade existing integrations

Update the installed SDK and rebuild the host app before using the identity flow below. The backend requires ownership proof when linking an existing anonymous installation to an account. Updated SDKs send previousSubjectToken automatically; your app continues to supply only the backend-minted account token to identify.

An older SDK can receive HTTP 409 IDENTITY_PROOF_REQUIRED during that link. Upgrade the client to preserve its anonymous history; do not reset the installation to work around the error. API or dashboard deployment does not upgrade SDKs inside installed apps. See the upgrade guide for the release channels and account-switching checklist.

Identity model

MeaningUserGistExample
Current installation sessionanonymousIdGenerated on installation, replaced on reset
Stable application accountexternalIdString(onlinePianistUser.id)
Internal canonical profilesubjectIdManaged by UserGist; never use as your account ID
Guest status in your productCustom isAnonymous propertytrue for a PianoByte guest
Optional contact informationCustom email propertyAllowed by app privacy settings and consent

A backend-verified guest with an external ID is an identified UserGist subject, even when your custom isAnonymous property is true. A visitor who has not been identified remains an anonymous UserGist subject. Both appear in the dashboard. There is no automatic identity inference from email or other properties.

When a guest registers and their backend ID stays the same, keep the external ID and installation unchanged. Update isAnonymous to false and set or remove the email property. Call reset when the account changes or signs out.

Obtain a subject token on your backend

Your backend calls:

POST https://api.usergist.com/v1/apps/YOUR_APP_ID/sdk/subject-tokens
Authorization: Bearer rtk_YOUR_SERVER_KEY
Content-Type: application/json
 
{"externalId":"12345"}

The server key needs sdk:subjects scope and must belong to the app's workspace. Owners or admins create it under Workspace → Server keys. A client write key cannot mint identified credentials. Guest account IDs are supported when the backend verifies that the requesting session owns that ID. Derive the ID from that verified session, never from the request body. The ID must contain 1–256 characters; portal: is reserved.

The actual response has an envelope:

{
  "success": true,
  "data": {
    "subjectToken": "st_OPAQUE_SECRET",
    "subjectId": "USERGIST_SUBJECT_UUID",
    "expiresAt": "2026-12-14T12:00:00.000Z"
  }
}

This is an opaque credential, not a JWT. The default lifetime is 90 days, configurable on the UserGist server; always use the returned expiresAt. Return only the requesting user's token and expiry from your own authenticated endpoint with Cache-Control: no-store. Keep rtk_ keys exclusively on the backend.

// Inside your existing authenticated backend route:
const externalId = String(session.user.id) // includes verified guest sessions
const response = await fetch(
  `https://api.usergist.com/v1/apps/${process.env.USERGIST_APP_ID}/sdk/subject-tokens`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.USERGIST_SERVER_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ externalId }),
    signal: AbortSignal.timeout(10000),
  },
)
if (!response.ok) throw new Error('Identity service unavailable')
const { data } = await response.json()
// Return { externalId, subjectToken: data.subjectToken, expiresAt: data.expiresAt }
// with Cache-Control: no-store. Do not return your server key.

Identify and observe the result

Restore your application account first. Identify once the account is known, before recording account-specific product events. Keep the app UI independent of SDK network requests.

SDKIdentification resultObserve later changesToken renewal
iOSidentifyAsync(userId:properties:subjectToken:) or identify(..., completion:)setIdentityStateHandler, identityStatesetSubjectTokenProvider
AndroididentifyAsync(userId, subjectToken, properties) or identify(..., completion)setIdentityStateHandler, identityStatesetSubjectTokenProvider
FlutteridentifyAsync(id, properties:, subjectToken:)setIdentityStateHandler, identityStatesetSubjectTokenProvider
React Native / ExpoidentifyAsync(id, properties, token)setIdentityStateHandler, getIdentityState()setSubjectTokenProvider
Webidentify(id, properties, token)subscribe() and getSnapshot()getSubjectToken in init
  • synced: the server accepted this operation and the SDK installed the confirmed identity.
  • queued: the operation is stored for retry; this is not server confirmation. Observe the identity state for eventual identification. A queued profile update does not cause an identity change.
  • rejected: invalid input, permanent server rejection, or a changed/reset session. Correct the cause before retrying. Web validation and network activation failures can also reject the promise; handle errors.

Native identity states include anonymous, identifying, identified, authentication required, resetting, reset failed, and rejected. Swift uses enum cases such as .authenticationRequired; other mobile SDKs use authentication-required. Web exposes active-anonymous, active-identified, and authentication-required in its existing snapshot.

Expiry and refresh

On account restoration, fetch a valid token from your backend and identify the same external ID. Install the renewal provider once. When the active credential receives a 401, mobile SDKs retain the account and pending work, notify the identity observer, and request a new token through the provider. Concurrent renewal is coalesced; failures back off and providers time out. A provider must use the host's authenticated session and must never return a token for another account.

The SDK exchanges backend proof for a credential bound to the installation without extending the original expiry. Logging out that installation does not revoke another device's credential. Never implement refresh by generating an external ID from email or falling back to an anonymous identity for an expired identified account.

// Native iOS 0.1.4 or later. Configure once.
UserGist.shared.setSubjectTokenProvider { externalId, complete in
    Task {
        do {
            let result = try await appBackend.userGistToken()
            guard result.externalId == externalId else {
                complete(.failure(URLError(.userAuthenticationRequired)))
                return
            }
            complete(.success(result.subjectToken))
        } catch { complete(.failure(error)) }
    }
}
UserGist.shared.setIdentityStateHandler { state in
    // .identified confirms a previously queued identify.
    // .authenticationRequired means the host session may need attention.
}
let result = await UserGist.shared.identifyAsync(
    userId: String(account.id),
    properties: ["isAnonymous": account.isGuest],
    subjectToken: tokenFromYourBackend
)

If an initial identify is rejected because its supplied token is invalid, fetch a replacement and call identify for the same ID again. Automatic active-session renewal does not substitute for authenticating the first identify call.

Update or remove properties

Profile mutations use the current SDK session. You do not need another identify call or a fresh token for every property update. Updates support strings, finite numbers, booleans, and null: up to 64 keys per operation, keys up to 120 characters, strings up to 8192 characters. unset removes a key; setting it to null is a stored null value.

SDKSet guest status and remove email
iOSawait UserGist.shared.setUserPropertiesAsync(["isAnonymous": false], unset: ["email"])
AndroidUserGist.setUserProperties(mapOf("isAnonymous" to false), unset = listOf("email")) in a coroutine
Flutterawait UserGist.setUserProperties({'isAnonymous': false}, unset: ['email'])
React Native / Expoawait UserGist.setUserProperties({ isAnonymous: false }, ['email'])
Webawait UserGist.setUserProperties({ isAnonymous: false }, ['email'])

Analytics consent is required. Email and other sensitive profile keys must be explicitly allowed in the app's privacy settings. The server reports filtered keys; filtering does not prevent identification. Profile email does not become an automatically collected event property. Removing an old value is supported even if it is no longer on the allowlist.

For a trusted backend update, use PATCH /v1/apps/:appId/users/properties with a server key scoped users.properties.write:

{
  "subject": { "externalId": "12345" },
  "update": {
    "mutationId": "12345678-1234-4234-8234-123456789abc",
    "set": { "isAnonymous": false },
    "unset": ["email"]
  }
}

Generate a new mutation UUID for a new update and reuse it when retrying that exact update.

Logout, account switching, and push

Stop producing the old account's SDK calls. Await successful local reset, then identify the next account and reapply the host's consent decisions. Swift, Android, and Flutter provide resetAsync() returning a boolean; false means local cleanup failed and the SDK remains paused. React Native's reset() promise rejects on cleanup failure. Web's reset() clears the tab's participation and pending work; it broadcasts logout to that user's other active tabs.

Reset rotates the installation alias, clears account properties, consent, queues, and SDK UI, and cancels old requests. Remote logout is kept in a separate cleanup queue, so temporary network failure does not block local sign-out. Native cleanup persists securely across app restarts. Web cleanup uses session storage for the tab; it cannot guarantee delivery after the tab is closed. Remote push invalidation requires connectivity; an offline device cannot immediately retract a notification already sent by a provider.

Mobile SDKs retain the OS device token separately from the user association. They report a registered subscription only after the server acknowledges it. A skipped registration (for example, permission/consent not ready) remains unregistered and is retried. Registration for the next account waits for old-session cleanup. Observe setPushSubscriptionStateHandler to distinguish token availability, SDK opt-in, and confirmed registration; OS notification permission is a separate check.

What gets linked automatically?

A successful identify proves ownership of the current anonymous session, links its installation history to the canonical external account, and binds its existing active push token on the server. Existing canonical profile values take precedence over anonymous defaults; explicit properties supplied in identify or a later property update can change them. A guest upgrade using the same external ID keeps the same profile and installation.

Events keep the identity captured when they were recorded. Anonymous events do not acquire fabricated external IDs; the alias relationship connects their history. UserGist emits UserGist User Identified with both IDs for configured analytics integrations. Amplitude uses device_id/user_id; Mixpanel uses $device_id/$user_id. Apply another SDK's user ID after confirmed identification, and reset that SDK using its own logout contract.