Track conversions
Routing learns from the conversions your code reports. This guide gives the two calls and their rules, a recipe for each place a conversion can happen, the checks that show conversions reaching their visits, and the tracking gap to record when tracking breaks. Stripe, auth providers and PostHog have guides of their own.
The two calls
routingcat.identify(accountId)links the current visitor to an account: your app’s own id for a user. Call it when the account is created and at every sign-in, so that the person’s other devices link to it too.routingcat.track(name, options)reports an event, such assignuporpurchase.
A conversion counts for a page when it reaches the visit that saw the page: through the visitor id it carries, from the rc_vid cookie, or else through its account. An event with neither counts nowhere.
| Where the conversion happens | What to use |
|---|---|
| Server code of your Next.js app’s App Router: Server Functions, route handlers and server components | routingcat.track and routingcat.identify from routingcat/next. They read the visitor from the request’s cookie, never throw, and send after the response, so they never delay it. |
| Client components | The same import and the same names, and routingcat.link, which carries the visitor id on a link to another of your domains. They post through your site’s /_rc path. |
Pages Router API routes, getServerSideProps, scripts |
The HTTP events API, with the server events key, as from another backend. routingcat/next’s calls send nothing there, and say so once in your logs. |
| Another backend, or a webhook | The HTTP events API, with the server events key. |
| Another frontend, or a form with no backend | routingcat/browser, or its script tag with your public key as data-key. Each version’s URL and integrity hash are in the JavaScript API. |
The rules
- Account ids are stable and non-personal: your own record id, or the SHA-256 of the trimmed, lowercased email when there is no record. Never an email address, and
identifywarns when an id holds@. A person needs the same id on every device and at every sign-in, and RoutingCat has no use for their email. - Events carry an
id: the provider’s event id, such as Stripe’sevent.id, orsignup:<account id>.routingcat.trackrequires one, in server code and client components alike, and a servertrackwithout one is logged once and not sent. The browser entry generates one where a call gives none. Providers deliver some events twice, and RoutingCat keeps one event per name andid, so a retry never counts twice. - Times: a server event keeps its
occurredAt, aDate, an RFC 3339 string or a number of Unix seconds, or of milliseconds asDate.now()gives them, and one more than five minutes ahead is rejected, with a line in your logs. A browser event takes the time RoutingCat receives it. A webhook can arrive hours late, and its conversion still counts when it happened. - Checked before sending: an event whose name or
idRoutingCat would refuse is not sent, an invalid optional field is left out and the event sent, and a field set tonullreads as absent. Each of these is said once, in your logs or the browser’s console, as is each event RoutingCat’s API refuses. - Names are 1 to 64 characters, with no control character,
+or/, since+joins the events of an objective where any one counts and/marks a page’s own objective. An event under a nameroutingcat.config.tsdoesn’t declare is recorded, but not analyzed until declared. - Sources: each event is
server,browserorposthog, and results show the objective’s source beside it. A browser can send anything, so an objective worth defending comes from your server. - Values:
valuewithcurrency, an ISO 4217 code, is money in major units,29.99for $29.99, and revenue sums it per currency. Withoutcurrencyit is a plain quantity, never revenue. Optionalitemslist{ id, quantity, unitValue },unit_valueover HTTP, at most 200. Revenue is reported; routing learns from the objective alone. - Environments: in development, the calls record nothing and log to the console. Events from a preview deployment, or sent with the preview server key, stay out of results, which count production only, so testing never shows in them.
Record a conversion in the same app
Where your app creates the account, call await routingcat.identify(user.id), then routingcat.track('signup', { id: `signup:${user.id}` }). Identify again where the app first loads the signed-in user, at every sign-in. When a provider such as Clerk creates the account, Clerk and other auth providers shows where.
A conversion with no account, such as joining a waitlist, tracks alone. The example app’s waitlist derives its id from a hash of the address, so the same address joining twice counts once, and the address is never kept:
'use server'
import { createHash } from 'node:crypto'import { refresh } from 'next/cache'import { routingcat } from 'routingcat/next'
export interface WaitlistState { readonly ok: boolean readonly message: string}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/** * The waitlist's Server Function, called from the home and pricing pages. It checks the address and * keeps nothing, then refreshes the page it was called from. */export async function joinWaitlist( _previous: WaitlistState | null, form: FormData,): Promise<WaitlistState> { const email = String(form.get('email') ?? '').trim() if (!EMAIL.test(email)) return { ok: false, message: 'Enter a valid email address.' } // The conversion, deduplicated per address without keeping it: a hash of the normalized email. const id = createHash('sha256').update(email.toLowerCase()).digest('hex') routingcat.track('waitlist', { id: `waitlist:${id}` }) refresh() return { ok: true, message: 'You are on the list.' }}Record it from an app on a subdomain
The rc_vid cookie is set on your registrable domain, so an app on app.lumen.so already reads the visitor id of lumen.so. A Next.js app there uses the recipe above; any other app uses the next one.
Record it from another backend
At sign-up, the backend reads the rc_vid cookie from its own request, then posts the identity link and the event with the server events key, which npx routingcat keys server prints:
POST https://ingest.routingcat.com/v1/eventsAuthorization: Bearer sk_…Content-Type: application/json
[{"type":"identity_link","id":"idl:acct_42:v_8v_ztmJDpYzDKH7UxXwXPA","visitor_id":"v_8v_ztmJDpYzDKH7UxXwXPA","account_id":"acct_42"}, {"name":"signup","id":"signup:acct_42","visitor_id":"v_8v_ztmJDpYzDKH7UxXwXPA","account_id":"acct_42"}]- Later events need only
account_id, since the identity link already ties the account to the visitor. - Write each field as the API names it:
visitor_id, nevervisitorId. An event with a field the API doesn’t take is refused, and the answer names the field meant; your own data goes inproperties. - Post off the response’s path. The answer waits until the events are stored durably, which can take several hundred milliseconds.
- Retry freely. A request holds at most 100 events, and the answer gives a result for each, so one bad event never blocks the others; a retried event counts once. The events API reference has the limits and the answers.
- Keep the key a secret of that backend: it writes events for your site. Access and keys says how to replace it.
Record it on another registrable domain
From lumen.so to lumen.app, the cookie can’t follow, so the link carries the visitor id:
- On
lumen.so,routingcat.link(url)fromroutingcat/next, in a client component, adds the visitor id to a link that crosses. Call it once the component has mounted, or in the link’s click handler: in the server’s render it returns the link unchanged, and in server code it throws. A frontend there that isn’t your Next.js app usesroutingcat.link(url)fromroutingcat/browser, or with the script tag. - On
lumen.app, load the browser entry withdata-collectorset tohttps://lumen.so, so that it posts through that site’s/_rcpath. It adopts the id, removes it from the address bar, and uses it foridentifyandtrackfor 7 days. Anrc_vidcookie there comes first. - The owner adds the domain once,
npx routingcat domains add lumen.app, since RoutingCat accepts browser calls only from your site’s domains.
Record a form with no backend
For a form your code never sees, such as a third-party waitlist form, the browser tracks on submit. Where your app has accounts of its own, track alone, routingcat.track('waitlist', { id: 'waitlist:<hash>' }), with the hex SHA-256 of the trimmed, lowercased email in the id, so each address counts once and your server’s identify keeps your own record ids. Where there are no accounts, link first: routingcat.identify() with that hash, then routingcat.track('waitlist', { id: 'waitlist:<hash>' }). The calls read the same in a Next.js client component and with the browser entry’s script tag on a page outside your app. Alternatively, put the rc_vid cookie’s value in a hidden field, and forward the form’s webhook to a backend that posts as above, which makes the conversion a server event.
Check that conversions reach their visits
-
The first event. After the deploy,
npx routingcat doctorshows the objective’s first event within a minute of it, with its source. Until one happens, the check waits, which is expected: the first real conversion passes it, a visitor’s or your own, and nothing else waits on it. Converting once yourself on your production site, as a visitor would, is the only end-to-end proof that a conversion reaches RoutingCat and joins its visit, so it is recommended, though where converting costs money, as an order does, you may prefer to skip it. That conversion is recorded as a real one. -
How they joined. After the next counts refresh,
npx routingcat statussums up the last 7 days on its tracking line:tracking signup 84 in 7 days · joined 52% (visitor 48%, account 5%) · no routed visit 36% · no key 5% · unlinked 1% · outside window 6%Share Means Fix joined, by visitor or account The conversion reached a visit that saw a routed page, through its visitor id or its account. Nothing. no routed visit Its visitor never saw a routed page. Nothing: it counts once its visitors do. no key It carried neither a visitor nor an account id. Pass visitorIdfrom therc_vidcookie, oraccountId(conversions with no key).unlinked Its account id was never linked to a visitor. Call identifyat sign-up and at every sign-in (accounts never linked).outside window It came after its visit’s window had closed. Nothing; a longer window costs learning speed, as Conversions and visits explains. statusalso names identify calls without a visitor id, by sending host, anddoctornames events under an undeclared name and the preview server key used in production. -
Alarms. The silence alarm fires when a page’s matured visits make zero conversions improbable, and the drop alarm when an objective’s daily count falls far below its recent days, often after a deploy that broke the call. Both are errors in
status: fix the call, then record a tracking gap.
Record a tracking gap
A tracking gap marks an interval when an outcome’s events weren’t tracked, such as after a deploy that removed a track call. Visits in that interval then count as unknown rather than as no conversion, so conversions that happened but went unrecorded don’t read as visits that failed to convert. Record one only when tracking actually broke. You record each gap yourself; the drop alarm suggests one.
Run npx routingcat gap <outcome> --from <time> [--to <time>] [--note <text>], as in npx routingcat gap signup --from 2026-10-10T08:00:00Z --to 2026-10-11T09:30:00Z:
| Part | What it takes |
|---|---|
<outcome> |
The objective, or a declared outcome, by its name in routingcat.config.ts, such as signup |
--from |
When tracking stopped. Required |
--to |
When it worked again. Leave it out while the gap is still open, then run the same command, with the same outcome and --from, adding --to, to close it |
--note |
Why, in at most 500 characters, quoted |
A time is an instant, such as 2026-10-10T08:00:00Z, a day, such as 2026-10-10, for its start at midnight UTC, or now. The gap applies to production results, and like every command that writes, gap takes --dry-run to show what it would record.