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.
Why native
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.
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.
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.
Install and configure
- 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.siteURL). UserTrack generatesUSERTRACK_PROJECT_IDand aUSERTRACK_SECRET(ut_int_…, shown once — rotate it if lost). - 2 · Install the package and mount the handler1 · 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.tsimport { 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 · 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 · Deploy, then click Verify
UserTrack calls
POST <base>/metricsonce, 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).
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 }> }. Returnexact: falseif you hit a scan cap; settimeFilter: falsewhen the table has no timestamp (totals only). - ›Conversion is state only: who is converted / on a trial, plus
mode(active_paiddefault,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.
How verification works
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.
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.
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.
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:
- 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.
- 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).
- 03The users count source must count registered users only (exclude anonymous / soft-deleted rows with a where filter) and must never return rows.
- 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.
- 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.
- 06Read projectId and secret from process.env.USERTRACK_PROJECT_ID / process.env.USERTRACK_SECRET. Never hardcode the secret and never commit it.
- 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.
- 08Optional sources: activation = a table with one row per activated user; conversion.converted = users with an active paid subscription (conversion state only — never amounts).
- 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.
- 10Run the project's typecheck and tests after the change; the SDK ships its own types.
- 11Deploy. UserTrack can only verify once the route with the env vars is live.
Common errors
Package source, tests and changelog: github.com/The-CodeCave/UserTrack · npm: @usertrack/node · MIT.