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:
- The four states a device can be in
- When to call
identify(...) - How profile merge works on the backend
- How to enrich a profile after
identify - The right way to log out
- Listening for identifier changes (for analytics, navigation, debug)
1. The four states
| State | Trigger | getSwanIdentifier() returns | Events ingest under |
|---|---|---|---|
| Uninitialized | Before Swan.init(...) | n/a | nothing — events queue locally |
| Anonymous | After init, before any identify | generatedCDID (UUID minted by backend on first device-register) | swanIdentifier = generatedCDID |
| Identified | After Swan.identify(externalId, ...) succeeds with profileSwitched=true | currentCDID (logged-in CDID) | swanIdentifier = currentCDID; backend merges prior anonymous activity under the customer profile |
| Logged out | After 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... |
|---|---|
identify | every cold-start (after restoring an auth token), and any time the customerId is the same as last session |
login | the 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:
- Flushes the queue so the last events ingest under the logged-in CDID
- Rotates the device to a new anonymous CDID (POST to
/device/register) - Clears
currentCDID+identifier - Resets in-memory super-properties (country, currency, businessUnit, currentScreenName)
- Fires
addSwanIdentifierChangedListenerwithsource = 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:
- Flushes the queue so the last events ingest under the logged-in CDID
- Sends a
userLogoutevent to the server (best-effort — local state updates even if the network call fails) - Clears
currentCDIDso future events ingest anonymously undergeneratedCDID - Re-syncs the push subscription with the anonymous CDID so push campaigns retarget correctly
- Emits
swanIdentifierChangedwith 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 structure — SwanIdentifierChangedPayload:
| Field | Type | Description |
|---|---|---|
swanIdentifier | String | The 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. |
source | SwanIdentifierChangedPayload.Source | What 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):
| Field | Type | Description |
|---|---|---|
deviceId | String | The persisted device id minted by /device/register. |
generatedCDID | String | The 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 structure — SwanIdentifierChangedPayload:
| Field | Type | Description |
|---|---|---|
swanIdentifier | String | The 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. |
source | SwanIdentifierChangedPayload.Source | What 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):
| Field | Type | Description |
|---|---|---|
deviceId | String | The persisted device id minted by /device/register. |
generatedCDID | String | The 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:
| Event | Callback parameter | When it fires |
|---|---|---|
'swanIdentifierChanged' | cdid: string — the new Swan identifier | The identifier actually changes (after login / logout / first identify). |
'initialized' | { success: boolean } | SDK initialization completes. |
'deviceRegistered' | credentials: { deviceId, generatedCDID, currentCDID, identifier, appId } — the full credentials object | Device registration with the backend succeeds. |
'deviceRegistrationFailed' | error: Error | A 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 credentials | Persisted 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 structure — SwanIdentifierChangedPayload:
| Field | Type | Description |
|---|---|---|
source | 'identify' | 'login' | 'logout' | What triggered the change. |
previousCDID | string | null | The CDID before the transition. null if the device hadn't registered yet (rare — listeners typically subscribe after init). |
newCDID | string | null | The 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:
| Field | Type | Description |
|---|---|---|
message | string | Human-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
| Symptom | Cause | Fix |
|---|---|---|
Credential not found! Please wait for Swan to register the device! | Calling identify before device-registration completed | Subscribe to addInitializedListener / addDeviceRegisteredListener and call identify from inside it — OR retry on SwanIdentifierChangedListener |
| Anonymous events not merged into customer profile | identify was called before the events were flushed but the queue had errored out | Use 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 profile | Attributes passed to identify were not flat key/values — the SDK preserves the structure but the campaigns engine may not index nested values | Pass 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 state | Host app cached the old CDID and didn't re-read on the listener | Subscribe to addSwanIdentifierChangedListener and re-fetch any cached state inside it |
Reference
- Concepts → Identity (CDID) — the model behind these APIs
- Push notifications — tying push to the identified CDID
- Android:
Swan.identify,login,logout,enrichProfile,addSwanIdentifierChangedListener