Skip to content

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. Auth providers and PostHog have guides of their own.

The two calls

RoutingCat’s integration gives your server code both calls on each request, in Astro.locals.routingcat, which an endpoint reads as locals.routingcat:

  • Astro.locals.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.
  • Astro.locals.routingcat.track(name, options) reports an event, such as signup or purchase.

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
An endpoint of your Astro site, or a page rendered at each request Astro.locals.routingcat.track and .identify. They read the visitor from the request’s rc_vid cookie, never throw, and send within the request’s lifetime, never delaying the response. They send with the RoutingCat credential your build received, so no key goes in your code.
Your pages, in the browser routingcat.track, routingcat.identify and routingcat.link from routingcat/tracker, in a page’s <script>, as Record a conversion in a page’s script shows. They post through your site’s /_rc path, from prerendered pages too.
Another backend, or a webhook it receives The HTTP events API, with the server events key.
  • A prerendered page’s server code sends nothing. It renders once, at build, for every visitor, so its calls to Astro.locals.routingcat send nothing, and say so once in the build’s log. Track from an endpoint, from a page with export const prerender = false, or from the page’s <script>.
  • Await identify, as the examples write it, and call track without await: it never throws, and never delays the response. RoutingCat joins a link and the events in whichever order they arrive. Even awaited, track resolves as soon as its send is handed off, so the response never waits for RoutingCat.

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 identify warns 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.
  • Server events carry an id: the provider’s event id, such as Stripe’s event.id, or signup:<account id>. A server track without one is logged once and dropped. Browser events get one generated. Providers deliver some events twice, and RoutingCat keeps one event per name and id, so a retry never counts twice.
  • Times: a server event keeps its occurredAt, a Date, an RFC 3339 string or a number of Unix seconds, and one more than five minutes ahead is rejected. Without it, the event takes the time of the call. A browser event takes the time RoutingCat receives it. A webhook can arrive hours late, and its conversion still counts when it happened.
  • 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 name routingcat.config.ts doesn’t declare is recorded, but not analyzed until declared.
  • Sources: each event is server, browser or posthog, and results show the objective’s source beside it. Astro.locals.routingcat‘s calls send server events, from your code with your build’s credential, and your pages’ scripts browser events. A browser can send anything, so an objective worth defending comes from your server.
  • Values: value with currency, an ISO 4217 code, is money in major units, 29.99 for $29.99, and revenue sums it per currency. Without currency it is a plain quantity, never revenue. Optional items list { id, quantity, unitValue }, unit_value over HTTP, at most 200. Revenue is reported; routing learns from the objective alone.
  • Environments: in development, the calls record nothing and log each call. A build that registered nothing records nothing either, and your server’s calls log each call. 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 your Astro site

Where your site creates the account, in an endpoint or a page rendered at each request, call await Astro.locals.routingcat.identify(user.id), then Astro.locals.routingcat.track('signup', { id: `signup:${user.id}` }). Identify again where your site first renders for 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 endpoint, which a sign-up form posts its address to at /api/waitlist, derives the event’s id from a hash of the address, so the same address joining twice counts once, and the address is never kept:

src/pages/api/waitlist.ts
// The waitlist's endpoint, rendered at each request, which a sign-up form posts its address to: it
// checks the address and keeps nothing, then tracks the conversion with `routingcat.track(…)`,
// `Astro.locals.routingcat`, which routingcat/astro's middleware gives every request, deduplicated
// per address without keeping it, by a hash of it. The call sends within the request's lifetime,
// never delaying the response, and never throws, so it is not awaited.
import type { APIRoute } from 'astro'
export const prerender = false
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const POST: APIRoute = async ({ request, locals }) => {
const body = (await request.json().catch(() => null)) as { email?: unknown } | null
const email = String(body?.email ?? '').trim()
if (!EMAIL.test(email)) {
return Response.json({ ok: false, message: 'Enter a valid email address.' }, { status: 400 })
}
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(email.toLowerCase()),
)
const id = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
locals.routingcat.track('waitlist', { id: `waitlist:${id}` })
return Response.json({ ok: true, message: 'You are on the list.' })
}
  • The calls send within the request’s lifetime: on Cloudflare Workers, through the request’s waitUntil; on Vercel, through the one its functions runtime gives each request.
  • The visitor is the rc_vid cookie the request carries, which RoutingCat sets on your pages. On a page rendered at each request, even a new visitor’s first request carries it, since RoutingCat’s middleware or Worker adds it before Astro renders the page.
  • On Vercel, RoutingCat’s middleware leaves /api paths alone, and on Workers its Worker hands them to Astro undecided, so an endpoint answers as it would without RoutingCat. Its calls work all the same.

Record a conversion in a page’s script

A conversion only the browser sees, such as a click, is tracked in the page’s own <script>, which Astro bundles: import routingcat from routingcat/tracker, then call routingcat.track(…), routingcat.identify(…) or routingcat.link(…), with no request to pass. From RoutingCat’s browser tests, a page, src/pages/events.astro, that tracks and identifies on a click:

---
// …
import { RoutingCat } from 'routingcat/astro/components'
---
<html lang="en">
<head>
<meta charset="utf-8" />
<RoutingCat />
…
<script>
import { routingcat } from 'routingcat/tracker'
const click = (test: string, call: () => unknown) =>
document.querySelector(`[data-test=${test}]`)?.addEventListener('click', () => void call())
click('track', () =>
routingcat.track('signup', { id: 'signup:acct_42', properties: { plan: 'pro' } }),
)
click('identify', () => routingcat.identify('acct_42'))
…
</script>
  • The page carries <RoutingCat />, as every page rendering through your layout does: its script brings your site’s public key, which the calls send with. On a page without it, they send nothing, and say so in the browser’s console. Build a page says where it goes.
  • They work on prerendered pages too, since they run in the visitor’s browser, and read the visitor from the rc_vid cookie.
  • A browser event’s id is generated when you give none, and its time is when RoutingCat receives it.

Record it from an app on a subdomain

The rc_vid cookie is set on your registrable domain, so a backend on app.lumen.so reads the visitor id of lumen.so from its requests, and posts as the next section shows.

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/events
Authorization: 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, never visitorId. An event with a field the API doesn’t take is refused, and the answer names the field meant; your own data goes in properties.
  • 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:

  1. On lumen.so, routingcat.link(url) from routingcat/tracker, in a page’s <script>, adds the visitor id to a link that crosses.
  2. On lumen.app, load the browser entry with data-collector set to https://lumen.so, so that it posts through that site’s /_rc path. It adopts the id, removes it from the address bar, and uses it for identify and track for 7 days. An rc_vid cookie there comes first.
  3. 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 page tracks on submit, in its <script>, with routingcat from routingcat/tracker. 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').

Alternatively, make the conversion a server event. A webhook reaches your code without the visitor’s cookie, so the visitor id travels with it:

  • Put the rc_vid cookie’s value in what you hand the third party, such as a hidden field of its form or its checkout’s metadata. Your page’s script can read the cookie.
  • Receive the third party’s webhook in an endpoint, and pass the id it carries back: locals.routingcat.identify(accountId, { visitorId }), and locals.routingcat.track(name, { id, visitorId }), with the submission’s own id as id. With visitorId named, the calls take it in place of the request’s cookie.

Check that conversions reach their visits

  • The first event. After the deploy, npx routingcat doctor shows 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.

  • A server call that sends nothing says why on a line starting routingcat:: in your server’s logs for a track without an id or a build that registered nothing, and in the build’s log for a call from a prerendered page.

  • How they joined. After the next counts refresh, npx routingcat status sums 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. From a request with no visitor cookie, such as a webhook’s, pass visitorId, or accountId (conversions with no key).
    unlinked Its account id was never linked to a visitor. Call identify at 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.

    status also names identify calls without a visitor id, by sending host, and doctor names 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.

Next