Swan SDK
Guides

Identify users

Sign-in flows, anonymous-to-identified profile merge, attribute enrichment, and logout — across Android, iOS, React Native and Web.

Before reading this guide, skim Concepts → Identity (CDID) — it explains why the SDK identifies devices the way it does. This guide is the how.

You'll learn:

  1. The four states a device can be in
  2. When to call identify(...)
  3. How profile merge works on the backend
  4. How to enrich a profile after identify
  5. The right way to log out
  6. Listening for identifier changes (for analytics, navigation, debug)

1. The four states

StateTriggergetSwanIdentifier() returnsEvents ingest under
UninitializedBefore Swan.init(...)n/anothing — events queue locally
AnonymousAfter init, before any identifygeneratedCDID (UUID minted by backend on first device-register)swanIdentifier = generatedCDID
IdentifiedAfter Swan.identify(externalId, ...) succeeds with profileSwitched=truecurrentCDID (logged-in CDID)swanIdentifier = currentCDID; backend merges prior anonymous activity under the customer profile
Logged outAfter Swan.logout()a new generatedCDID (the SDK rotates)future events ingest anonymously under the new CDID

Why a new CDID at logout, not the original anonymous one? Shared-device handover. If two users sign in and out of the same phone, you don't want the second user's anonymous activity merging into the first user's profile.


2. Calling identify after sign-in

On React Native, identify(...) is available from @loyalytics/swan-react-native-sdk v2.8.0+ and is the recommended call on every cold start where the host app already has an active session — it asserts identity to the SDK without emitting a USER_LOGIN event. Use login(...) instead from your credential-submit handler.

import cx.swan.sdk.Swan

// Minimum form
Swan.identify(customerId = "user-12345")

// With profile attributes — captured into the merged profile on the backend
Swan.identify(
    customerId = "user-12345",
    attributes = mapOf(
        "firstName" to "Jane",
        "lastName"  to "Doe",
        "email"     to "jane@example.com",
        "tier"      to "gold",
        "joinedAt"  to "2024-08-12",
    ),
)

The customerId is your app's stable identifier for the user — typically the primary key from your auth backend, or the user's email / loyalty ID if those are stable.

identify is idempotent. If the SDK is already identified as customerId, the call short-circuits and skips the network round-trip. Calling identify from every cold start is safe (and recommended).

If you need a Promise/Future-like contract for the result, use the login(...) API (covered below) instead.

Swan.shared.identify(identifier: "user-12345", attributes: [
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
])
import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();
const result = await sdk?.identify('user-12345', {
    firstName: 'Jane',
    lastName: 'Doe',
    email: 'jane@example.com',
    tier: 'gold',
});
// result: { CDID: string, profileSwitched: boolean }

identify(...) is idempotent and does not emit a USER_LOGIN event — call it from your app's bootstrap path on every launch where your host app has an active stored session. Use login(...) instead from your credential-submit handler (it emits USER_LOGIN).

await Swan.identify('user-12345', {
    firstName: 'Jane',
    lastName: 'Doe',
    email: 'jane@example.com',
});

Validation

The SDK validates the identifier client-side — empty / non-string identifiers throw a programmer error. Network failures are logged but swallowed: the call will return cleanly so you can retry on the next user action without wrapping every call in try/catch.


3. login(...) — a richer variant

Use login when you want a typed result and explicit profile-switch handling — typically at the moment the user submits the sign-in form, not on every cold start.

import cx.swan.sdk.Swan
import cx.swan.sdk.LoginResult
import kotlinx.coroutines.launch

lifecycleScope.launch {
    val result: LoginResult = Swan.login(
        identifier = "user-12345",
        attributes = mapOf("email" to "jane@example.com"),
    )

    if (result.profileSwitched) {
        // Backend confirmed a server-side profile switch — the prior
        // anonymous activity has been merged into the customer profile
        navigateToHome()
    } else {
        // Same profile as before (repeat-login) — nothing to migrate
        navigateToHome()
    }
}

login(...) is a suspend function. It flushes the queue before sending the login event so anonymous events aren't attributed to the new identity post-switch — important for analytics correctness.

let result = await Swan.shared.login(
    identifier: "user-12345",
    attributes: ["email": "jane@example.com"]
)
if result.profileSwitched {
    // route to home, show "welcome back" if you like
}

login(...) is an async function returning LoginResult (cdid, profileSwitched). It never throws — failed identify calls return a LoginResult with cdid: nil so call sites stay clean.

import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();
const result = await sdk?.login('user-12345', {
    method: 'email',
    email: 'jane@example.com',
});
// result: { CDID: string, profileSwitched: boolean } | undefined

if (result?.profileSwitched) {
    // backend merged the prior anonymous activity into the customer profile
    navigation.navigate('Home');
}

login(...) flushes the event queue before sending the login event so prior anonymous events aren't attributed to the new identity post-switch — important for analytics correctness. Concurrent login / logout calls are serialized internally.

const result = await Swan.login('user-12345', {
    email: 'jane@example.com',
});

When to use identify vs login:

Call this...When...
identifyevery cold-start (after restoring an auth token), and any time the customerId is the same as last session
loginthe user just submitted the sign-in form — you need to wait for the merge to complete before navigating, or you care about profileSwitched

4. Enrich the profile later

identify carries attributes once. Use enrichProfile to push additional attributes on the user after identify — e.g. their address after they complete checkout, or their tier after a loyalty milestone.

Swan.enrichProfile(mapOf(
    "shippingAddress" to mapOf(
        "line1"   to "100 King St",
        "city"    to "Dubai",
        "country" to "AE",
    ),
    "loyaltyTier" to "platinum",
    "preferences" to mapOf(
        "newsletter" to true,
        "channel"    to "push",
    ),
))

Accepts a Map<String, Any?> or a kotlinx-serialization JsonObject for typed callers. Nested maps become JSON objects; lists become arrays; null values are preserved (useful to clear an attribute).

Swan.shared.enrichProfile([
    "shippingAddress": [
        "line1": "100 King St",
        "city": "Dubai",
        "country": "AE",
    ],
    "loyaltyTier": "platinum",
])
import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();
await sdk?.enrichProfile({
    shippingAddress: {
        line1: '100 King St',
        city: 'Dubai',
        country: 'AE',
    },
    loyaltyTier: 'platinum',
    preferences: {
        newsletter: true,
        channel: 'push',
    },
});

enrichProfile(...) returns Promise<void> — the SDK queues the enrichment locally and ships it with the next batch flush, so the returned promise resolves once the call is queued, not once the backend has acknowledged. Nested maps become JSON objects; arrays become JSON arrays. The CDID is read from local credentials — no need to pass it explicitly.

await Swan.enrichProfile({
    shippingAddress: { line1: '100 King St', city: 'Dubai', country: 'AE' },
    loyaltyTier: 'platinum',
});

enrichProfile is anonymous-safe: if the SDK isn't identified yet, the attributes attach to the anonymous CDID and are merged into the customer profile when identify runs.


5. Logging out

Swan.logout()

What happens:

  1. Flushes the queue so the last events ingest under the logged-in CDID
  2. Rotates the device to a new anonymous CDID (POST to /device/register)
  3. Clears currentCDID + identifier
  4. Resets in-memory super-properties (country, currency, businessUnit, currentScreenName)
  5. Fires addSwanIdentifierChangedListener with source = LOGOUT

Future events ingest anonymously under the new generatedCDID until the next identify.

Swan.shared.logout()
import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();
await sdk?.logout();

What happens, in order:

  1. Flushes the queue so the last events ingest under the logged-in CDID
  2. Sends a userLogout event to the server (best-effort — local state updates even if the network call fails)
  3. Clears currentCDID so future events ingest anonymously under generatedCDID
  4. Re-syncs the push subscription with the anonymous CDID so push campaigns retarget correctly
  5. Emits swanIdentifierChanged with the new anonymous identifier

Concurrent logout / login calls are serialized internally.

await Swan.logout();

6. Listen for identifier changes

Useful for: navigation reset on sign-out, refreshing analytics user properties, debugging profile switches in dev.

import cx.swan.sdk.SwanIdentifierChangedPayload

val unsubscribe = Swan.addSwanIdentifierChangedListener { payload ->
    when (payload.source) {
        SwanIdentifierChangedPayload.Source.IDENTIFY ->
            Log.d("auth", "switched to ${payload.swanIdentifier}")
        SwanIdentifierChangedPayload.Source.LOGOUT ->
            Log.d("auth", "anonymous now: ${payload.swanIdentifier}")
        SwanIdentifierChangedPayload.Source.PROFILE_SWITCH ->
            Log.d("auth", "profile switch")
    }
}

// Call when done — typically tied to your activity / scope lifecycle
unsubscribe()

Fires only when the identifier actually changes. A no-op identify (same customerId as the SDK is already on) does not fire the listener.

Payload structureSwanIdentifierChangedPayload:

FieldTypeDescription
swanIdentifierStringThe new Swan identifier — same string Swan.getSwanIdentifier() returns at emit time. After identify / login: the new currentCDID. After logout: the anonymous generatedCDID. Never null at emit time.
sourceSwanIdentifierChangedPayload.SourceWhat triggered the change. Enum values: IDENTIFY, LOGOUT, PROFILE_SWITCH.

Device-registered listener — fires once when device registration completes (fresh registration or cached-credentials warm path). Buffered single-shot — late subscribers receive the buffered payload synchronously on subscribe:

Swan.addDeviceRegisteredListener { event ->
    Log.d("auth", "deviceId=${event.deviceId} CDID=${event.generatedCDID}")
}

Payload (TelemetryEvent.DeviceRegistered):

FieldTypeDescription
deviceIdStringThe persisted device id minted by /device/register.
generatedCDIDStringThe anonymous CDID minted at device-register time.

addDeviceRegistrationFailedListener { event -> ... } receives TelemetryEvent.DeviceRegistrationFailed(error: Throwable). The SDK keeps queuing events locally and retries on the next network-online transition.

let unsubscribe = Swan.shared.addSwanIdentifierChangedListener { payload in
    switch payload.source {
    case .identify:      print("identified:", payload.swanIdentifier)
    case .logout:        print("logged out, anon:", payload.swanIdentifier)
    case .profileSwitch: print("profile switch")
    }
}
// later — call the closure returned by add* to unsubscribe:
unsubscribe()

Payload structureSwanIdentifierChangedPayload:

FieldTypeDescription
swanIdentifierStringThe new Swan identifier — same string Swan.shared.swanIdentifier returns at emit time. After identify / login: the new currentCDID. After logout: the anonymous generatedCDID. Never empty at emit time.
sourceSwanIdentifierChangedPayload.SourceWhat triggered the change. Enum cases: .identify, .logout, .profileSwitch.

Device-registered listener — fires once when device registration completes (fresh or cached-credentials warm path). Buffered single-shot — late subscribers receive the buffered payload synchronously on subscribe:

Swan.shared.addDeviceRegisteredListener { payload in
    print("deviceId=", payload.deviceId, "CDID=", payload.generatedCDID)
}

Payload (TelemetryEvent.DeviceRegisteredPayload):

FieldTypeDescription
deviceIdStringThe persisted device id minted by /device/register.
generatedCDIDStringThe anonymous CDID minted at device-register time.

addDeviceRegistrationFailedListener { payload in ... } receives TelemetryEvent.DeviceRegistrationFailedPayload(error: Error). The SDK keeps queuing events locally and retries on the next network-online transition.

import SwanSDK from '@loyalytics/swan-react-native-sdk';

const sdk = SwanSDK.getCurrentInstance();

const sub = sdk?.addListener('swanIdentifierChanged', (newCDID: string) => {
    console.log('new CDID:', newCDID);
});

// later:
sub?.remove();

The listener fires with the new identifier as a string, and only when the identifier actually changes (login → identified CDID, logout → anonymous CDID, identify → identified CDID on first switch). A no-op identify call with the same identifier the SDK already knows does not fire the listener.

Callback payloads — related lifecycle events on the same addListener API:

EventCallback parameterWhen it fires
'swanIdentifierChanged'cdid: string — the new Swan identifierThe identifier actually changes (after login / logout / first identify).
'initialized'{ success: boolean }SDK initialization completes.
'deviceRegistered'credentials: { deviceId, generatedCDID, currentCDID, identifier, appId } — the full credentials objectDevice registration with the backend succeeds.
'deviceRegistrationFailed'error: ErrorA device-registration attempt fails. The SDK keeps queuing events locally and retries on network recovery.
'pushNotificationsReady'{ success: boolean }Push notification subsystem finishes initialization.
'deviceInfoChanged'credentials: object — updated credentialsPersisted credentials change (e.g. CDID rotation, identifier update).
const off = Swan.addSwanIdentifierChangedListener((payload) => {
    console.log('identifier change:', payload);
    // payload: { source, previousCDID, newCDID }
});
// later: off();

Payload structureSwanIdentifierChangedPayload:

FieldTypeDescription
source'identify' | 'login' | 'logout'What triggered the change.
previousCDIDstring | nullThe CDID before the transition. null if the device hadn't registered yet (rare — listeners typically subscribe after init).
newCDIDstring | nullThe CDID after the transition.

Fires only when the identifier actually changes. No-op identify calls (same identifier as the SDK already holds) do not fire the listener.

Init + device-registration listeners:

const offInit = Swan.addInitializedListener(() => {
    console.log('SDK ready');
});

const offFailed = Swan.addDeviceRegistrationFailedListener((payload) => {
    console.warn('device register failed:', payload.reason, payload.message);
});

addInitializedListener receives no arguments — initialize() has resolved (fires immediately if init already completed).

addDeviceRegistrationFailedListener receives a DeviceRegistrationFailedPayload:

FieldTypeDescription
messagestringHuman-readable error message from the underlying failure.
reason'network' | 'timeout' | 'http' | 'shape' | 'unknown'Coarse classification so callers can react without regex-parsing the message.

A successful retry of device registration fires addInitializedListener instead.


7. Common pitfalls

SymptomCauseFix
Credential not found! Please wait for Swan to register the device!Calling identify before device-registration completedSubscribe to addInitializedListener / addDeviceRegisteredListener and call identify from inside it — OR retry on SwanIdentifierChangedListener
Anonymous events not merged into customer profileidentify was called before the events were flushed but the queue had errored outUse login(...) instead of identify for the sign-in moment — it flushes the queue before sending the login event
Profile attributes from sign-in form aren't on the customer profileAttributes passed to identify were not flat key/values — the SDK preserves the structure but the campaigns engine may not index nested valuesPass scalar attributes (string / number / boolean / null) for things you'll segment on; use enrichProfile later for richer nested data
logout() doesn't seem to reset analytics stateHost app cached the old CDID and didn't re-read on the listenerSubscribe to addSwanIdentifierChangedListener and re-fetch any cached state inside it

Reference

On this page