crxpaydocs

Install tracking & anonymous users

Every install is a first-class customer row — before email, before payment. Uninstalls are captured automatically.

crxpay treats every install as a customer, even when you don't know who the person is. You get install and uninstall counts, active-user retention, and a clean upgrade path from anonymous to identified to paying — all without asking users to sign in.

Why this matters

Most subscription SDKs only know about a user once they pay. That means:

  • Install funnel analytics are impossible
  • You can't see retention of free users
  • A user who pays and later comes back unsigned-in looks like a new customer

crxpay fixes this by writing a customers row the moment your extension boots for the first time. When the user later calls identify() or pays, the same row gets an email patched onto it. No duplicates, no analytics blind spot.

The identity model

Every customer row has three identity keys, in order of preference:

KeySet whenRequired
installIdFirst SDK boot — alwaysYes
emailAfter identify({ email })No
externalUserIdIf your extension has its own authNo

Rows that only have installId are anonymous. Once they have email or externalUserId, they become identified. If they have an active subscription, they become paying. The dashboard's Users list colors each row by this derived status.

Integrating with your own auth

Most extensions that need login use Firebase, Supabase, Clerk, Auth0, or a custom backend. crxpay works with all of them — you don't rewrite your auth, you just hand crxpay whichever ID you already have.

Call identify() right after your own login succeeds:

// Firebase
import { getAuth, onAuthStateChanged } from 'firebase/auth';
import * as crxpay from '@crxpay/sdk';

await crxpay.configure({ apiKey: 'crxpay_pub_…' });

onAuthStateChanged(getAuth(), (user) => {
  if (!user) return;
  crxpay.identify({
    email: user.email ?? undefined,
    externalUserId: user.uid,     // your stable id
  });
});
// Supabase
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(URL, KEY);

supabase.auth.onAuthStateChange((_event, session) => {
  if (!session) return;
  crxpay.identify({
    email: session.user.email,
    externalUserId: session.user.id,
  });
});
// Clerk
import { useUser } from '@clerk/chrome-extension';
const { user } = useUser();
if (user) {
  crxpay.identify({
    email: user.primaryEmailAddress?.emailAddress,
    externalUserId: user.id,
  });
}
// Your own API
const me = await fetch('/me').then(r => r.json());
crxpay.identify({ email: me.email, externalUserId: me.id });

Which ID should I pass?

  • email: good display label in the dashboard; lets you look up a user by typing their email. Can change if they update it.
  • externalUserId: your stable primary key. Never changes. Best when your auth lets users change email.

Pass both when you have them. crxpay keys the row on installId either way — email and externalUserId are alias data, not primary identity.

On logout

Don't call a "crxpay.logout()" — there isn't one. The installId stays the same across sessions, so the user is always the same crxpay customer. If another user logs in on the same install, that's fine: call identify() again with their info and crxpay records the re-identification in the event log.

What you get in the dashboard

Once you call identify() with your own externalUserId:

  • The Users list shows the user as Identified (blue pill).
  • Searching by email or your user ID finds the row.
  • The detail page Identity block shows both email and externalUserId.
  • Every subsequent event (paywall view, checkout, subscription change, uninstall) is attributed to this same customer row — no duplicates, no orphan anonymous rows.
  • Webhook payloads you subscribe to include both email and externalUserId, so your server can look up the user in your own DB without an extra round-trip.

Pre-login analytics

Everything the user does before they log in is already tracked against the anonymous row: installs, paywall views, even checkouts started. When they finally sign up, identify() patches that same row — so the funnel stays intact and retention metrics include users who never logged in.

What the SDK does automatically

On first boot, the SDK:

  1. Generates a stable installId (UUID v4, stored in chrome.storage.local).
  2. Calls POST /v1/sdk/register-install once with { installId, extensionVersion, locale, browser }.
  3. Registers a Chrome uninstall URL via chrome.runtime.setUninstallURL() that pings /v1/uninstall?k=<publicKey>&i=<installId> and returns a 1×1 gif.
  4. Sends X-CrxPay-Install-Id and X-CrxPay-Version on every request so the backend can bump last_seen_at.

You don't call any of these directly. Just CrxPay.configure({ apiKey }) and you're done.

Identifying a user

When the user signs in to your extension (magic link, Google SSO, whatever you use), call:

await crxpay.identify({ email: 'ava@example.com' });

This does not create a new customer. It patches the email column onto the existing anon row. The customer keeps all their history: events, retention, spent revenue, entitlements.

If your extension has its own auth and you'd rather key on your user ID:

await crxpay.identify({ externalUserId: 'u_01HNPK3XYZ' });

You can pass both — email takes priority for display, but either key makes the row identified.

The uninstall gif

Chrome opens the setUninstallURL in a new tab when the user removes the extension. The backend validates the public key + installId, writes uninstalled_at = now(), emits a customer.uninstalled event, and returns a 1×1 transparent gif so the tab closes cleanly.

Uninstalled rows stay in the database. They power retention metrics and churn analysis — deleting them would throw away exactly the signal you need.

What you see in the dashboard

  • Overview → Installs / Active Users / Paying: org- or extension-scoped totals.
  • Overview → Installs vs Uninstalls chart: 30-day daily bar chart, signal at a glance.
  • Overview → Retention card: D1 / D7 / D30 — share of installs still active after N days.
  • Users list: Status column (Anonymous / Identified / Paying / Churned / Uninstalled), Last active, Version, Country. Filter chips switch between presets.
  • User detail → Timeline: every lifecycle event in order — install, identify, checkout, paid, uninstalled.

API reference

POST /v1/sdk/register-install

Called by the SDK on first boot. You don't call this directly.

{ "installId": "uuid", "extensionVersion": "1.2.0", "locale": "en-US", "browser": "chrome" }

Returns { "customerId": "cus_…", "isNew": true }. Idempotent — subsequent calls return isNew: false.

POST /v1/sdk/identify

{ "email": "ava@example.com" }

Aliases the anon row. Returns { customerId, email, isNew, jwtToken }. The jwtToken is the short-lived customer JWT used by subscription/portal/checkout routes.

GET /v1/uninstall?k=<publicKey>&i=<installId>

Public, unauthenticated endpoint. Validates k against the extension's public key, looks up the customer by installId, sets uninstalled_at, emits customer.uninstalled, returns a 1×1 gif. Always returns the gif — an invalid key fails silently so probing attackers can't enumerate installIds.

FAQ

Does identify() create a duplicate customer? No. It patches the email onto the existing anon row matched by installId. If no anon row exists (rare — e.g. custom server-side code calling identify directly), it creates a new row as before.

What if the user clears chrome.storage? You lose the installId mapping and the next boot creates a new anonymous row. This is rare in practice (installId is stored in chrome.storage.local, which survives updates and reloads).

Can I disable install tracking? Not currently. The install row is how the SDK knows which customer to attribute subscription events to, so it's always created. No PII is captured — just installId, version, locale, browser, and country (from CF-IPCountry on the server).

Was this page helpful?

Your feedback shapes what we document next.