Skip to content
Sendora Cloud
Create account
Identity

Device-takeover on signIn

One device should always resolve to one identity. When a previously-anonymous device signs in to an identified account, Sendora retires the anonymous row, reassigns its push tokens, and notifies you so your own data layer can do the same.

The class of bug this fixes

A mobile app launches → SDK calls signInAnonymously() → an anonymous user_id A is minted on Sendora's side. The device registers its push token under A. Later the human signs in → signIn(email, password) → a separate identified user_id B is minted. The device re-registers under B.

Without intervention, both A and B stay alive. Any audience query that joins on user_id will fan out to both rows, and the device receives every send twice. Email, SMS, push — all affected, anywhere the customer's pipeline mirrors Sendora's user identifier into its own tables.

What Sendora does on signIn

When the SDK detects an active anonymous session, it forwards the anonymous refresh token to the signin endpoint as prevAnonRefreshToken. Backend:

  1. Validates the refresh token (proves the caller really held that session).
  2. Revokes every session bound to the anonymous user_id.
  3. Reassigns every push token registered under the anonymous user_id to the identified one — the device's token survives the identity flip.
  4. Hard-deletes the anonymous user row from auth_service_users.
  5. Emits an auth.device_takeover event row + dispatches a webhook of the same type to every subscribed endpoint.
  6. Returns retiredAnonUserId on the signin response so the SDK can fire an inline listener.

The handler is best-effort. Wrong / expired / cross-project / non-anonymous refresh tokens are silent no-ops — the identified login still completes.

Which endpoints trigger it

Every identified-session-mint path on the auth-service surface accepts the optional prevAnonRefreshToken field:

  • POST /auth-service/login (email + password)
  • POST /auth-service/login/social (Google / Apple / GitHub / Microsoft / LinkedIn / Facebook / Discord)
  • POST /auth-service/magic-link/verify
  • POST /auth-service/email-otp/verify
  • POST /auth-service/passkeys/authenticate/finish
  • POST /auth-service/mfa/challenge
  • POST /auth-service/sso/oidc/start (persisted on the state row; consumed on callback)
  • POST /auth-service/sso/saml/start (same)

Mirror the cleanup into your own data layer

Sendora-side identity is now clean, but anything you mirror locally — a customer-side users table, an analytics cache, an audience filter on your own backend — still references the retired anonymous user_id until you act. The duplicate-push class of bug reappears on your side of the fence.

You have two notification mechanisms. Use either, or both.

Option A — Inline SDK listener (recommended for clients)

The SDK fires a local event the moment the signin response carries retiredAnonUserId. No infrastructure required. Works even if your webhook receiver is down or you don't have one. Available on every first-party SDK — React Native 1.0.5+, Web 3.0.1+, iOS 4.0.5+, Android 4.0.5+.

import { Sendora } from "@sendoracloud/sdk-react-native";

const sendora = Sendora.init({ apiKey: "pk_…" });

// Register once at app start.
const unsub = sendora.auth.onDeviceTakeover(async (evt) => {
  // evt.retiredAnonUserId   — the anonymous user_id Sendora just hard-deleted
  // evt.identifiedUserId    — the identified user_id that took the device over
  // evt.at                  — Date the SDK observed the event

  // Example: delete from your own users mirror so audience queries
  // joining on user_id stop matching the stale row.
  await fetch("/api/internal/users/" + evt.retiredAnonUserId, {
    method: "DELETE",
  });
});

// Later, on signIn:
await sendora.auth.signIn("user@example.com", "…");
// → listener fires automatically when the backend retired an anon row.

Late subscribers can also pull the latest takeover the SDK observed during this session:

const last = sendora.auth.getLastDeviceTakeover();
if (last) { /* ... clean up ... */ }

Option B — Webhook subscription (server pipelines)

Subscribe an endpoint in Dashboard → Webhooks to auth.device_takeover. Sendora signs every delivery with X-Sendora-Signature: t=…,v1=… (HMAC-SHA256 over the body keyed on the endpoint's secret). Retries follow the same 2s / 8s / 30s / 90s backoff every other Sendora webhook uses.

POST <your endpoint>
X-Sendora-Signature: t=1737000000,v1=…
{
  "id": "<uuid>",
  "type": "auth.device_takeover",
  "orgId": "<uuid>",
  "occurredAt": "2026-05-29T18:42:00.000Z",
  "data": {
    "anonUserId": "<uuid>",
    "identifiedUserId": "<uuid>",
    "projectId": "<uuid> | null"
  }
}

Handler shape (TypeScript):

if (event.type === "auth.device_takeover") {
  const { anonUserId } = event.data;
  // Delete the matching row from your users mirror.
  await db.delete(users).where(eq(users.id, anonUserId));
  // Optionally dedupe push tokens by canonical contact at the
  // same time. Sendora's own push tokens already moved over
  // server-side — this covers your local mirror.
}

Both layers, defence-in-depth

The inline listener is the immediate / low-latency signal — handler runs the instant signin returns. The webhook is the durable signal — survives crashed client processes, lossy mobile networks, app force-quits between signin and handler execution.

For consumer mobile apps where the client is the natural cleanup target, option A is usually enough. For B2B platforms that maintain server-side user mirrors, option B is the load-bearing path. Mirror-heavy architectures should wire both.

Safety rails

  • UUID-gated. SDKs validate retiredAnonUserId against the canonical UUID form before invoking your listener. A tampered SSO fragment or response body silently fails the gate instead of becoming a path-injection sink in your fetch("/api/users/" + evt.retiredAnonUserId) handler.
  • Anonymous-only. Sendora refuses to retire a non-anonymous row, even if the caller presents that account's refresh token.
  • Same project. The anonymous and identified rows must share the same project. Prevents a prod-anon row being absorbed into a dev account.
  • Best-effort. A failure on takeover never fails the signin. The identified session still mints.
  • Not a merge. Anonymous events stay attributed to the deleted user_id — Sendora does not graft them onto the identified account. For event reattribution semantics, use auth.merge() instead.
  • Kiosk-safe. Because the SDK only forwards the anonymous refresh token when the local subject is currently anonymous, an already-identified session can never take over a stranger's anon row.

Minimum versions

  • Backend — deployed on prod 2026-05-29.
  • @sendoracloud/sdk-react-native 1.0.5 for onDeviceTakeover + getLastDeviceTakeover.
  • @sendoracloud/sdk-web3.0.1 for the same inline listener API on browsers + SSR-hydrated tabs.
  • sdk-ios4.0.5 for the inline listener API. Plumbing alone available from 4.0.4.
  • sdk-android4.0.5 for the inline listener API. Plumbing alone available from 4.0.4.