Tracking reference
Events & configuration
What Coruve captures on its own, how to send events of your own from the browser or your backend, and every option init() accepts.
On this page
- What's captured automatically
- Track custom events
- Single-page apps
- Server-side ingestion (REST)
- Configuration reference
All documentation
What's captured automatically
The tracker collects the essentials out of the box — no custom code required.
Autocaptured events
page_view— every page load, and every route change in single-page apps (React, Next.js, Vue…) via history API detection.click— clicks on buttons, links, submit buttons and elements withrole="button", carrying the element’s tag, id and first classes so each one can be named separately. Not text inputs, selects or checkboxes: typing is not clicking, and we never read what you typed.$passive_click— a click that hit nothing interactive, carrying its position and the element’s tag, id and classes but never any text; capped at 10 per page view. This is what powers dead-click detection and the click maps.form_submit— a form on the page was submitted, whether by clicking its button or by pressing Enter. It carries the form’s tag, id and classes, the path itsactionpoints at, and how many fields it has. Never a field’s name and never a field’s value: we count the fields, we do not read them.$scroll_depth— the maximum scroll percentage reached, attributed to the page you scrolled (sent when you leave it). Since tracker 1.2.0 one page view can send this more than once — only ever with a strictly larger value, and at most four times. Before that the first report latched, so a visitor who glanced at another tab at 25% and then came back and read the whole article was recorded as having read a quarter of it. The cost of the fix, stated rather than hidden: Click & scroll counts rows as its reports figure and Site health takes a p75 over the vitals rows, and neither takes a maximum per page view — so the extra lower rows pull both figures slightly down. The same four-report budget applies to the final$web_vitalreadings, and for the same reason.$web_vital— page-speed measurements: TTFB, FCP, LCP, INP, CLS. INP — how quickly the page reacts to taps and clicks — needs tracker 1.4.0 or later.
A click on a link also says what kind of link it was, as one extra property. Deliberately not three new event names: renaming part of click would have moved every figure you have already built on it.
link_scheme—mailtoortel. The address itself is never read, so it can never be sent.file_extandfile_name— a download. The file name is the basename only: no folders, no query string. A download hosted on another domain is a download, not an outbound click.link_host— an outbound link, by host. Never the full URL: a session token or an email address rides in one often enough to matter. Sub-domains of your own site are not outbound; “your site” is the last two parts of your hostname, which reads two unrelated.co.uksites as one.
The download extensions, printed rather than described: pdf xls xlsx doc docx txt rtf csv exe key pps ppt pptx 7z pkg rar gz zip avi mov mp4 mpeg mpg wmv midi mid mp3 wav wma dmg.
Put data-coruve-ignore on any element and nothing inside it is ever recorded — no click, no dead click, no form submission, no text. It is checked before anything else, including your own project settings. On <body> it makes a page report its page view and nothing more.
The three names beginning with $ are Coruve’s own measurements rather than actions your visitors took, and they are treated that way in both directions. None of them is ever billed: they are free, they never appear on your usage meter, and they never count against your monthly event allowance. And none of them appears in your Events figure, in Top events, in the live feed or in a CSV column called events — they are reported in Click & scroll, Frustration and Site health, which is what they are for. Anything else you send is your event, including a name of your own that happens to start with $. page_view, click and form_submit are things your visitors did, so they are reported and billed like any event you send yourself.
Button text is read to name the button, then forgotten
Captured context
Each event carries its page URL and path, referrer, UTM parameters (source, medium, campaign, term, content), viewport size, and a session ID kept in localStorage, shared across the visitor's tabs and expiring after 30 minutes of inactivity — no cookies. Traffic is classified at ingest into eight channels (direct, search, social, paid, email, ai, referral, internal — the last being a visit that arrived from your own site) and human vs. AI agent vs. crawler, which powers the Acquisition and AI Traffic reports with zero setup.
Six switches your dashboard controls, without editing the snippet
Since tracker 1.4.0 what Coruve is allowed to record is decided in Project Settings → Tracking, not by editing the snippet on your site. Three of the six have a snippet option as well; three have none and are only reachable from the dashboard.
A switch stops collection. It is not a filter that hides something still being stored — what a switch forbids is refused or deleted at ingest before the row is queued, so there is nothing to un-hide later. What you already collected stays and keeps counting, and each change is marked on your charts so a step in a number has a reason beside it.
Five of the six are enforced at ingest that way. “Count #anchors as pages” is the exception and is a different kind of switch: it changes the URL the tracker reports rather than adding a field, so there is nothing at ingest for it to strip — it takes effect in the browser only, on the timing described below.
| Switch | Wire key | Default | Snippet option | What it records |
|---|---|---|---|---|
| Form submissions | form | On | autoForms | Records that a form was sent, and how many fields it had. Never what was typed. |
| Outbound links | out | On | None — dashboard only | Records the site a link points to. Just the site — never the full address. |
| File downloads | dl | On | None — dashboard only | Records the file type, and the file's name. Never the address it was behind. |
| Contact links | contact | On | None — dashboard only | Records that an email or phone link was tapped. Never the address or the number. |
| Use the words on buttons to name them | text | Off | collectText | When Coruve gives a button a name, it reads the words on that button once, uses them to write the name, and then forgets them. The words are never stored, never shown in a report and never exported. |
| Count #anchors as pages | hash | Off | hashRouting | Turn this on only if your site uses # for real navigation. On a site that uses # for tabs it inflates page views. |
Precedence: your snippet's explicit false always wins
How a switch reaches a browser, and what enforces it
There is no config request at page load. Every POST /v1/batch answers 202 { success: true, capture }, and the tracker was already reading that response for its 5xx test — so the switches ride a response that existed. The object is a version number plus one 0/1 per switch under the short wire keys in the table above, cached in localStorage under _cv_cap_<projectId> and read synchronously at init() before the first page view. A response carrying a version that is not newer than the one already applied changes nothing, so a slow batch answered late cannot undo a switch.
The two other endpoints are deliberately asymmetric: /v1/events and /v1/identify answer 202 { success: true } with no capture object. They are the server-side paths, and a backend has no browser to configure.
Timing is asymmetric too, and the useful half is the strict one. Turning a capture on reaches a given visitor from their first flush — about two seconds into their first page view after the change — and every later page view immediately. Turning one off takes effect at ingest no matter what any tracker believes — at once, because saving the switch evicts ingest’s cached copy of your settings, and within five minutes at the outside if that eviction itself fails. For each of those five that is off, ingest refuses the event or strips the field before the queue job is created: a form_submit is dropped outright, and link_host, file_ext and file_name, link_scheme, or el_text and el_aria are deleted from the enriched event.
This is not a security boundary
Track custom events
Beyond automatic capture, track the actions that matter to your business.
Track an action
After the snippet loads, the tracker is available as window.Coruve (or via the _CORUVE queue, which is safe to call even before the script finishes loading). Pass an event name and optional properties:
// Basic event
Coruve.track("plan_upgraded");
// With properties (sanitized: max depth 5, payload capped at 64 KB)
Coruve.track("plan_upgraded", {
plan: "pro",
billing_cycle: "monthly",
source: "pricing_page",
});AI names your events for you
Identify users (optional)
If you have logged-in users, you can associate events with a stable user reference. Pass an opaque or hashed ID — never an email address or username. IDs are hashed again server-side before storage. Each call writes one row of its own, named $identify: a fourth name beginning with $, and unlike the three above it is a call your code made rather than Coruve's own measurement, so it is reported and billed like any event you send yourself. Its properties are the traits you passed, sanitized — the raw user reference is never among them.
// Use an opaque or hashed ID — never an email address
Coruve.identify("user_abc123", {
plan: "pro",
});Goals are retroactive
Any event you track can become a goal later. Define a goal on the Goals report and Coruve computes its full conversion history from events you already captured — you never lose data by defining goals late.
Single-page apps
Using Coruve from application code.
Call the tracker from your code
The snippet exposes the full API globally, so components can track events directly. Route changes are detected automatically — the tracker patches history.pushState and history.replaceState and listens for popstate, so you do not need to instrument your router:
export function UpgradeButton() {
return (
<button
onClick={() => {
window.Coruve?.track("upgrade_clicked", { source: "navbar" });
// ...
}}
>
Upgrade
</button>
);
}There is no pageview() method
Installing from application code
The typed npm package @coruve/tracker is built but not published yet, so the script snippet remains the supported install method for every framework. window.Coruve gives your code the same init / track / identify API the package will export, which means nothing you write today has to change when it ships.
Server-side ingestion (REST)
Send events from your backend with an API key — useful for signups, payments, and anything that doesn't happen in a browser.
1. Create an API key
Project Settings → API keys, which is its own section of the Settings rail. Ingest keys look like pk_live_… and can only write; read keys look like rk_live_… and can only read. The full key is shown once at creation, so store it in your server environment immediately. Keys can be revoked and re-created at any time. For sending events you want an ingest key.
2. POST events to /v1/batch
Authenticate with the X-Coruve-Key header. Between 1 and 50 events per request; each event needs a UUID (for deduplication), a name of at most 200 characters, and an anonymousId of your choosing, up to 512 characters (it is hashed server-side before storage). Single events can also be posted to /v1/events, and identify calls to /v1/identify — that last one writes a row of its own, named $identify, carrying the sanitized traits you sent and the hashed user reference. It is billed as one event, because it is a call your code made rather than Coruve's own instrumentation.
curl -X POST https://ingest.coruve.com/v1/batch \
-H "Content-Type: application/json" \
-H "X-Coruve-Key: pk_live_YOUR_KEY" \
-d '{
"events": [
{
"eventId": "1f0d9aa2-8f7e-4a57-9d3b-2b8f6f6d9c11",
"eventName": "subscription_started",
"anonymousId": "user_abc123",
"properties": { "plan": "pro" },
"timestamp": "2026-07-16T12:00:00Z"
}
]
}'Server events have no session
3. Stay inside the limits
Every limit below is enforced at the edge, before anything is stored — a request that breaks one is rejected with a 400 naming the field rather than being silently truncated.
| Limit | Value |
|---|---|
| Events per batch | 1–50 |
| Request payload | 64 KB |
| Properties per event | 50 |
| Property key length | 100 characters |
| Property value length | 1,000 characters |
| Property nesting depth | 5 |
| Rate limit, per key | 2,000 requests/minute |
| Rate limit, per IP | 200 requests/minute |
4. Handle the responses
202— accepted for processing. From/v1/batchthe body is{ success: true, capture }, wherecaptureis your project’s current capture switches — a version number plus one0/1per switch. It is what the browser tracker caches; a server-side sender can ignore it./v1/eventsand/v1/identifyanswer{ success: true }with nocaptureobject at all. A batch whose events a switch forbids still answers202— what the switch forbids is stripped, not rejected, because a tracker sending a field a project has since switched off has done nothing wrong and a 4xx would look like an outage on your own site.400— validation error (the response body lists exactly which field failed).401— missing or invalid key.429— you’ve hit your plan’s monthly event limit or a rate limit; the message says which.
Configuration reference
Everything init() accepts. Only apiHost is required.
Options and defaults
Pass options as the second argument to init:
_CORUVE.init("proj_YOUR_ID", {
apiHost: "https://ingest.coruve.com", // required — where events are sent
autoPageView: true, // capture page views + SPA route changes
autoClicks: true, // capture clicks on buttons, links, submit buttons
// and anything with role="button"
autoForms: true, // capture form submissions (descriptor + destination
// path + field COUNT — never a name, never a value)
passiveClicks: true, // also capture clicks that hit nothing clickable
// (position + selector only, max 10 per page view)
respectDNT: true, // disable fully when DNT or GPC is on
collectText: false, // read button text to NAME the button, then forget it
hashRouting: false, // only if "#" is real navigation on your site
offlineBuffer: true, // hold undeliverable events and retry them
allowedProperties: [], // [] means "allow all"; a list is a strict allowlist
batchSize: 20, // events per network request
flushIntervalMs: 2000, // max wait before a batch is sent
maxPayloadSizeKbytes: 64,
maxPropertyDepth: 5,
});| Option | Default | What it does |
|---|---|---|
| apiHost | "" (required) | Where events are sent. Left empty the tracker stays silent — nothing leaves the page. |
| autoPageView | true | Sends page_view on load and on every SPA route change. |
| autoClicks | true | Sends click for buttons, links, submit buttons and role="button" elements. Not for text inputs, selects or checkboxes. |
| autoForms | true | Sends form_submit when a form is submitted, by button or by Enter. Carries the form's descriptor, the path its action points at, and how many fields it has — never a field name, never a field value. Also governed by the Form submissions switch in Project Settings → Tracking; a false written here always wins. |
| passiveClicks | true | Also sends $passive_click for clicks that hit nothing interactive. Capped at 10 per page view. |
| respectDNT | true | Disables the tracker entirely when Do Not Track or Global Privacy Control is on. |
| collectText | false | Reads the words on a clicked element (max 80 characters) to name it in your dashboard, then forgets them — never stored, never reported, never exported: the stored row carries the structural descriptor and there is no column for the text. With the switch off the two fields are deleted at ingest before the job that would read them is created. Never read from an input, textarea or select. This is the compiled default only. Your project's Tracking settings can turn it on without editing the snippet, and a project created from tracker 1.4.0 onwards starts with that switch already on; a project older than 1.4.0 starts with it off and is asked first. A false written here always wins. |
| hashRouting | false | Counts each "#section" as its own page and keeps the fragment in the page-view URL. Turn it on only if your site uses "#" for real navigation — on a site that uses "#" for tabs or anchors it inflates page views. Also switchable from Project Settings → Tracking; a false written here always wins. |
| offlineBuffer | true | Keeps events that could not be sent and retries them, so a dropped connection is not lost data. |
| projectRouter | null | For one site that hosts more than one Coruve project (a marketing site and its app on one origin). A function called for every event with that event's own path; the project id it returns receives the event, and null drops the event. Sessions, visitor ids and the offline buffer are kept per project, and a session never spans projects. Unset, every event goes to the init() project — which is all a single-project site needs. |
| allowedProperties | [] | An empty list allows every top-level property. Naming properties turns it into a strict allowlist and drops the rest before they leave the browser. |
| batchSize | 20 | Events per network request. |
| flushIntervalMs | 2000 | Longest a queued event waits before its batch is sent. |
| maxPayloadSizeKbytes | 64 | An event larger than this is dropped in the browser with a console warning rather than sent and rejected. |
| maxPropertyDepth | 5 | How deep nested property objects are walked before being cut. |
apiHost is required