Skip to main content
track() records what somebody did in your product. Everything else on this page exists because the interesting part is not the call — it is what happens to the event between a browser that is about to be closed and a chart somebody reads next week.

One call, and it is never a request

track() returns nothing and awaits nothing. The event goes into a queue that lives in the browser’s storage, and the queue leaves in batches — on a five second interval, the moment the browser comes back online, and on pagehide with keepalive, which is the flush that survives the tab being closed. Two fields are minted at the call, not at the flush:
  • id, a UUIDv7. It is what makes a redelivery countable once: a batch the page died before hearing the answer to is sent again, and the events that already landed write nothing.
  • occurred_at, the client’s clock. A queue that waited out an outage must not date everything to the minute the network came back.
An event leaves the queue only after the API’s 202. A network failure, a 500, a laptop lid — the batch stays and the next flush carries it. The queue holds 500 events and drops the oldest on overflow: recent behaviour is worth more than what an outage missed, and a queue that grew without a ceiling would be a storage quota failure landing on the session it shares that storage with. On a server there is no interval, no online and no pagehide. Call flushEvents() yourself — it is also the explicit drain for a test, or for a “before you go” moment of your own.

Names

^[a-z0-9_.:-]{1,64}$, lowercase. A name outside that throws invalid_event_name at the call rather than being dropped somewhere quieter — an event silently discarded is discovered in a dashboard a week too late. The $ prefix is reserved, and track("$auth.signed_in") throws reserved_event_name. That refusal is what makes the events below worth trusting: nothing in a browser can write into the platform’s namespace. properties is a JSON object of at most 8 KiB — dimensions the explorer groups by, not a document store.

Properties every event carries

register() stamps a set of properties onto every event from then on — what other tools call super properties.
It exists because the alternative is remembering. A multi-tenant product wants organization_id on every event, and a property that has to be written at thirty call sites is the one that will be missing from exactly the event somebody needed it on. The stamp happens at the call, not at the flush: an event queued before a register() describes a moment when that was not yet true. The event’s own properties win a collision — the call site knows more about that one event than the registration did. unregister("plan") removes by name. getRegistered() returns a copy of what is being stamped.
They are cleared with the session, for the reason the feature flags and the active team are: the next session may be somebody else, and an organization_id that outlived a sign-out is not a gap in the data — it is a wrong answer inside it. Register where you call boot(), not once at the top of the page.

Page views in a SPA

A single-page app never loads a page again, so the browser has nothing to report and every product ends up writing this against its own router — with an event name of its own, which is how one product ends up unable to compare page views across two tenants.
It works with any router: pushState and replaceState are wrapped and popstate is listened to, which is all there is. On the <script>, data-pageviews="auto" turns on the same thing — and both are off by default, because this writes to your event stream and is not our decision to make. The event carries path, plus from on a navigation, and never the query string. A page view is automatic, so whatever is in the URL when it fires is in your analytics: a password-reset token, an OAuth ?code= (this package puts one there itself). Attribution does not need it either — utm_*, the referrer and ref are captured once by first-touch. There is no title, and its absence is the deliberate half: a router changes the URL and renders afterwards, so document.title at that moment is still the previous page’s. A title that is wrong is worse than a title that is missing. Anything else you want belongs in register(), which is stamped on these too. It only counts when the path changes: routers call replaceState for things that are not navigations — syncing a filter, cleaning a param — and counting those inflates every product with a search box.

What 202 means

Accepted, never durable. Events are held in process memory and flushed to storage within seconds; a crash between those two moments loses what was accepted and not yet flushed. It is an explicit trade, made for analytics events and for nothing else — a fact your product cannot afford to lose belongs on your backend, behind an API key, not here. Validation is per event. A bad line is reported in rejected with its index and a code, and the rest of the batch still lands: an offline buffer replaying a hundred events must not lose ninety-nine to one stale timestamp. The codes are invalid_id, reserved_name, invalid_name, properties_too_large, invalid_properties, invalid_occurred_at and occurred_at_out_of_range (occurred_at may be up to 7 days in the past — the offline buffer’s own window — and 5 minutes in the future, for clock skew). A rejected event is final: the SDK drops it with the batch, because the server has answered it and a retry would only be refused again. One request carries at most 100 events and 256 KiB, and the door is rate limited to 600 requests per minute per IP behind a ceiling of 6000 per minute per environment.

Who the event belongs to

The publishable key travels in the body, exactly as it does for boot, and the key’s allowed-origins list is the gate. The environment follows the key: a uk_pk_test_… writes test events. The device’s anonymous_id always rides along, so a visitor’s events are attributed before anybody has signed in and stitch to the contact who eventually does. A contact session names, and never gates:
  • signed in, the batch resolves to that contact;
  • a token that has expired or been revoked mid-flight demotes the batch to anonymous rather than losing it;
  • a contact_id in the body is ignored. An event naming somebody is an assertion, and only a session gets to make it.
With @userkit/nextjs the same call goes through your own origin — the key stays out of the bundle and the httpOnly cookie signs the batch.

Which team the event belongs to

A B2B product has people in more than one team, and “what did this person do” is only answerable once you know inside which account. So every event carries the team as well. In the browser it is the team the session is already using: the X-Customer-Id the SDK sends after you pick one, or — with no header — the contact’s oldest membership. Nothing new to call. From your backend, POST /v1/track takes customer_id (ours) or customer_external_id (yours, the same id you mirror the team with) per line. Both on one line is ambiguous_customer; a team that does not resolve in that environment is unknown_customer and the line is refused rather than stored without one — saying which team and being ignored is worse than not saying. No team is an answer, not an error. A product without teams never fills the field, and the event is then the person’s own: the panel lists it inside every team of theirs, marked “Sem time”. Events written before the column existed are exactly that case — nothing is guessed retroactively, because attributing an old fact today would date it to a relationship that may not have existed then.

The funnel you do not instrument

UserKit publishes what it saw itself into the same pipeline, under the reserved prefix. A funnel that starts at sign-up needs no track() from you at all. They are transcribed from the internal bus, not written by the handlers, which is why a new way in cannot forget to produce one. Two consequences worth knowing: they are durable at commit and land when the bus drains (seconds, not milliseconds), and anonymous visitors never appear — the widget mints a session per page load and none of them is an account. $auth.signed_in carries method and deliberately not the IP or user agent. The explorer answers how, never from where; where a session came from is the audit trail’s question, and it answers to a narrower permission.

The explorer

Analytics → Events in the panel, behind the analytics:read permission (owner and admin by default — the same screens carry revenue, so there is no “just the counts” seat; grant it on a custom role if your analyst is not an admin). Two reads sit under it. The raw list filters by event name, contact and range. The chart counts events per UTC day and name, and says which source answered:
  • a window of 48 hours or less reads the raw events, because that is exactly where the rollup’s up-to-an-hour lag would be the thing on screen;
  • anything wider reads the hourly rollup, because re-aggregating months of raw rows on every refresh is the cost the rollup exists to pay once.
Both apply the same aggregation rules, so which one answered never changes what a number means — the response says which one it was because a support conversation about a chart starts there. A filter worth keeping becomes a saved query: a name and the explorer’s own filter form, stored per organization rather than per environment. The question “where do people drop off” is the same in live and test; the environment is picked when you ask it again.

Funnels

A funnel is an ordered list of event names (2 to 10) and a window in days. A contact enters when their first step-one event falls inside the range you are asking about, and reaches step n by doing every step in order, the whole sequence no later than window_days after entering — the window anchors at step one, so a generous middle step cannot stretch a funnel indefinitely. The range bounds who enters, never who finishes: somebody who entered on the last day still has their window to convert in.
A funnel counts contacts, so it sees the events that resolved to one — an anonymous session’s contact included, since a funnel over visitors is a legitimate question. Events that carry nothing but an anonymous_id are in the explorer and not in a funnel. Steps may name the platform’s $auth.* events and your own in the same list — that mixture is the point, and it is the chart no tool that only sees your instrumentation can draw. Results come back as a dense list: every step, including the ones nobody reached, because a zero is something a chart must draw rather than a hole it papers over. conversion is against the funnel’s first step — “of those who entered” is the question a funnel answers, and a step-to-step ratio is one division away in the client, while the reverse loses precision.

Whose midnight

Days are UTC in storage — for the counts, the rollups and the revenue metrics alike. Whose midnight a day belongs to is decided once, at the point of storage, and your organization’s timezone is applied at display. It is why two charts drawn from two of these reads can be laid over each other and agree.

How long the numbers are kept

Your plan sets a retention for analytics — 30 days on Free, 365 on Pro — and it applies in two places, which is worth knowing before you build a report against these reads. On the read. Every analytics response carries a range object saying which window it actually answered over:
clamped is true when your plan moved from forward. Asking for more is never an error and never a 402 — you get a 200 over the window your plan keeps, and the range says so. Read clamped rather than comparing timestamps yourself: only the API knows which horizon it applied. retention_days is null when the read looked through everything stored. On storage. Past that horizon the events are deleted nightly, along with the daily counts and the weekly cohorts derived from them. This is not the same as the panel’s staff audit window, which only filters a read: here, moving to a longer plan gives you a longer horizon from that day forward, not the history you had before. If you need analytics kept beyond your plan’s retention, export it — the event explorer is the read to pull from. Revenue metrics are the exception. They are frozen from your invoices rather than derived from events, so they are only ever clipped on the read, never deleted.

Where the numbers become money

Revenue metrics — MRR, the four movement buckets, LTV, NRR — and revenue by acquisition channel are the other half of this module, and they are derived from subscriptions and invoices rather than from events.