UserTrack
Developers / Integrations / Native SDK
Native SDK

Any app → UserTrack in two minutes.

@usertrack/node mounts one signed, read-only handler in your app. UserTrack pulls aggregate counts from it every 4 hours — verified, without emails, names, user records or database credentials leaving your app. Adapters for Prisma, Drizzle, Convex and Auth.js; a count() function is all a custom app needs.

Using Better Auth? The official plugin speaks the same protocol: @usertrack/better-auth.

Overview

Why native

Native · verified

The response is signed with a secret only your deployment holds, so UserTrack marks the source Verified regardless of hostname — unlike a plain JSON endpoint.

Whole funnel, one handler

Signed up from your users table; optionally Activated (a table with one row per activated user) and Trial / Converted (subscription state, never amounts) from the same route. UserTrack attaches the extra stages automatically.

Nothing breaks

Read-only aggregate queries; the optional push hooks are fire-and-forget. If UserTrack is down, your app never notices.

ESM · Node 18.17+ and edge runtimes with WebCrypto · zero runtime dependencies · works as a Next.js route export, in Hono, Remix, Bun, Cloudflare Workers, and via toNodeHandler() in Express or node:http.

Manual setup

Install and configure

  1. 1 · Create the integration in UserTrack

    Project → Integrations → Users → My app (SDK), pick your adapter and enter the base URL where the handler will live (default https://your-app.com/api/usertrack; Convex: your .convex.site URL). UserTrack generates USERTRACK_PROJECT_ID and a USERTRACK_SECRET (ut_int_…, shown once — rotate it if lost).

  2. 2 · Install the package and mount the handler
    1 · Install
    npm install @usertrack/node@latest
    2 · Mount the handler · app/api/usertrack/metrics/route.ts
    // app/api/usertrack/metrics/route.ts — signed, read-only aggregate counts for UserTrack (no user records)
    import { createUserTrackHandler } from "@usertrack/node";
    import { prismaUsers } from "@usertrack/node/prisma";
    import { prisma } from "@/lib/prisma";
    
    export const POST = createUserTrackHandler({
      projectId: process.env.USERTRACK_PROJECT_ID!,
      secret: process.env.USERTRACK_SECRET!,
      source: "prisma",
      users: prismaUsers(prisma.user),
      // optional: activation: <count source of activated users>, conversion: { converted: <count source> }
    });
    Optional · Optional: push signups between syncs · lib/prisma.ts
    import { PrismaClient } from "@prisma/client";
    import { userTrackPrismaExtension } from "@usertrack/node/prisma";
    
    export const prisma = new PrismaClient().$extends(userTrackPrismaExtension({ projectId: process.env.USERTRACK_PROJECT_ID!, secret: process.env.USERTRACK_SECRET! }));
    • ›prismaUsers(prisma.user) runs count() with createdAt filters only — no rows are read. Pass { createdAtField: null } if the model has no timestamp, or { where: { deletedAt: null } } to exclude soft-deleted users.
    • ›Any model works as a source: prismaUsers(prisma.workspace) for activation, prismaUsers(prisma.subscription, { where: { status: "active" } }) for conversion.
  3. 3 · Environment variables
    # UserTrack (https://usertrack.dev) — registered-user growth metrics, no PII
    USERTRACK_PROJECT_ID=
    USERTRACK_SECRET=

    Set the real values in your local env file and in your hosting provider for every environment. Never commit the secret.

  4. 4 · Deploy, then click Verify

    UserTrack calls POST <base>/metrics once, checks the signature, reads the total user count, records the first verified snapshot and schedules syncs every 4 hours. Result: Connected · 12,481 users detected · Verified via Prisma (UserTrack SDK).

Lifecycle

Activation and conversion from the same handler

Any count source works for any stage. Pass activation and / or conversion; UserTrack attaches the stages after the next sync of your users source — no second credential, no second setup.

export const POST = createUserTrackHandler({
  projectId: process.env.USERTRACK_PROJECT_ID!,
  secret: process.env.USERTRACK_SECRET!,
  source: "prisma",
  users: prismaUsers(prisma.user),
  activation: prismaUsers(prisma.workspace),                                        // one row per activated user
  conversion: { converted: prismaUsers(prisma.subscription, { where: { status: "active" } }), mode: "active_paid" },
});
  • ›A count source is { count({ createdAtGte?, createdAtLt? }) => Promise<number | { count, exact }> }. Return exact: false if you hit a scan cap; set timeFilter: false when the table has no timestamp (totals only).
  • ›Conversion is state only: who is converted / on a trial, plus mode (active_paid default, ever_paid, first_payment). UserTrack never asks for amounts, prices or MRR.
  • ›Optional identities() returns stable user ids per stage for Cohort Verified funnels — never emails (anything containing @ is dropped).
  • ›Push between syncs with createTracker().track("user.created" | "user.activated" | "trial.started" | "user.converted", { id }) — the id is HMAC-pseudonymised before it leaves your app.
Protocol

How verification works

Pull — source of truth

UserTrack sends POST <base>/metrics with { protocolVersion: 1 } (plus days for the 30-day history on first sync). Each request carries x-usertrack-project, -timestamp, -nonce and an HMAC-SHA256 -signature over method, path, timestamp, nonce and body hash. The handler verifies with a timing-safe comparison, rejects timestamps outside ±5 minutes and replayed nonces, then signs its response bound to the request nonce. UserTrack verifies that signature before storing anything.

Push — freshness (optional)

Lifecycle events go to https://usertrack.dev/api/integrations/native/events — after your write, fire-and-forget, 3-second timeout, deduplicated by event id. The dashboard shows signups, activations and conversions between syncs; the next pull always wins.

{
  "protocolVersion": 1,
  "clientVersion": "0.1.0",
  "source": "prisma",
  "projectId": "<USERTRACK_PROJECT_ID>",
  "generatedAt": "2026-09-03T08:00:00.000Z",
  "users": { "totalUsers": 12481, "newUsers": { "24h": 84, "7d": 491, "30d": 1832 }, "daily": [{ "day": "2026-08-04", "newUsers": 51 }, "…"] },
  "activation": { "activatedUsers": 6210, "activated24h": 40, "activated7d": 260, "activated30d": 990 },
  "conversion": { "convertedUsers": 812, "newConverted30d": 61, "trialUsers": 130, "mode": "active_paid" },
  "capabilities": { "exactCounts": true, "history": true, "roles": ["users", "activation", "conversion"] }
}

Protocol package: @usertrack/protocol (WebCrypto only, zero dependencies) if you implement the client in another language. UserTrack checks protocolVersion (supported: 1) and clientVersion on every sync.

Data & privacy

What leaves your app

  • ›Total registered users and new users in 24h / 7d / 30d, plus a daily series on first sync (aggregate counts only).
  • ›Optionally activated users and converted / trial users from the same handler (counts only — never amounts, prices or MRR).
  • ›Optional lifecycle events user.created / user.deleted / user.activated / trial.started / user.converted with a pseudonymous HMAC-derived subject — never the user id, email, name or profile.
  • ›Never: emails, names, passwords, password hashes, session tokens, verification tokens, account or provider metadata.

On your public UserTrack page the source appears only as provenance: Verified via Prisma (UserTrack SDK). Trust semantics are the same as for every provider: UserTrack independently retrieves aggregate metrics from your installation; it does not claim that every account is a real human.

AI setup

Let your coding agent do it

Connect the UserTrack MCP server (https://usertrack.dev/mcp, token from your developer page) to Claude Code, Cursor, Codex or VS Code and send:

Add this project to UserTrack with the native SDK. Detect how the app stores users (Better Auth, Auth.js / NextAuth, Convex, Prisma, Drizzle or a custom database), create or find the UserTrack project, create the native integration (usertrack_create_integration { provider: "native", source }), get the plan from usertrack_get_native_setup, install @usertrack/node (or @usertrack/better-auth) with this repo's package manager, add the single route file with a users count source (plus activation / conversion sources if the tables exist), add both env vars to .env.example and the local env (never commit the secret), typecheck, deploy, verify the integration, trigger the first sync, publish the project (usertrack_update_project { isPublic: true } — a draft is not listed and its public URL 404s) and return the public UserTrack URL.

The agent calls usertrack_create_integration { provider: "native", source } (receives the secret once), usertrack_get_native_setup (install command, route file, env vars, safety rules) and usertrack_verify_integration. The instructions it receives include these safety rules:

  1. 01Install with the @latest suffix and check `npm ls @usertrack/protocol` resolves to 0.1.1 or newer before writing code. Older versions bundle a node:crypto fallback that breaks Convex, Workers, Deno and Edge builds; if the lockfile pins an older one, update it rather than patching or aliasing the builtin.
  2. 02Add exactly one route file that exports the UserTrack handler; do not change authentication, billing or database code beyond that (and the optional push hook).
  3. 03The users count source must count registered users only (exclude anonymous / soft-deleted rows with a where filter) and must never return rows.
  4. 04Honour createdAtGte / createdAtLt on every count. UserTrack calls the users source once per day to rebuild the growth curve from real signup dates, so a user who signed up on 1 September appears on 1 September in the chart — not on the day you installed the SDK. A source that cannot filter by time must declare timeFilter: false; UserTrack then stores totals only and the curve starts the day you connected.
  5. 05Count the table that owns the user record — the auth store or user table. Never count an analytics or event-log table (a `growthEvents` / `signup_completed` style log), even one the repo already writes on signup: it only holds rows from the day it shipped, so the count and its history start at ~0 and no backfill can recover them. If the repo keeps users in an auth component (@convex-dev/better-auth, @convex-dev/auth), read that component's `user` table, not a mirror table filled by a trigger.
  6. 06Read projectId and secret from process.env.USERTRACK_PROJECT_ID / process.env.USERTRACK_SECRET. Never hardcode the secret and never commit it.
  7. 07Add both variable names to .env.example (values empty) and the real values to the git-ignored local env file and to the hosting provider for every environment that serves the app.
  8. 08Optional sources: activation = a table with one row per activated user; conversion.converted = users with an active paid subscription (conversion state only — never amounts).
  9. 09Both optional sources are windowed by createdAtGte / createdAtLt, so they need the timestamp of the stage itself. Activation: key on each user's first value-event (dedupe by owner, take the earliest) so the daily series counts a user once, on the day they activated. Conversion: key on when the account started paying — filtering paid accounts by their signup date means a user who upgrades months later never shows up in newConverted30d. If no such column exists, add one (e.g. convertedAt, written once on the first upgrade) rather than reusing the signup date.
  10. 10Run the project's typecheck and tests after the change; the SDK ships its own types.
  11. 11Deploy. UserTrack can only verify once the route with the env vars is live.
Troubleshooting

Common errors

Message
Cause
Fix
No UserTrack handler found at …/metrics (404)
The route is not mounted at the base URL you entered, or the deploy is not live.
Mount the handler, redeploy, check the base URL (metrics live at <base>/metrics).
… rejected the signature (401)
USERTRACK_SECRET or USERTRACK_PROJECT_ID in the deployed environment differ from the integration.
Fix the env vars in every environment and redeploy — or rotate the secret in UserTrack and update the app.
… rejected the request as stale
The server clock is off by more than 5 minutes.
Fix NTP on the host; UserTrack retries automatically.
Could not reach …
Wrong base URL, app down or blocked.
The URL must be reachable over HTTPS from the internet.
… could not count users (500)
Your count source threw.
Enable debug: true to log the error server-side; it is never sent to UserTrack.
Counts labelled approximate
A source returned exact: false (Convex cap).
Keep an exact counter (@convex-dev/aggregate) and return its value.

Package source, tests and changelog: github.com/The-CodeCave/UserTrack · npm: @usertrack/node · MIT.