@despia/user-identity
v1.0.5
Published
Resolves and persists app_user_id for Despia apps. You plug the value into RevenueCat, OneSignal, your backend, etc.
Maintainers
Readme
@despia/user-identity
What this package is
@despia/user-identity is the standard way to get and persist a single, stable user ID (app_user_id) in Despia native apps (iOS and Android). It does only the following:
- Resolves who the user is: vault first (device’s stored identity — iCloud/Android backup). If nothing in the vault, then restore from purchase history (RevenueCat), then fall back to a new install ID.
- Persists that ID in the device vault so it survives app restarts and reinstalls (when backup is used).
- Returns that ID to you so you can pass it to RevenueCat, OneSignal, your backend, and any other service.
This package does not register users with your backend, sync to OneSignal, or call RevenueCat. It only gives you the app_user_id; you send it to your backend and other services yourself. It does not require users to sign in; it works without accounts or passwords.
What are user sessions in Despia?
Every Despia app needs one consistent user identifier (app_user_id) that:
- Stays the same across app restarts, reinstalls, and device changes
- Links all your services together (purchases, push notifications, backend, etc.)
- Works without requiring users to sign in - no password, no account, no login screen
This package gives you that identifier. It’s the standard way Despia apps manage user identity.
Why this matters
Without a stable identity, users get “lost” when they reinstall. They lose subscriptions, trials, credits, or preferences. You can’t recognize them or enforce your rules.
This package solves that by using:
- iCloud Key-Value store (iOS) and Android backup - identity survives reinstall when the user backs up
- Restore purchases - if they bought something before, identity is recovered from purchase history
- Install ID - fallback for brand-new users
You get a stable app_user_id without forcing users to create an account. They keep access to what they paid for.
It also helps prevent fraud. The same identity lets you detect when someone uninstalls and creates a new account to claim another free trial or bonus credits. You can enforce limits and keep paying users correctly linked.
Install
Install the package and the Despia native SDK (required):
# npm
npm install @despia/user-identity despia-native
# pnpm
pnpm add @despia/user-identity despia-native
# yarn
yarn add @despia/user-identity despia-nativeYou must have despia-native in your app; this package uses it and does not bundle it (no duplicate React or Despia instance).
Detailed step-by-step instructions
Follow these steps in order. Do not show paywalls, push registration, or any user-specific UI until identity is ready.
Step 1: Resolve identity at app entry
Where: The first place your app runs (e.g. root layout, App.tsx, or main entry before any route that needs the user).
What to do:
Import the package and
despia-native:import despia from 'despia-native'; import { userIdentity, isDespia, getPlatform } from '@despia/user-identity';Call
userIdentity()once and await it. This reads the vault and/or purchase history and returns the user ID (or creates a new one).const result = await userIdentity();Check the result:
- If
result === null: the app is running on web (not in Despia). Use your own web identity (session, cookies, etc.) and skip the steps below for native. - If
resultis an object: you are in the Despia native app. Continue.
- If
From
resultyou get:appUserId,installId,source,aliases. UseappUserIdeverywhere you need a user identifier (RevenueCat, OneSignal, backend). Thesourcetells you where the ID came from:'vault'(restored from backup),'restore'(from purchase history), or'new'(first install).
Step 2: Send the ID to your backend (do not block the UI)
Important: Do not await the backend call. If you await it, a slow or cold-starting backend can block your app for minutes. Fire the request and forget.
Call your register endpoint with the identity payload. Example:
if (result) { const { appUserId, installId, source } = result; const platform = getPlatform(); // 'ios' | 'android' | null fetch('https://your-api.com/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId, deviceId: installId, source, platform: platform ?? 'unknown', timestamp: new Date().toISOString(), }), }).catch(() => {}); // fire-and-forget; do not await }Your backend should upsert a user by
app_user_idand link the device. The user can use the app immediately; the backend just needs to receive the data eventually.
Step 3: Sync the ID to RevenueCat and OneSignal
Use the same appUserId so purchases and push are tied to one user.
RevenueCat: Whenever you open a paywall or set the user in RevenueCat, pass
appUserIdas theexternal_id. Example:despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=default`);If you do not pass this
external_id, restore-from-purchases and identity recovery will not work correctly.OneSignal: Set the external user ID so push is linked to this user. Example (also fire-and-forget; do not block):
despia(`setonesignalplayerid://?user_id=${appUserId}`);
Step 4: Add a "Restore purchases" button
App Store and Play Store require a way to restore purchases. Add a button (e.g. on Settings or the paywall) that:
Calls
restore()from this package:import { restore } from '@despia/user-identity'; const restored = await restore();If
restoredis not null, you getrestored.appUserId. Sync it to OneSignal and your backend (again, fire-and-forget). Ifrestoredis null, no purchase with anexternalUserIdwas found; show a message like "No purchases found."
Step 5: If you have login, wire it to identity
When the user signs in, your backend returns the canonical app_user_id (CLAIM or RECOVER). Persist it with this package so future launches use it:
After a successful login response that includes
appUserId:import { setAppUserId } from '@despia/user-identity'; await setAppUserId(appUserIdFromBackend);Then sync that ID to OneSignal and your backend as in Steps 2 and 3.
Usage (full example)
import despia from 'despia-native';
import { userIdentity, restore, isDespia, getPlatform } from '@despia/user-identity';
// At app entry - before any paywall, push, or user-specific feature
const result = await userIdentity();
if (result) {
const { appUserId, installId, source, aliases } = result;
// Sync to services (fire-and-forget; do not await)
despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=default`);
despia(`setonesignalplayerid://?user_id=${appUserId}`);
fetch('/api/user/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appUserId,
deviceId: installId,
source,
platform: getPlatform() ?? 'unknown',
timestamp: new Date().toISOString(),
}),
}).catch(() => {});
} else {
// Web: use your own identity (session, etc.)
}
// Restore purchases button (e.g. in Settings)
async function onRestorePurchases() {
const restored = await restore();
if (restored) {
const { appUserId } = restored;
despia(`setonesignalplayerid://?user_id=${appUserId}`);
fetch('/api/user/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appUserId, source: 'restore_button', timestamp: new Date().toISOString() }),
}).catch(() => {});
}
}Do not block app load on backend or OneSignal. Call userIdentity() and then fire registration and OneSignal in the background without awaiting them.
When each source is used
| Source | When it happens |
|----------|------------------|
| vault | User had this device before and iCloud/Android backup restored the stored ID |
| restore| Vault was empty but purchase history had an externalUserId (from RevenueCat) - we recovered it |
| new | First install, no backup, no purchases - we use the device install ID |
User ID flows (enterprise reference)
This section documents all identity flows end-to-end for integration, audits, and support.
Flow quick reference
| Flow | Trigger | Source | Outcome |
|------|---------|--------|---------|
| 1 | Every app launch | - | Resolution: vault → restore → new |
| 2 | First install | new | Install ID used, persisted to vault |
| 3 | Reinstall + backup restored | vault | Same ID from iCloud/Android |
| 4 | Reinstall, no backup | restore | Recovered from purchase history |
| 5 | New device | restore or new | Purchase sync or install ID |
| 6 | Login, account new | CLAIM | Device ID linked to account |
| 7 | Login, account exists | RECOVER | Device switches to account ID |
| 8 | Restore purchases button | - | Recovered from store, vault updated |
| 9 | Backend merge | - | linkAlias() for fraud/audit |
Flow 1: Identity resolution (every app launch)
Vault first (highest priority). We only restore from purchases or use install ID when the vault is empty. userIdentity() runs this logic in order:
flowchart TD
A[Read Storage Vault<br/>iCloud / Android backup] --> B{Found?}
B -->|Yes| C[Use it. source = vault]
B -->|No| D[Query purchase history<br/>RevenueCat / App Store / Play Store]
D --> E{Any purchase with<br/>externalUserId?}
E -->|Yes| F[Use it. source = restore<br/>Lock identity to vault]
E -->|No| G[Fallback to install ID<br/>source = new]
C --> H[Persist to vault]
F --> H
G --> H
H --> I[Return appUserId, installId, source, aliases]Critical: Always pass appUserId as external_id when launching RevenueCat paywalls. Otherwise step 2 will never find a recoverable ID.
Flow 2: First-time user (no backup, no purchases)
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | App launches, vault empty | - | - |
| 2 | userIdentity() → vault empty, no purchases | - | - |
| 3 | Uses install ID, source: 'new' | - | - |
| 4 | Persists install ID to vault | - | app_user_id = install ID |
| 5 | POST /api/user/register | Upsert user, link device | - |
| 6 | Sync to RevenueCat, OneSignal | - | - |
Result: appUserId = install ID. Same user on same device will keep this ID across restarts.
Flow 3: Returning user - reinstall with iCloud/Android backup
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | User reinstalls app | - | - |
| 2 | iCloud/Android restores vault before app runs | - | app_user_id = previous ID |
| 3 | userIdentity() reads vault | - | - |
| 4 | Returns source: 'vault' | - | - |
| 5 | POST /api/user/register | Recognizes existing user | - |
Result: Same appUserId as before reinstall. Subscriptions, credits, and preferences preserved.
Flow 4: Returning user - reinstall without backup (restore from purchases)
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | User reinstalls, backup not yet restored | - | Empty |
| 2 | userIdentity() → vault empty | - | - |
| 3 | Queries purchase history | - | - |
| 4 | Finds purchase with externalUserId (from previous paywall launch) | - | - |
| 5 | Uses that ID, source: 'restore', locks to vault | - | app_user_id = recovered ID |
| 6 | POST /api/user/register | Recognizes existing user | - |
Result: Same appUserId as when they originally purchased. Requires purchases were made with external_id = app_user_id.
Flow 5: New device, same Apple/Google account
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | User gets new phone, installs app | - | Empty (new device) |
| 2 | Purchase history may sync from App Store / Play Store | - | - |
| 3 | If purchase history has externalUserId → source: 'restore' | - | Recovered ID written |
| 4 | If not yet synced → source: 'new', install ID used | - | Install ID written |
| 5 | User taps "Restore purchases" later | - | - |
| 6 | restore() finds purchase with externalUserId | - | Vault updated |
| 7 | Sync to backend, OneSignal, RevenueCat | - | - |
Result: Identity recovered when store syncs purchase history, or when user restores purchases.
Flow 6: Login - CLAIM (account has no app_user_id)
User signs in for the first time. Their account has no linked app_user_id.
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | userIdentity() returns appUserId (e.g. install ID) | - | - |
| 2 | User logs in | - | - |
| 3 | POST /api/user/login { accountId, currentAppUserId } | - | - |
| 4 | Backend: account has no app_user_id | - | - |
| 5 | Backend: CLAIM - save currentAppUserId to account | - | - |
| 6 | Returns { appUserId: currentAppUserId, action: 'claimed' } | - | - |
| 7 | Client calls setAppUserId(appUserId) | - | Same ID (already in vault) |
| 8 | Sync to RevenueCat, OneSignal | - | - |
Result: Anonymous device identity is now linked to the account. Future logins from this device use this ID.
Flow 7: Login - RECOVER (account already has app_user_id)
User signs in on a new device. Their account already has an app_user_id from another device.
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | New device: userIdentity() returns appUserId = install ID | - | - |
| 2 | User logs in | - | - |
| 3 | POST /api/user/login { accountId, currentAppUserId } | - | - |
| 4 | Backend: account has app_user_id = "user-abc" | - | - |
| 5 | Backend: RECOVER - return existing ID | - | - |
| 6 | Returns { appUserId: "user-abc", action: 'recovered' } | - | - |
| 7 | Client calls setAppUserId("user-abc") | - | Vault overwritten with "user-abc" |
| 8 | Sync to RevenueCat, OneSignal | - | - |
Result: Device now uses the account’s canonical ID. Purchases and entitlements from other devices apply here.
Flow 8: Restore purchases button
Required by App Store and Play Store. User taps "Restore purchases" (e.g. in Settings).
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | User taps "Restore purchases" | - | - |
| 2 | restore() queries purchase history | - | - |
| 3 | Finds purchase with externalUserId | - | - |
| 4 | Writes ID to vault, locks identity | - | app_user_id updated |
| 5 | Returns { appUserId, aliases } | - | - |
| 6 | Client syncs appUserId to RevenueCat, OneSignal, POST /api/user/register | - | - |
Result: Identity recovered from store. Use source: 'restore_button' when registering so backend can attribute the source.
Flow 9: Aliases and fraud detection
When backend detects the same person under multiple IDs (e.g. same device, same receipt):
| Step | Client | Backend | Vault |
|------|--------|---------|-------|
| 1 | Backend merges IDs, returns instruction to link | - | - |
| 2 | Client calls linkAlias(oldInstallId) | - | Aliases array updated |
| 3 | Next userIdentity() returns { appUserId, aliases: [oldInstallId] } | - | - |
| 4 | Send aliases to backend for fraud checks | - | - |
Result: Main ID stays canonical. Aliases support auditing and fraud prevention.
Backend decision logic: CLAIM vs RECOVER
flowchart TD
A[POST /api/user/login] --> B[Look up account by accountId]
B --> C{Does account have<br/>app_user_id?}
C -->|No| D[CLAIM]
D --> E[Save currentAppUserId to account]
E --> F[Return appUserId: currentAppUserId<br/>action: claimed]
C -->|Yes| G[RECOVER]
G --> H[Return appUserId: account.app_user_id<br/>action: recovered]Service sync order (recommended)
After userIdentity() or restore() or setAppUserId():
- RevenueCat -
despia('revenuecat://launchPaywall?external_id=' + appUserId)(or set external ID via SDK) - OneSignal -
despia('setonesignalplayerid://?user_id=' + appUserId) - Backend -
POST /api/user/registerwithappUserId,deviceId,source,platform,timestamp
Backend integration
Your backend stores app_user_id as the main user identifier. It receives it in two flows:
1. Register (on every app launch)
On each launch, the app sends the resolved app_user_id to your backend so you can track devices and know who’s active:
// Client (after userIdentity())
await fetch('/api/user/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appUserId,
deviceId: installId,
source, // 'vault' | 'restore' | 'new'
platform: 'ios' | 'android',
timestamp: new Date().toISOString()
})
});Your backend upserts a user and links the device. Use this for analytics, last-seen, and knowing which devices belong to whom.
2. Login (CLAIM vs RECOVER)
When a user signs in, the backend must decide whether to CLAIM or RECOVER:
| Action | When | What the backend does |
|--------|------|------------------------|
| CLAIM | Account has no app_user_id yet | Link the current app_user_id from the device to this account |
| RECOVER | Account already has an app_user_id | Return that existing ID so the device switches to it |
The client sends currentAppUserId (from userIdentity()). The backend replies with the canonical app_user_id to use. The client then calls setAppUserId(returnedId) so the vault and future resolution use it:
// Client
const { appUserId, action } = await fetch('/api/user/login', {
method: 'POST',
body: JSON.stringify({
accountId: 'user-123',
currentAppUserId: result.appUserId,
credentials: { email, password }
})
}).then(r => r.json());
await setAppUserId(appUserId);
// action is 'claimed' or 'recovered'The backend should store app_user_id as the primary key or foreign key for that user. RevenueCat, OneSignal, webhooks, and any other service should map back to this ID.
Minimal database schema
-- Users: app_user_id is the main identifier
CREATE TABLE users (
app_user_id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_seen TIMESTAMPTZ DEFAULT NOW()
);
-- Devices: link device_id to app_user_id
CREATE TABLE user_devices (
device_id TEXT PRIMARY KEY,
app_user_id TEXT REFERENCES users(app_user_id),
platform TEXT, -- 'ios' | 'android'
last_seen TIMESTAMPTZ DEFAULT NOW()
);
-- Accounts (if you have login): link account to app_user_id
CREATE TABLE accounts (
id UUID PRIMARY KEY,
app_user_id TEXT REFERENCES users(app_user_id),
email TEXT,
...
);Register endpoint: upsert into users and user_devices on each POST. Login endpoint: look up account, return app_user_id (CLAIM or RECOVER).
3. RevenueCat webhook (grant access on purchase)
This package does not call RevenueCat; it only gives you app_user_id. To grant access when a user pays, use a RevenueCat webhook on your backend.
- In RevenueCat, set the webhook URL to your backend (e.g.
https://your-api.com/webhooks/revenuecat). - RevenueCat sends events (e.g.
INITIAL_PURCHASE,RENEWAL) withapp_user_id(RevenueCat calls itapp_user_idor the external ID you set). - Your backend finds the user by
app_user_idand updates subscription status (e.g. setsubscription_status = 'active'). - The app can then poll your backend or use your own logic to show premium content. Do not grant access only from the client; the webhook is the source of truth.
Relevant for this package: the webhook payload’s user identifier is the same app_user_id you get from userIdentity() and pass as external_id to RevenueCat. Use it to match events to your users table. See RevenueCat webhooks for payload shape and setup.
Main ID + aliases
You get a single main appUserId and an optional list of aliases (other IDs linked to the same user). Use the main ID for RevenueCat, OneSignal, backend, etc. Use aliases for fraud checks or when your backend merges users:
const result = await userIdentity();
// result.appUserId = main/canonical ID
// result.aliases = ['old_install_id', 'merged_user_id']
// When backend says "merge ID X into this user":
await linkAlias('old_install_id');Web vs native
- Native Despia app (iOS/Android):
await userIdentity()returns{ appUserId, installId, source, aliases }. Use it. - Web (browser):
await userIdentity()returnsnull. The package does nothing. Use your own web identity (session, cookies, etc.). - Check before use: Always check
if (result)orif (isDespia())before syncing to RevenueCat, OneSignal, or backend.
Platform detection: Despia sets the user agent so you can detect native runtime and platform. Official guide: User Agent (no package required; use navigator.userAgent.toLowerCase().includes('despia') and check for iphone/ipad/android). When you use this package, use its helpers instead of reimplementing:
import { isDespia, getPlatform } from '@despia/user-identity';
const inDespia = isDespia(); // same as userAgent.includes('despia')
const platform = getPlatform(); // 'ios' | 'android' | null (iphone/ipad vs android)
const isDespiaIOS = inDespia && platform === 'ios';
const isDespiaAndroid = inDespia && platform === 'android';
// e.g. show RevenueCat in Despia, Stripe on webSame behavior as the official doc: isDespia() checks for "despia"; getPlatform() returns 'ios' (iphone/ipad) or 'android'.
Common scenarios
| Scenario | What happens |
|----------|--------------|
| First install, no backup | source: 'new', appUserId = install ID |
| Reinstall, iCloud/Android backup restored | source: 'vault', appUserId from backup |
| Reinstall, no backup, but had purchases | source: 'restore', appUserId from purchase history |
| New device, same Apple/Google account, had purchases | source: 'restore' if purchase history syncs, else source: 'new' |
| User taps "Restore purchases" | restore() returns recovered appUserId if any purchase had externalUserId |
Important: For restore to work, you must pass app_user_id as external_id when launching RevenueCat paywalls. Otherwise purchases aren’t linked and recovery won’t find them.
Troubleshooting
| Issue | Likely cause |
|-------|---------------|
| getPlatform is undefined / not in package | Use @despia/[email protected] or later. Import: import { getPlatform } from '@despia/user-identity'. Do not use a custom detectPlatform helper. |
| Calls seem to hang / no callback in native | This package uses Despia’s promises directly—no extra timeout. Data is processed as soon as the native layer resolves (vault has a 30s timeout on the native side). Vault and install-ID fetch run in parallel. Install ID uses window.uuid first (set by native before any package loads), then despia.uuid, then the async get-uuid call only if needed. |
| Getting $RCAnonymous instead of real ID | Recovery from purchase history prefers non–RevenueCat-anonymous externalUserId (ignores $RCAnonymous when a real ID exists). Ensure paywalls are launched with your app_user_id as external_id so RevenueCat stores it. |
| userIdentity() returns null | Running in browser, not Despia native app. Use isDespia() to check. |
| restore() returns null | No purchases, or purchases weren’t made with external_id (RevenueCat). Always pass app_user_id as external_id when launching paywalls. |
| User “lost” after reinstall | iCloud/Android backup may not have synced yet, or backup was disabled. Restore purchases can still recover if they had a purchase with externalUserId. |
API & response shapes
userIdentity() (alias: getAppUserId())
Resolves identity and persists to vault. Call once at app launch.
Returns: IdentityResult | null
| Field | Type | Description |
|-------|------|-------------|
| appUserId | string | Main user ID. Use for RevenueCat external_id, OneSignal, backend. |
| installId | string | Device/install UUID from get-uuid://. Changes per install. |
| source | 'vault' \| 'restore' \| 'new' | Where the ID came from: vault (iCloud/backup), restore (purchase history), or new (first install). |
| aliases | string[] | Alternate IDs linked via linkAlias(). For fraud checks, analytics. |
Returns null when not in Despia native runtime (e.g. web).
// Example
const result = await userIdentity();
// { appUserId: "abc-123", installId: "device-xyz", source: "vault", aliases: [] }restore()
Recovers identity from purchase history. Use for "Restore purchases" button.
Returns: { appUserId: string; aliases: string[] } | null
| Field | Type | Description |
|-------|------|-------------|
| appUserId | string | Recovered ID from a purchase with externalUserId. Sync to services. |
| aliases | string[] | Current aliases (from vault). |
Returns null when no purchases found, no purchase has externalUserId, or not in Despia.
// Example
const restored = await restore();
// { appUserId: "abc-123", aliases: [] } or nullsetAppUserId(appUserId: string)
Sets the main ID (e.g. from login). Persists to vault. No return value.
linkAlias(aliasId: string)
Links an alternate ID to this user. Persists to vault. No return value. Skips if aliasId is already main or in aliases.
isDespia()
Returns: boolean - true if navigator.userAgent includes "despia" (native app), else false.
getPlatform()
Returns: 'ios' | 'android' | null - platform when in Despia (based on user agent: iphone/ipad vs android). null on web or unknown.
API summary
| Function | Returns |
|----------|---------|
| userIdentity() | { appUserId, installId, source, aliases } \| null |
| restore() | { appUserId, aliases } \| null |
| setAppUserId(id) | void |
| linkAlias(id) | void |
| isDespia() | boolean |
| getPlatform() | 'ios' \| 'android' \| null |
