Coruve

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.

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 with role="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 its action points 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_vital readings, 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 — mailto or tel. The address itself is never read, so it can never be sent.
  • file_ext and file_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.uk sites 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.

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. What is stored is the button's description — its tag, its id and its classes — and the name you can rename. It is capped at 80 characters, never read from an input, a textarea or a select, and you can switch it off in Project Settings → Tracking.

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.

SwitchWire keyDefaultSnippet optionWhat it records
Form submissionsformOnautoFormsRecords that a form was sent, and how many fields it had. Never what was typed.
Outbound linksoutOnNone — dashboard onlyRecords the site a link points to. Just the site — never the full address.
File downloadsdlOnNone — dashboard onlyRecords the file type, and the file's name. Never the address it was behind.
Contact linkscontactOnNone — dashboard onlyRecords that an email or phone link was tapped. Never the address or the number.
Use the words on buttons to name themtextOffcollectTextWhen 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 pageshashOffhashRoutingTurn this on only if your site uses # for real navigation. On a site that uses # for tabs it inflates page views.
Three inputs in one order. A false written in your snippet is a decision you made about your own visitors and no dashboard switch overrules it. Otherwise the dashboard decides, once it has spoken — that is what lets a switch turn something ON without anyone editing HTML. Otherwise the compiled default applies, which is what a first page view on a fresh browser uses.

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

The tracker's copy of the switches decides what leaves the page, which is the cheaper and more private half. It is not the authoritative one: the switches are cached in a browser the visitor controls, and the response that carries them is readable by anyone with the project ID, which is public by design — it is in the snippet on your page. Ingest is what enforces them, and a batch that arrives carrying a field a project has since switched off is stripped, never rejected.

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:

custom-event.ts
// 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",
});
Don't stress about perfect naming — every new event name gets an AI-suggested human-readable label and a semantic category (signup, checkout, navigation, engagement…) on the dashboard's Events page. You approve, rename, or merge once, and every report uses the clean name.

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.

auth.ts
// 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:

UpgradeButton.tsx
export function UpgradeButton() {
  return (
    <button
      onClick={() => {
        window.Coruve?.track("upgrade_clicked", { source: "navbar" });
        // ...
      }}
    >
      Upgrade
    </button>
  );
}
The public API is exactly three methods — init, track and identify. Page views, including SPA route changes, are sent by the tracker itself; there is nothing to call and nothing to switch on.

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.

Terminal
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"
      }
    ]
  }'
Session-based metrics (bounce rate, duration, entry/exit pages) only count browser traffic — server-sent events are excluded from them by design, but appear in every event-based report and goal.

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.

LimitValue
Events per batch1–50
Request payload64 KB
Properties per event50
Property key length100 characters
Property value length1,000 characters
Property nesting depth5
Rate limit, per key2,000 requests/minute
Rate limit, per IP200 requests/minute

4. Handle the responses

  • 202 — accepted for processing. From /v1/batch the body is { success: true, capture }, where capture is your project’s current capture switches — a version number plus one 0/1 per switch. It is what the browser tracker caches; a server-side sender can ignore it. /v1/events and /v1/identify answer { success: true } with no capture object at all. A batch whose events a switch forbids still answers 202 — 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:

init-options.ts
_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,
});
OptionDefaultWhat it does
apiHost"" (required)Where events are sent. Left empty the tracker stays silent — nothing leaves the page.
autoPageViewtrueSends page_view on load and on every SPA route change.
autoClickstrueSends click for buttons, links, submit buttons and role="button" elements. Not for text inputs, selects or checkboxes.
autoFormstrueSends 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.
passiveClickstrueAlso sends $passive_click for clicks that hit nothing interactive. Capped at 10 per page view.
respectDNTtrueDisables the tracker entirely when Do Not Track or Global Privacy Control is on.
collectTextfalseReads 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.
hashRoutingfalseCounts 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.
offlineBuffertrueKeeps events that could not be sent and retries them, so a dropped connection is not lost data.
projectRouternullFor 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.
batchSize20Events per network request.
flushIntervalMs2000Longest a queued event waits before its batch is sent.
maxPayloadSizeKbytes64An event larger than this is dropped in the browser with a console warning rather than sent and rejected.
maxPropertyDepth5How deep nested property objects are walked before being cut.

apiHost is required

Without apiHost the tracker stays silent — nothing is sent anywhere. The snippet from your Settings page always includes it.
Events & configuration | Coruve