[invisit](https://invisit.dev) · [Docs](https://invisit.dev/docs) · [llms.txt](https://invisit.dev/llms.txt) · [llms-full.txt](https://invisit.dev/llms-full.txt) # invisit documentation > Cookieless web analytics that shows who visited, how they found you (ChatGPT and other AI assistants included) and what they did next. Free to start. All 17 pages in reading order, each starting with its title, a one-line summary and its address. The index is https://invisit.dev/llms.txt. --- # Documentation > Install invisit, record the events that matter, connect revenue and read your analytics from an assistant or the API. Source: https://invisit.dev/docs invisit is web analytics that shows you the people behind your traffic: who stopped by, how they found you and what they did next. It counts visitors without cookies, follows single-page apps on its own, and keeps your data forever on every plan. ## Start here - [Quickstart](https://invisit.dev/docs/getting-started/quickstart.md): from a new account to your first visitor in about five minutes. - [Script tag](https://invisit.dev/docs/web/installation.md#script-tag) or [npm package](https://invisit.dev/docs/web/installation.md#npm-package): the two ways to put the tracker on a site. - [Web frameworks](https://invisit.dev/docs/web/frameworks.md): where the one line goes in Next.js, Nuxt, SvelteKit, Astro and the rest. ## Go further - [Custom events](https://invisit.dev/docs/guides/events.md): signups, purchases and anything else worth counting, from code or with HTML attributes alone. - [First-party proxy](https://invisit.dev/docs/server/sdk.md): serve the tracker from your own domain with `@invisitdev/server`. - [Revenue](https://invisit.dev/docs/guides/revenue.md): connect Stripe, Polar, Paddle, Dodo Payments, Creem or Lemon Squeezy and read gross, refunds, fees and net beside your traffic. - [MCP](https://invisit.dev/docs/api/mcp.md): ask Claude, Cursor or any MCP client about your traffic. - [REST API](https://invisit.dev/docs/api/rest.md): every report and setting over HTTP with a site API key. ## What gets measured Page views, custom events and conversions, sources and UTM campaigns, AI referrals, countries and cities, devices, browsers, web vitals, outbound links and file downloads, heatmaps, funnels, crawler reads, short-link clicks and revenue. Every visitor report can be narrowed by any of them. > [!TIP] > These docs are built for assistants as well as people. Every page is Markdown at its address plus `.md`, the whole set is at [/llms-full.txt](https://invisit.dev/llms-full.txt), and the **Copy page** button above puts this page on your clipboard as Markdown. --- # Quickstart > Create a site, add one script tag, and watch your first visitor arrive in the realtime view. About five minutes, no build step needed. Source: https://invisit.dev/docs/getting-started/quickstart ## 1. Create a site [Sign up](https://app.invisit.dev/auth) with email, Google or GitHub. Your account starts with a workspace of its own; add a site to it with the domain it runs on, such as `example.com`. ## 2. Add the tracker Open **Settings → Installation** for the site. It shows your **site id** and a snippet with it filled in. Paste the snippet into the `` of every page: ```html title="index.html" ``` Using a bundler or a framework? Install the package instead and call `init()` once in the browser: ```sh title="Terminal" npm install @invisitdev/web ``` ```ts title="src/main.ts" import { init } from '@invisitdev/web' init({ site: '' }) ``` > [!NOTE] > The site id is public and never changes. It is safe in client code and in `PUBLIC_` or `NEXT_PUBLIC_` environment variables. ## 3. Check that it works Deploy, open your site in a new tab, then open **Realtime** in the dashboard. Your visit shows up within a few seconds. If nothing arrives, **Settings → Blocked traffic** lists what the collector refused for the site over the last 14 days, and why. The most common cause is a domain missing from **Settings → Collection**. Ad blockers that block third-party analytics hosts can hide your own visit. Add the [first-party proxy](https://invisit.dev/docs/server/sdk.md) to measure those visitors too. ## Next steps - [Record custom events](https://invisit.dev/docs/guides/events.md) such as signups and purchases. - [Link visits to your users](https://invisit.dev/docs/guides/events.md#identify-signed-in-users) once they sign in. - [Exclude your own visits](https://invisit.dev/docs/guides/privacy.md#excluding-traffic) and internal pages. - [Connect revenue](https://invisit.dev/docs/guides/revenue.md) from your payment provider. --- # Support > How to reach the people behind invisit about your account, billing, tracking, the API or an AI assistant, and where to report a security issue. Source: https://invisit.dev/docs/getting-started/support Write to **[help@invisit.dev](mailto:help@invisit.dev)**. A person reads every message. Say which site and workspace it is about, and for a tracking question the page address you tested; never send a password, an API key or a personal token. ## Before you write - **Nothing arrives in the dashboard.** The [quickstart](https://invisit.dev/docs/getting-started/quickstart.md) checks the snippet step by step, and **Settings → Installation** shows whether the site is receiving events. - **Plans and invoices.** [Billing](https://invisit.dev/docs/guides/billing.md) covers changing and canceling a plan; the workspace's **Billing** tab lists every charge with its PDF invoice. - **Members and roles.** [Workspaces](https://invisit.dev/docs/guides/workspaces.md). - **The REST API.** [REST API](https://invisit.dev/docs/api/rest.md), with the full reference at [api.invisit.dev/openapi](https://api.invisit.dev/openapi). ## AI assistants (MCP) invisit connects to Claude, ChatGPT, Codex and other MCP clients; [MCP](https://invisit.dev/docs/api/mcp.md) covers setup and every tool. - **The assistant says it lacks access.** The connection was granted read, write or manage when you connected. Disconnect it under **Account → Connected apps** and connect again with the access you want. - **It stopped working after a while.** Remove the connection in the assistant and add it again; signing in renews it. - **Remove an assistant's access.** **Account → Connected apps** revokes a connection at once. ## Security and privacy - Security issues: **[security@invisit.dev](mailto:security@invisit.dev)**. - Personal data and data requests: **[privacy@invisit.dev](mailto:privacy@invisit.dev)**, as the [privacy policy](/legal/privacy) describes. --- # Installation > Put the tracker on your site with one script tag and no build step, or with the typed, SSR-safe @invisitdev/web package. Source: https://invisit.dev/docs/web/installation There are two ways to install invisit, and they run the same tracker built from the same source. Pick the **script tag** when there is no build step: static HTML, WordPress, Shopify, Framer. It works in bundled apps too. Pick the **npm package** when you want `track()`, `identify()` and `consent()` as typed imports and the tracker bundled with your app. Both need your **site id**, from **Settings → Installation** in the dashboard. It is public and never changes, so it is safe in client code and in `PUBLIC_` or `NEXT_PUBLIC_` environment variables. ## Script tag ```html title="index.html" ``` The tracker loads asynchronously and never blocks the page. The tag always serves the current tracker release, so updates reach your site with no change on your side. Where it goes on WordPress, Shopify and Framer is in the [platform guides](https://invisit.dev/docs/web/frameworks.md#wordpress). > [!IMPORTANT] > Keep `data-collector`. Unlike the npm package, the script has no default collector: without the attribute it sends events to your own origin, which only works behind the [first-party proxy](https://invisit.dev/docs/server/sdk.md). ## npm package ```sh title="Terminal" npm install @invisitdev/web ``` Or with `pnpm add`, `yarn add` or `bun add`. Then call `init()` once, in the browser, as early as your app allows: ```ts title="src/main.ts" import { init } from '@invisitdev/web' init({ site: '' }) ``` That is the whole setup. `init()` sends the first page view, follows client-side navigation from then on, and returns the running tracker. The [web frameworks](https://invisit.dev/docs/web/frameworks.md) show where that line goes in each framework. The package's optional parts (web vitals, no-code rules, the feedback widget, the heatmap overlay) are dynamic imports, so your bundler splits them into your own files and the page never requests a script from a third-party host. > [!NOTE] > Importing the package touches no browser globals, and every function is a silent no-op where there is no `window`. Import it from shared or server code freely: it only does something once `init()` runs in the browser. Calls made before `init()` are queued and applied once it starts, so an `identify()` in your auth code can safely run first. ## Options Each option is an `init()` field, or a `data-*` attribute on the script tag. Boolean attributes take `true` or `false`. | Option | Attribute | Default | What it does | | ---------------- | ---------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `site` | `data-site` | required | The site id. | | `baseUrl` | `data-collector` | `https://api.invisit.dev`, none for the script | The collector, or a [proxy](https://invisit.dev/docs/server/sdk.md) path such as `/_i`. | | `requireConsent` | `data-require-consent` | `false` | Sends nothing until consent is granted. See [Privacy](https://invisit.dev/docs/guides/privacy.md#requiring-consent). | | `storage` | `data-storage` | `auto` | `none` keeps nothing at all on the device. | | `excludePaths` | `data-exclude-paths` | none | Paths never measured. See [Excluding traffic](https://invisit.dev/docs/guides/privacy.md#excluding-traffic). | | `outboundLinks` | `data-outbound-links` | `true` | Records clicks on links to other sites. | | `fileDownloads` | `data-file-downloads` | `true` | Records clicks on file downloads. | | `respectGpc` | `data-respect-gpc` | `false` | Turned on, stops for browsers that send Global Privacy Control. | | `respectDnt` | `data-respect-dnt` | `false` | Turned on, stops for browsers that send Do Not Track. | | `debug` | `data-debug` | `false` | Logs delivery problems to the console, and lets a browser driven by automation send events, so you can test your setup. | Everything else (allowed domains, redacted query keys, web vitals, heatmaps, no-code event rules, the feedback widget) is set per site in the dashboard and fetched by the tracker when it starts, so changing it needs no redeploy. ## Group dynamic paths Where ids or names sit in the path, such as `/orders/8121` or `/acme/settings`, every one of them is its own row in the Pages report. `pageUrl` rewrites each address before it is recorded, so one route is one row: ```ts title="src/main.ts" init({ site: '', pageUrl: (href) => href.replace(/\/orders\/\d+/, '/orders/:id'), }) ``` It gets the full URL and returns a full URL; query parameters and the fragment are redacted afterwards as usual. With the script tag, queue the same function on the `invisit` stub before the script loads: ```html title="index.html" ``` Two addresses that rewrite to the same one count as the same page, so moving between them records no new page view. A heatmap is drawn over the page opened at its recorded address, and `/orders/:id` opens no page. So clicks are not collected where `pageUrl` changes the path, and those pages have no click heatmap. Rewriting only the query keeps it. ## Both on one page If a page has both the script tag and the package, whichever starts first wins and the other reuses it. A visit is never counted twice. ## Single-page apps Nothing to add. The tracker follows client-side navigation in every router and records a page view for each new address. The fragment is not part of an address, so a router that changes only `#/path` records one page view, not one per route. ## Browser support Chrome 91, Edge 91, Firefox 90, Safari 15 and later. --- # Web frameworks > Where the tracker goes in Next.js, TanStack Start, Nuxt, SvelteKit, Astro, Remix, Waku, Vite, Angular, WordPress, Shopify, Framer and more. One line, once. Source: https://invisit.dev/docs/web/frameworks There are no wrapper packages: call `init()` from [`@invisitdev/web`](https://invisit.dev/docs/web/installation.md#npm-package) wherever your framework runs code once in the browser. Each snippet shows only what to add to the file your scaffold generated; keep the rest of it. The [script tag](https://invisit.dev/docs/web/installation.md#script-tag) works in every framework too: put it in the root layout's ``. It is the same tracker, and it follows client-side navigation on its own. The package is the better fit when you want `track()`, `identify()` and `consent()` as typed imports. Hosted site builders (WordPress, Shopify, Framer) have no build step you control, so they take the script tag. Their sections are at the end of this page. To also serve the tracker from your own domain and record crawler reads, add the server side from the [server frameworks](https://invisit.dev/docs/server/frameworks.md). ## Next.js A client component rendered inside `` in the root layout (App Router): ```tsx title="app/analytics.tsx" 'use client' import { useEffect } from 'react' import { init } from '@invisitdev/web' export function Analytics() { useEffect(() => { init({ site: process.env.NEXT_PUBLIC_INVISIT_SITE! }) }, []) return null } ``` ```tsx title="app/layout.tsx" import { Analytics } from './analytics' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` With the Pages Router, the same `useEffect` goes in `pages/_app.tsx`. ## TanStack Start A component that starts the tracker once it is in the browser: ```tsx title="src/components/analytics.tsx" import { useEffect } from 'react' import { init } from '@invisitdev/web' export function Analytics() { useEffect(() => { init({ site: import.meta.env.VITE_INVISIT_SITE }) }, []) return null } ``` Render `` beside `` in the `RootDocument` of `src/routes/__root.tsx`, so every route has it. ## Nuxt A client-only plugin. Nuxt 4 reads `app/plugins/`; on Nuxt 3 the folder is `plugins/`. ```ts title="app/plugins/invisit.client.ts" import { init } from '@invisitdev/web' export default defineNuxtPlugin(() => { init({ site: useRuntimeConfig().public.invisitSite }) }) ``` ## SvelteKit In the root layout. On SvelteKit 2, import `browser` from `$app/environment` instead. ```svelte title="src/routes/+layout.svelte" {@render children()} ``` ## Astro A ` ``` ## React Router and Remix The same `useEffect` as for Next.js goes in the default `App` export of `app/root.tsx`. Keep `Layout`, `links` and `ErrorBoundary`: `Layout` renders ``, without which the page never hydrates. ## Waku A client component, rendered by the root layout. Waku hands client components only variables named `WAKU_PUBLIC_*`. ```tsx title="src/components/analytics.tsx" 'use client' import { useEffect } from 'react' import { init } from '@invisitdev/web' export function Analytics() { useEffect(() => { init({ site: import.meta.env.WAKU_PUBLIC_INVISIT_SITE }) }, []) return null } ``` ```tsx title="src/pages/_layout.tsx" import type { ReactNode } from 'react' import { Analytics } from '../components/analytics' export default async function RootLayout({ children }: { children: ReactNode }) { return ( <> {children} ) } ``` ## SolidStart In the client entry: ```tsx title="src/entry-client.tsx" import { mount, StartClient } from '@solidjs/start/client' import { init } from '@invisitdev/web' init({ site: '' }) mount(() => , document.getElementById('app')!) ``` ## Vite A Vite app (React, Vue, Svelte, Solid or none of them) starts in its entry module (`src/main.tsx`, `src/main.ts`), before the first render or `app.mount()`. React Router in framework mode is set up differently: see [React Router and Remix](#react-router-and-remix). ```ts title="src/main.ts" import { init } from '@invisitdev/web' init({ site: import.meta.env.VITE_INVISIT_SITE }) ``` To serve the tracker from your own domain too, add the server side to whatever serves the built app; see the [server frameworks](https://invisit.dev/docs/server/frameworks.md). ## Angular On Angular 19 and later, an app initializer next to the providers the CLI generated. With SSR, keep the `provideClientHydration(…)` the CLI added. ```ts title="src/app/app.config.ts" import { ApplicationConfig, PLATFORM_ID, inject, provideAppInitializer, provideBrowserGlobalErrorListeners, } from '@angular/core' import { isPlatformBrowser } from '@angular/common' import { provideRouter } from '@angular/router' import { init } from '@invisitdev/web' import { routes } from './app.routes' export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), provideAppInitializer(() => { if (isPlatformBrowser(inject(PLATFORM_ID))) init({ site: '' }) }), ], } ``` ## Gatsby, Docusaurus and VitePress ```ts title="gatsby-browser.tsx" import type { GatsbyBrowser } from 'gatsby' import { init } from '@invisitdev/web' export const onClientEntry: GatsbyBrowser['onClientEntry'] = () => { init({ site: '' }) } ``` Docusaurus takes a client module, registered with `clientModules: ['./src/invisit.ts']` in `docusaurus.config.ts`: ```ts title="src/invisit.ts" import { init } from '@invisitdev/web' init({ site: '' }) ``` VitePress runs the theme's `enhanceApp` during the build too, hence the `inBrowser` guard: ```ts title="docs/.vitepress/theme/index.ts" import DefaultTheme from 'vitepress/theme' import { inBrowser, type Theme } from 'vitepress' import { init } from '@invisitdev/web' export default { extends: DefaultTheme, enhanceApp() { if (inBrowser) init({ site: '' }) }, } satisfies Theme ``` ## WordPress Print the script tag into every page's head from your child theme's `functions.php`, so a theme update never removes it: ```php title="wp-content/themes/my-child-theme/functions.php" add_action('wp_head', function () { // Leave out your own visits while you are signed in to edit the site. if (current_user_can('edit_posts')) { return; } ?> ` tag into the site header with a code snippets plugin instead. Clicks become [events](https://invisit.dev/docs/guides/events.md#without-javascript) with `data-invisit-event` attributes, which the block editor takes in a Custom HTML block. WordPress runs PHP, where `@invisitdev/server` does not. To serve the tracker from your own domain and record crawler reads, put the [Cloudflare Worker](https://invisit.dev/docs/server/frameworks.md#cloudflare-workers) in front of the site. ## Shopify In Shopify admin, open **Online Store → Themes**, then **… → Edit code** on your live theme. Open `layout/theme.liquid` and paste the tag right before ``. Wrap it in `request.design_mode` so your own visits in the theme editor are not counted: ```liquid title="layout/theme.liquid" {% unless request.design_mode %} {% endunless %} ``` Save, and every storefront page is counted from its next load. Checkout pages are served by Shopify and do not load theme code, so they stay unmeasured. The tag lives in the theme, so paste it again when you switch to another theme. ## Framer Open **Project Settings → Custom Code** and choose **Add Script**. Paste the tag, place it at the **end of ``** and set it to run **once**. Publish the site. Page changes inside Framer are client-side navigation, and the tracker counts each one without a reload, so it only needs to run once. ## No framework Use the [script tag](https://invisit.dev/docs/web/installation.md#script-tag). It is the same tracker. 1. Open the template every page shares, or each HTML file if there is none. 2. Paste the tag anywhere inside ``. Last is fine: it loads async and never blocks the page. 3. Deploy. The next page load is counted. --- # Server SDK > @invisitdev/server serves the tracker from your own domain, records crawler reads and sends events from your backend, on any runtime. Source: https://invisit.dev/docs/server/sdk [`@invisitdev/server`](https://invisit.dev/docs/reference/server.md) is one client with three jobs: - **First-party proxy.** Serves the tracker and its endpoints from your own domain, under `/_i` by default. Blocklists that match analytics by third-party host never see one, and the collector uses each visitor's real address instead of your server's. - **Crawler reads.** Records search engines, AI crawlers and link previews, which never run JavaScript. - **Server-side events.** `track()` from a route handler, attributed to the visit the visitor's browser started. It is built on web-standard `Request`, `Response` and `fetch`, so it runs unchanged on Node, Bun, Deno, Cloudflare Workers and Vercel or Netlify Edge. It needs Node 20 or later, or any runtime with `fetch` and Web Crypto. ## Setup In the dashboard, open **Settings → API keys** and create a key with **only the ingest scope**. It starts with `invisit_sk_`. Store it as a server-side environment variable, then create the client: ```sh title="Terminal" npm install @invisitdev/server ``` ```ts title="lib/invisit.ts" import { createInvisit } from '@invisitdev/server' export const invisit = createInvisit({ key: process.env.INVISIT_API_KEY! }) ``` > [!WARNING] > The API key is a secret. Never put it in client code or in a `PUBLIC_` or `NEXT_PUBLIC_` variable. An ingest-only key can send events for your site but never read them, which is why it should carry no other scope. `createInvisit` throws when the key is missing or is not an `invisit_sk_` key, so a missing variable fails at start instead of quietly dropping events. ## Proxy Call `invisit.handle(request)` at the top of your middleware. It answers tracker requests under `/_i`, records crawler reads in the background, and resolves to `null` for everything else so your app answers it: ```ts title="src/middleware.ts" const response = await invisit.handle(request) if (response) return response ``` The [server frameworks](https://invisit.dev/docs/server/frameworks.md) show exactly where this goes in each framework. Then point the browser at the proxy, with the script tag: ```html title="index.html" ``` or with the npm package, `init({ site: '', baseUrl: '/_i' })`. To use another path, pass `{ prefix: '/stats' }` to `handle` and load `/stats/invisit.js` with `data-collector="/stats"`. The proxy forwards only what the collector reads: user agent, language, origin, referer, Sec-Fetch headers and client hints. Cookies, `Authorization` and `Host` stay on your side, `Set-Cookie` from upstream is dropped, and bodies are capped at 1 MiB. Requests to the collector time out after 10 seconds, so a slow collector never holds your response for long. ## Visitor address The collector needs each visitor's real address. The default reader checks these headers in order and uses the first one present: `CF-Connecting-IP`, `X-Real-IP`, `Fly-Client-IP`, `True-Client-IP`, `X-Vercel-Forwarded-For`, then the first entry of `X-Forwarded-For`. > [!IMPORTANT] > Those headers are only trustworthy when the platform in front of you sets or overwrites them, as Cloudflare, Vercel, Netlify and Fly do. If your server is reachable directly, a visitor can write any address there. Pass the address yourself with `ip`, or your own reader as `createInvisit({ key, clientIp: (request) => … })`. A proxied request with no readable address counts as coming from your server, and `onError` is told once with `CLIENT_IP_UNRESOLVED`. On Node, the adapters use the socket address and drop forwarding headers unless you pass `{ trustProxy: true }`. ## Crawler reads Search engines, AI crawlers and link-preview bots fetch your pages without running JavaScript, so no browser tracker sees them. `handle` already records them; without it, call `crawlers` from your middleware: ```ts title="src/middleware.ts" void invisit.crawlers(request, { status: response.status }) ``` It never rejects; failures go to `onError`. On serverless platforms, keep the request alive with `waitUntil` so it is not frozen before the read is sent. A request is recorded when its user agent looks like a crawler (per [`isbot`](https://www.npmjs.com/package/isbot), checked again by the collector), the method is GET or HEAD, it is a document request rather than a framework data request, and the path is not a static asset. `robots.txt` and sitemaps are kept. `handle` runs before your app, so it records every read as 200. To record what your server actually answered, such as the 404s a crawler keeps hitting, use `proxy` before your app and `crawlers` after it, as the [Hono](https://invisit.dev/docs/server/frameworks.md#hono) and [Astro](https://invisit.dev/docs/server/frameworks.md#astro) guides do. Crawler reads are free on every plan, up to a fair-use line of three times the plan's events. See [Billing](https://invisit.dev/docs/guides/billing.md#what-counts-as-an-event). ## Server-side events When the thing worth counting happens on your server, send it from there. The event joins the visit the visitor's browser started, so it shows up with the right source, page and country. ```ts title="app/api/checkout/route.ts" import { invisit } from '@/lib/invisit' export async function POST(request: Request) { // … the purchase await invisit .track('purchase', { request, url: 'https://shop.example.com/checkout', properties: { plan: 'pro', amount: 49 }, userId: user.id, }) .catch(console.error) // a lost event must not fail the purchase return Response.json({ ok: true }) } ``` > [!IMPORTANT] > `request` must be **the visitor's own request**, sent from their browser through your server. Its address, user agent, `Accept-Language` and `Sec-GPC` are what tie the event to the visit. A webhook from a payment provider is not a visitor's request. - **Idempotency.** Every event gets a UUID v7 id. Pass your own `eventId` to make retries safe: the same id again resolves as a `duplicate`, not a second event. - **Bots and opt-outs.** A crawler user agent resolves to `{ accepted: 0 }`, exactly as in the browser. So does a visitor sending `Sec-GPC: 1`, when `createInvisit` has `respectGpc` on. - **Never fail your route on analytics.** `track` rejects on any failure with an [`InvisitError`](https://invisit.dev/docs/reference/server.md#errors). Always catch it where you call it. Where there is no request of the visitor's to hand over, such as a background job or a [Convex action](https://invisit.dev/docs/server/frameworks.md#convex), pass what was kept of it as `visitor: { ip, userAgent }` instead. Every option of `track` is in the [reference](https://invisit.dev/docs/reference/server.md#track). --- # Server frameworks > The invisit proxy and crawler reads in Next.js, TanStack Start, Nuxt, Nitro, Hono, Elysia, Effect, Express, NestJS, Fastify, Workers and more. Source: https://invisit.dev/docs/server/frameworks Every guide calls [`@invisitdev/server`](https://invisit.dev/docs/server/sdk.md) from your middleware and assumes the client from [its setup](https://invisit.dev/docs/server/sdk.md#setup) lives in `lib/invisit.ts`, or your framework's equivalent. Where no edge in front of the server sets the visitor's address in a header (a bare Bun, Deno or Node server), the guides pass it with `ip`. Once the server side is in, point the browser at it with `init({ site: '', baseUrl: '/_i' })`, or `data-collector="/_i"` on the script tag. ## Next.js In the proxy file. That is Next.js 16; before it, the file is `middleware.ts` with `export async function middleware`. ```ts title="proxy.ts" import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server' import { invisit } from './lib/invisit' export async function proxy(request: NextRequest, event: NextFetchEvent) { const response = await invisit.handle(request, { waitUntil: (p) => event.waitUntil(p) }) return response ?? NextResponse.next() } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] } ``` If you already have one, call `invisit.handle` at its top and return its response when there is one. It runs before the page renders, so crawler reads are recorded as 200. ## TanStack Start One global request middleware does both jobs: it answers tracker requests itself and records crawler reads with the status each page got. ```ts title="src/start.ts" import { createMiddleware, createStart } from '@tanstack/react-start' import { getRequestIP } from '@tanstack/react-start/server' import { invisit } from './lib/invisit' const invisitMiddleware = createMiddleware().server(async ({ request, next }) => { const proxied = await invisit.proxy(request, { ip: getRequestIP() }) if (proxied) return proxied const result = await next() void invisit.crawlers(request, { status: result.response.status }) return result }) export const startInstance = createStart(() => ({ requestMiddleware: [invisitMiddleware] })) ``` `src/start.ts` is not in the default template; create it beside `src/router.tsx`. ## Nuxt In a Nitro middleware. Create the client in `server/utils/invisit.ts` from `useRuntimeConfig().invisitApiKey`, set from `NUXT_INVISIT_API_KEY`. ```ts title="server/middleware/invisit.ts" import { invisit } from '../utils/invisit' export default defineEventHandler(async (event) => { if (event.path.startsWith('/_i/')) { const ip = getRequestIP(event, { xForwardedFor: true }) return (await invisit.proxy(toWebRequest(event), { ip })) ?? undefined } // Nuxt's own asset and island requests are not page reads. if (event.method === 'GET' && !/^\/_{1,2}nuxt/.test(event.path)) { event.waitUntil(invisit.crawlers(toWebRequest(event))) } }) ``` `getRequestIP(event, { xForwardedFor: true })` trusts `X-Forwarded-For`, which is only right behind a proxy that overwrites it; drop the option if visitors can reach the server directly. ## Nitro On Nitro 3, a middleware file. `event.req` is already a web `Request`; returning the proxied response ends the request there, and anything else lets it go on. ```ts title="middleware/invisit.ts" import { defineHandler } from 'nitro' import { getRequestIP } from 'nitro/h3' import { invisit } from '../lib/invisit' export default defineHandler(async (event) => { const proxied = await invisit.proxy(event.req, { ip: getRequestIP(event, { xForwardedFor: true }), }) if (proxied) return proxied if (event.req.method === 'GET') event.waitUntil(invisit.crawlers(event.req)) }) ``` Nitro middleware runs before the route, so crawler reads are recorded as 200. On Nitro 2, use the Nuxt middleware above: Nuxt runs on it. ## SvelteKit In the handle hook. Written for SvelteKit 3, with the key declared in `src/env.ts` and imported from `$app/env/private`; on SvelteKit 2, import the key from `$env/static/private`, `Handle` from `@sveltejs/kit` and the client from `$lib/server/invisit`. ```ts title="src/hooks.server.ts" import type { Handle } from '@sveltejs/kit/hooks' import { invisit } from '#lib/server/invisit.ts' export const handle: Handle = async ({ event, resolve }) => { const proxied = await invisit.proxy(event.request, { ip: event.getClientAddress() }) if (proxied) return proxied const response = await resolve(event) if (!event.isDataRequest) void invisit.crawlers(event.request, { status: response.status }) return response } ``` > [!WARNING] > With adapter-node, set `kit.paths.origin` (SvelteKit 3) or the `ORIGIN` variable (SvelteKit 2) when serving over plain HTTP or behind a proxy that changes `Host`. Otherwise SvelteKit's CSRF check answers the tracker's POSTs with a 403. ## Astro A middleware, which needs on-demand rendering: an adapter, and `output: 'server'` or `prerender = false` on the pages it covers. Astro also runs middleware while it prerenders pages at build time, where the key is usually not set, so create the client on first use: ```ts title="src/lib/invisit.ts" import { createInvisit, type Invisit } from '@invisitdev/server' let client: Invisit | undefined export const invisit = (): Invisit => (client ??= createInvisit({ key: process.env.INVISIT_API_KEY! })) ``` ```ts title="src/middleware.ts" import { defineMiddleware } from 'astro:middleware' import { invisit } from './lib/invisit' export const onRequest = defineMiddleware(async (context, next) => { if (context.isPrerendered) return next() const proxied = await invisit().proxy(context.request, { ip: context.clientAddress }) if (proxied) return proxied const response = await next() void invisit().crawlers(context.request, { status: response.status }) return response }) ``` > [!WARNING] > A prerendered 404 page, which is Astro's default, answers `/_i/*` before the middleware runs. Add `export const prerender = false` to `src/pages/404.astro`. With Starlight, set `disable404Route: true` and add that page yourself. > [!WARNING] > With the Node adapter, name your public host in `security.allowedDomains`. Without it, Astro builds every request URL on `localhost`, and POSTs whose `Origin` is your real domain get a CSRF 403. ## React Router and Remix A splat resource route, registered in `app/routes.ts` as `route('_i/*', 'routes/[_i].$.ts')`: ```ts title="app/routes/[_i].$.ts" import { invisit } from '~/lib/invisit.server' const proxy = async ({ request }: { request: Request }) => (await invisit.proxy(request)) ?? new Response(null, { status: 404 }) export const loader = proxy export const action = proxy ``` `react-router-serve` passes on no socket address: put a proxy in front that sets `X-Forwarded-For` or `X-Real-IP`, or serve the app from Express with `middleware(invisit)`. For crawler reads with the real status, run `npx react-router reveal entry.server` and call `invisit.crawlers(request, { status: responseStatusCode })` in `handleRequest`. ## SolidStart A middleware, registered with `solidStart({ middleware: 'src/middleware.ts' })` in `vite.config.ts` (`app.config.ts` on SolidStart 1): ```ts title="src/middleware.ts" import { createMiddleware } from '@solidjs/start/middleware' import { invisit } from './lib/invisit' export default createMiddleware([ async (event, next) => { const proxied = await invisit.proxy(event.req, { ip: event.req.ip }) if (proxied) return proxied const response = await next() // SolidStart's own /_build and /_server requests are not page reads. if (!event.url.pathname.startsWith('/_')) { void invisit.crawlers(event.req, { status: event.res.status ?? 200 }) } return response }, ]) ``` ## Waku Waku loads every file in `src/middleware` as Hono middleware: ```ts title="src/middleware/invisit.ts" import type { MiddlewareHandler } from 'hono' import { invisit } from '../lib/invisit' export default function invisitMiddleware(): MiddlewareHandler { return async (c, next) => { const proxied = await invisit.proxy(c.req.raw) if (proxied) return proxied await next() void invisit.crawlers(c.req.raw, { status: c.res.status }) } } ``` Behind a host that sets the visitor's address (Vercel, Netlify, Cloudflare), that is all. On a bare Node server, pass `ip: getConnInfo(c).remote.address` from `@hono/node-server/conninfo`. ## Hono ```ts title="src/index.ts" import { Hono } from 'hono' import { getConnInfo } from 'hono/bun' // Node: '@hono/node-server/conninfo', Deno: 'hono/deno' import { invisit } from './lib/invisit' const app = new Hono() app.use(async (c, next) => { const proxied = await invisit.proxy(c.req.raw, { ip: getConnInfo(c).remote.address }) if (proxied) return proxied await next() void invisit.crawlers(c.req.raw, { status: c.res.status }) }) ``` On Cloudflare Workers, read the key from the Worker's bindings, drop `getConnInfo` and `ip` (`CF-Connecting-IP` carries the visitor), and keep the crawler read alive with `c.executionCtx.waitUntil(invisit.crawlers(…))`. ## Elysia ```ts title="src/index.ts" import { Elysia } from 'elysia' import { invisit } from './lib/invisit' new Elysia() .onRequest( async ({ request, server }) => (await invisit.handle(request, { ip: server?.requestIP(request)?.address })) ?? undefined, ) .listen(3000) ``` ## Effect A global router middleware for `effect/http` (Effect 4). It answers tracker requests, then records crawler reads with the status the visitor got, including the 404 a missing route turns into. ```ts title="src/invisit-middleware.ts" import { Effect, Exit, Option } from 'effect' import { HttpRouter, HttpServerError, HttpServerRequest, HttpServerResponse } from 'effect/http' import { invisit } from './lib/invisit' export const InvisitMiddleware = HttpRouter.middleware( (app) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const ip = Option.getOrNull(request.remoteAddress) if (request.url.startsWith('/_i/')) { const web = yield* HttpServerRequest.toWeb(request) const proxied = yield* Effect.promise(() => invisit.proxy(web, { ip })) if (proxied) return HttpServerResponse.fromWeb(proxied) } return yield* app.pipe( Effect.onExit((exit) => Effect.gen(function* () { const status = Exit.isSuccess(exit) ? exit.value.status : (yield* HttpServerError.causeResponse(exit.cause))[0].status const url = Option.getOrThrow(HttpServerRequest.toURL(request)) const read = new Request(url, { method: request.method, headers: request.headers }) void invisit.crawlers(read, { status }) }), ), ) }), { global: true }, ) ``` Merge it into the layer you serve, beside your routes: `HttpRouter.serve(Layer.mergeAll(Routes, InvisitMiddleware))`. ## Express ```ts title="src/server.ts" import express from 'express' import { middleware } from '@invisitdev/server/node' import { invisit } from './lib/invisit.js' const app = express() app.use(middleware(invisit)) // before body parsers: it reads tracker request bodies itself app.use(express.json()) ``` Tracker requests are answered from the collector; every other request goes on to your app, and crawler reads are recorded once its response finishes, with the real status. Register it **before any body parser**, or it fails tracker requests with `BODY_CONSUMED`. ## NestJS With the Express platform (the default), register the middleware on the app in `main.ts`, before any `app.useBodyParser()`, not in a module's `configure()`: ```ts title="src/main.ts" import { NestFactory } from '@nestjs/core' import { middleware } from '@invisitdev/server/node' import { AppModule } from './app.module' import { invisit } from './lib/invisit' const app = await NestFactory.create(AppModule) app.use(middleware(invisit)) await app.listen(3000) ``` With the Fastify platform, add the Fastify hook to its instance instead: ```ts title="src/main.ts" import { NestFactory } from '@nestjs/core' import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify' import { middleware } from '@invisitdev/server/node' import { AppModule } from './app.module' import { invisit } from './lib/invisit' const app = await NestFactory.create(AppModule, new FastifyAdapter()) const invisitMiddleware = middleware(invisit) app .getHttpAdapter() .getInstance() .addHook('onRequest', async (request, reply) => { await invisitMiddleware(request.raw, reply.raw) if (reply.raw.headersSent) reply.hijack() }) await app.listen(3000) ``` ## Fastify ```ts title="src/server.ts" import Fastify from 'fastify' import { middleware } from '@invisitdev/server/node' import { invisit } from './lib/invisit.js' const fastify = Fastify({ logger: true }) const invisitMiddleware = middleware(invisit) fastify.addHook('onRequest', async (request, reply) => { await invisitMiddleware(request.raw, reply.raw) if (reply.raw.headersSent) reply.hijack() }) ``` ## Cloudflare Workers ```ts title="src/worker.ts" import { createInvisit } from '@invisitdev/server' export default { async fetch(request, env, ctx) { const invisit = createInvisit({ key: env.INVISIT_API_KEY }) const proxied = await invisit.proxy(request) if (proxied) return proxied const response = await fetch(request) // your origin, or your app's own handler ctx.waitUntil(invisit.crawlers(request, { status: response.status })) return response }, } satisfies ExportedHandler<{ INVISIT_API_KEY: string }> ``` Set the key with `wrangler secret put INVISIT_API_KEY`. A Worker routed in front of any origin, a WordPress or other PHP site included, gives it the proxy and crawler reads without touching the site itself. ## Convex Convex functions run on Convex's servers, not your site's, so there is nothing to proxy and no page to record a crawler on. What fits is [server-side events](https://invisit.dev/docs/server/sdk.md#server-side-events) from an action: Convex knows the address and user agent of the request that called it, and `track` takes them as the `visitor`. ```ts title="convex/analytics.ts" import { v } from 'convex/values' import { createInvisit } from '@invisitdev/server' import { action } from './_generated/server' export const trackPurchase = action({ args: { plan: v.string() }, handler: async (ctx, { plan }) => { const { ip, userAgent } = await ctx.meta.getRequestMetadata() // Scheduled and cron runs have no visitor behind them. if (!ip || !userAgent) return const invisit = createInvisit({ key: process.env.INVISIT_API_KEY! }) await invisit .track('purchase', { visitor: { ip, userAgent }, properties: { plan } }) .catch(console.error) }, }) ``` Set the key with `npx convex env set INVISIT_API_KEY invisit_sk_…`. Mutations cannot call `fetch`. From one, schedule an action with `ctx.scheduler.runAfter(0, …)` and pass it the `ip` and `userAgent` from the mutation's own `getRequestMetadata()`: a scheduled action has none of its own. ## Plain node:http ```ts title="server.ts" import { createServer } from 'node:http' import { sendResponse, toRequest } from '@invisitdev/server/node' import { invisit } from './lib/invisit.js' createServer(async (req, res) => { if (req.url?.startsWith('/_i/')) { const response = await invisit.proxy(await toRequest(req)) if (response) return sendResponse(res, response) } // … your app }) ``` `toRequest` reads the request body, so only call it for requests you hand to invisit. --- # Events and users > Record signups, purchases and anything worth counting with track(), conversions or HTML attributes, and link visits to your own users. Source: https://invisit.dev/docs/guides/events Page views, outbound links and file downloads are recorded on their own. Everything specific to your product, such as a signup, a checkout or a plan change, is a **custom event** you name. ## Custom events ```ts title="src/checkout.ts" import { track } from '@invisitdev/web' track('checkout_completed', { plan: 'pro', seats: 5, annual: true }) ``` Events appear under **Events** in the dashboard with their properties, and can be steps in a [funnel](#funnels). > [!TIP] > Name events as `object_action` in the past tense (`signup_completed`, `invoice_paid`). The list stays sorted by object, and the name says what happened rather than what was clicked. ## Conversions A conversion is an event that marks a goal reached, reported apart from ordinary events. Record one on the tracker `init()` returns: ```ts title="src/main.ts" import { init } from '@invisitdev/web' const tracker = init({ site: '' }) tracker?.conversion('trial_started', { plan: 'growth' }) ``` ## Without JavaScript Any element records an event when clicked, and any `
` when submitted, with data attributes. This works with the script tag as well as the package. ```html title="pricing.html" …
``` Each `data-invisit-prop-` attribute becomes a string property. An event can also be defined as a rule instead of code: a click on an element matching a CSS selector, a form submit, or a visit to a URL pattern. Rules are managed through the [REST API](https://invisit.dev/docs/api/rest.md) (event definitions) or by an assistant over [MCP](https://invisit.dev/docs/api/mcp.md). The tracker fetches them when it starts, so a new rule needs no deploy. ## Names and properties The tracker validates and cleans everything before it leaves the browser. Invalid input is dropped quietly, never thrown. | Rule | Limit | | -------------- | ------------------------------------------------------------------------------------------------------------------- | | Event name | 1 to 64 characters, `A-Z a-z 0-9 _ . : -`, starting with a letter or digit. An invalid name drops the event. | | Property key | 1 to 40 characters, `A-Z a-z 0-9 _ . -`, starting with a letter or `_`. Keys starting with `invisit_` are reserved. | | Property value | `string`, finite `number`, `boolean` or `null`. Strings are cut at 256 characters; other types are dropped. | | Properties | At most 32 per event. | To keep personal data out of your analytics, property keys that look sensitive (`password`, `token`, `email`, `phone` and similar) are dropped, and email addresses and long digit runs in string values are replaced with `[redacted]`. ## Identify signed-in users Visits are anonymous by default. When someone signs in, link the visit to your own id for them: ```ts title="src/auth/session.ts" import { identify } from '@invisitdev/web' identify(user.id) ``` The id, up to 128 characters, is **hashed** on arrival and only the hash is kept, attached to the visit. Reports and raw exports carry that hash, never the id you sent, and the same id always gives the same hash on a site, so one signed-in person's visits line up across devices and days. Calls made before `init()` are queued, so it is safe to call from your auth code whenever the session is known. > [!CAUTION] > Pass an opaque id from your database. Never an email address, a name or any other personal data. ## Funnels A funnel is a sequence of pages or custom events. The dashboard shows how many visits reached each step and where they dropped off, split by source, country or device if you like. Save the funnels you look at often; assistants can run them too through [MCP](https://invisit.dev/docs/api/mcp.md). ## From your server When the thing worth counting happens on your server, such as a payment confirmed in a route handler, send the event from there with [`@invisitdev/server`](https://invisit.dev/docs/server/sdk.md#server-side-events). It joins the visit the visitor's browser started, and takes the same user id as `userId`. --- # Heatmaps > See where visitors click, which clicks are rage or dead clicks, and how far down each page they read, drawn over your live pages. Source: https://invisit.dev/docs/guides/heatmaps Heatmaps show where visitors click on each page, which of those clicks were rage clicks or dead clicks, and how far down the page each view got. They are drawn over your real, live page, so what you see is the page as it is today. Heatmap clicks and scroll depth are never billed. ## Turn heatmaps on Heatmaps are off for a new site. Open **Heatmaps** for the site and choose **Enable heatmaps**, or switch them on under **Settings → Installation**. A member or owner of the workspace can do it; a viewer cannot. The tracker reads the setting when it starts, so nothing changes in your code and no redeploy is needed. Clicks are recorded from then on; earlier visits have none. Scroll depth comes from the engagement signal, which is on by default, so a page usually has scroll data before its first click. ## What is recorded | Signal | What counts | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Click | A real pointer click on an element. Clicks fired by scripts or made with the keyboard are left out, and so are clicks on invisit's own widgets. | | Rage click | The third or later click within 30 pixels of the others inside one second: someone hammering a spot that did not respond. | | Dead click | A click on a link, a button or anything with a pointer cursor that gets no visible reaction within one second: no change to the page, no input, no scroll, no change of `#hash`. | | Scroll depth | The deepest point the bottom of the window reached in a page view, as a share of the page's height, in 5% steps. | A link or submit button that the browser follows itself is never a dead click, even when the next page takes more than a second. One whose click your code cancels and then does nothing is. Each click is stored as the element it landed on and a cell in a 32 by 32 grid over that element, not as a position on the screen. The heat stays on the right element when the layout shifts or the window is a different width. The element is named by a short path of tags, such as `main>section:nth-of-type(2)>button:nth-of-type(1)`, never by its classes, its text or what anyone typed. Clicks on form fields record that the field was clicked, nothing more. The same consent rules as the rest of the tracker apply: with [consent required](https://invisit.dev/docs/guides/privacy.md#requiring-consent), or with Global Privacy Control or Do Not Track honoured, no clicks are sent either. ## Reading a heatmap Pick a page from the list, busiest first, and **Desktop** or **Mobile**. Tablets count as desktop, since they get the desktop layout. The date range is the dashboard's, like every other report. Above the page you get the range's clicks, rage clicks, dead clicks, average scroll depth and the share of views that reached the end, each against the previous period. Rage and dead clicks going up show in red. Hover any element for its own clicks, its share of the page's clicks, and its rage and dead clicks. **Show scroll depth** adds bands that show how many views got how far down. Two notes can appear: - **Clicks on elements no longer on this page.** The element they were recorded on is gone or has moved, often after a redesign. They still count in the totals. - **Showing the busiest spots.** A page with a lot of clicks draws its 5,000 busiest cells. ## Showing your page in the dashboard The dashboard loads your page in a frame and draws the heatmap over it. That needs two things: 1. The invisit script or package on the page. 2. Permission for the dashboard to frame it. If your site sends a `frame-ancestors` policy, add the dashboard to it; if it sends `X-Frame-Options`, replace that with this policy, which browsers apply instead: ```http title="Response header" Content-Security-Policy: frame-ancestors 'self' https://app.invisit.dev ``` Without either, **Open in a new tab** shows the same heatmap on your page in its own tab, with a small toolbar. Size the window to the layout you are looking at: under 768 pixels wide is mobile. The dashboard hands your page a short-lived token to read the heatmap with. It is taken out of the address at once, so it never shows up in your own analytics or history, and it expires after 30 minutes; open the heatmap from the dashboard again to get a new one. While the heatmap is open, that tab records nothing, so looking at heatmaps does not add to your traffic. ## Keep clicks on the right element Paths stop at the nearest element with a stable `id`. Ids that look generated, such as React's `:r1:` or ones with three or more digits in a row, are skipped because they change between deploys. For an element that moves around, a card in a list or a button whose position changes with A/B copy, give it a name of its own: ```html title="index.html" ``` Its clicks then stay with it wherever it sits on the page. Clicks recorded before you add the attribute stay under the old path. ## From the API Two read-only reports, with an API key that can read analytics ([REST API](https://invisit.dev/docs/api/rest.md)): | Report | Address | Returns | | -------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Pages | `GET /v1/sites/{site_id}/analytics/heatmaps?from=&to=&device=` | The 100 busiest pages, with clicks, rage clicks, dead clicks and scroll depth for each. | | One page | `GET /v1/sites/{site_id}/analytics/heatmaps/page?from=&to=&device=&hostname=&path=` | Its totals, clicks by device, its 20 busiest elements with their rage and dead clicks, and its scroll depth in 5% bands. | `device` is `desktop` or `mobile` and defaults to desktop. `hostname` is the page's host without `www.`. The [MCP server](https://invisit.dev/docs/api/mcp.md) reaches both through its API tools. --- # Privacy and exclusions > How invisit counts visitors without cookies, how to gate tracking behind consent, and how to keep your own visits and internal pages out. Source: https://invisit.dev/docs/guides/privacy invisit measures without cookies or device identifiers. Visitors are told apart with a hash that mixes in a fresh random salt for each UTC day, so a visitor cannot be followed from one day to the next by anything stored on their device. Each day's salt is deleted automatically about 25 hours after the day ends, once its late events can no longer arrive, and from then on nobody, invisit included, can recompute that day's hashes. Only two choices are ever kept on the device: a consent answer you set, and a site owner's own opt-out. `storage: 'none'` keeps even those off. ## What is kept out - Sensitive query parameters (`token`, `code`, `email`, `password`, `state` and similar, plus any you add under **Settings → Collection**) are stripped from recorded URLs, along with the fragment and any credentials. - Property keys that look sensitive are dropped, and email addresses and long digit runs in values become `[redacted]`. - Optionally, browsers that send [Global Privacy Control](https://globalprivacycontrol.org) or Do Not Track can be left out: turn on `respectGpc` or `respectDnt` (`data-respect-gpc` or `data-respect-dnt` on the script tag). Both are off unless you set them, so those browsers are measured like any other. For server events, pass `respectGpc: true` to `createInvisit` in the [server SDK](https://invisit.dev/docs/server/sdk.md). Analytics data is kept forever on every plan, except that a free site with no events for about six months is [cleaned up](https://invisit.dev/docs/guides/billing.md#inactive-free-sites). Deleting a site deletes its analytics; see [Workspaces](https://invisit.dev/docs/guides/workspaces.md#deleting). ## Requiring consent Where your policy needs consent first, start the tracker with `requireConsent` and answer from your banner: ```ts title="src/consent.ts" import { consent, init } from '@invisitdev/web' init({ site: '', requireConsent: true }) // from your consent banner consent('granted') ``` Nothing is sent until `'granted'`. `consent('denied')` stops all collection, and `'unknown'` forgets the answer. The answer is remembered on the device unless `storage: 'none'`. With the script tag, set `data-require-consent="true"`. To keep nothing on the device at all, start with `storage: 'none'`. The consent answer and the owner opt-out are then not remembered between page loads. ## Excluding traffic ### Your own visits Open any page of your site with `#invisit-ignore` at the end of the address. That browser stops being counted, on every page, until you open a page with `#invisit-unignore`. The choice is kept on the device, unless the tracker runs with `storage: 'none'`. Do it once in each browser you use, including on your phone. ### Paths All paths are tracked by default. List the ones that never should be: ```ts title="src/main.ts" init({ site: '', excludePaths: ['/admin/*', '/health'] }) ``` With the script tag, the same list goes in `data-exclude-paths="/admin/*,/health"`. - An exact path matches only itself: `/health` excludes `/health`, not `/health/db`. - One trailing `*` matches any suffix: `/admin/*` excludes everything under `/admin/`. - Matching is case-sensitive and uses the pathname only, without query or fragment. An excluded path sends no events or heartbeats and loads no optional tracker modules. Tracking resumes as soon as client-side navigation reaches an allowed path, and the setting applies before the first page view, even when the tracker configuration was cached. ### Bots Visits from bots are never counted as visitors. The script sends nothing from a browser driven by automation, such as Playwright, Puppeteer, Selenium or Lighthouse, where `navigator.webdriver` is true; the `debug` option lifts that for your own tests. Crawler reads, such as search engines and AI crawlers fetching your pages, are their own report, recorded on your server with [`@invisitdev/server`](https://invisit.dev/docs/server/sdk.md#crawler-reads). --- # Revenue > Connect Stripe, Polar, Paddle, Dodo Payments, Creem or Lemon Squeezy and read gross, refunds, fees and net revenue beside your traffic. Source: https://invisit.dev/docs/guides/revenue Connect the provider that takes your money and invisit reads its payments, refunds and disputes into the site: gross revenue, refunds, fees and net, against the previous period and charted beside your visits. Nothing changes in your checkout and nothing is added to your site. ## Connect a provider Open **Settings → Payments** for the site and pick your provider. It takes two things: 1. **A read-only credential.** Create it in your provider's dashboard with only the read access listed below, and paste it in. invisit checks it on the spot and reads the last 90 days of payments, refunds and disputes with it. 2. **A webhook.** invisit shows the webhook address for the connection; add it in your provider's webhook settings and paste the signing secret back. New payments, refunds and disputes then arrive within seconds. When the provider asks which events to send, the simplest correct answer is all of them: anything invisit does not read is acknowledged and ignored. The connection screen lists the narrow set, the events that carry money, if you prefer to subscribe to those only. ## Providers | Provider | Credential | Read access | Webhook note | | --- | --- | --- | --- | | Stripe | Restricted API key (`rk_live_…`) | `Charges`, `Refunds`, `Disputes`, `Balance transactions` | If asked for a payload style, choose Snapshot; thin payloads are not read. | | Polar | Organization access token (`polar_oat_…`) | `orders:read`, `refunds:read` | Choose the Raw format for the endpoint. | | Paddle | API key (`pdl_live_apikey_…`) | `Transactions`, `Adjustments` | A sandbox key connects the sandbox account; use the sandbox dashboard for it. | | Dodo Payments | API key | `Payments`, `Refunds`, `Disputes` | | | Creem | API key (`creem_…`) | `Transactions` | Creem cannot list refunds or disputes, so without the webhook they do not arrive at all. | | Lemon Squeezy | API key | `Orders`, `Subscription invoices` | Choose your own signing secret there and paste the same one here. | > [!NOTE] > Credentials are stored encrypted and used only to read. invisit never creates charges, refunds or customers. ## What you see - **Totals:** gross, refunds, fees and net for the period, against the previous one. - **Over time:** revenue charted across the period you are looking at. - **Transactions:** every payment with its product, status and amounts. On the Free plan, revenue shows totals and charts; individual transactions need Starter or Pro. See [Billing](https://invisit.dev/docs/guides/billing.md). Assistants read the same numbers with the `get_revenue` tool over [MCP](https://invisit.dev/docs/api/mcp.md), and the [REST API](https://invisit.dev/docs/api/rest.md) serves them under `/v1/sites/{site_id}/revenue`. ## When something is off A connection whose credential stops working (revoked, expired, or missing a permission) is marked degraded in **Settings → Payments**. Create a new credential with the access listed above and paste it in. --- # Workspaces, members and roles > How sites belong to workspaces, what viewers, members and owners can do, how invitations and ownership transfers work, and what deleting removes. Source: https://invisit.dev/docs/guides/workspaces Every site belongs to a **workspace**, and people reach sites through their membership in it. Your account starts with a workspace of its own, which you own; create more for clients or projects whenever you like. ## Roles | | Viewer | Member | Owner | | ---------------------------------------------------------------------------------------------------- | :----: | :----: | :---: | | Read dashboards, analytics, realtime and revenue | ✓ | ✓ | ✓ | | Sites and site settings, integrations, API keys, funnels, events, annotations, share links, feedback | | ✓ | ✓ | | Invite and manage people, rename, delete or hand over the workspace, billing | | | ✓ | Roles apply to the whole workspace. Inviting people is free on every plan: plans are priced by events, never by seats. ## Inviting people An invitation is a link you copy and share; no email is sent. Links expire after 7 days and can be revoked, and inviting the same address again replaces the old link. The person opens it and joins in one step, signing in or creating an account with the invited address. ## Handing over ownership A workspace has exactly one owner. To hand it over, the owner offers it to a member, and ownership moves only when they accept. Offers lapse after 7 days and wait in the recipient's dashboard rather than their inbox. Before accepting, the recipient sees what the workspace holds this month and how it would affect their own plan. A paid workspace stops renewing on the old owner's card at the end of the paid period, and the new owner is asked to take billing over. See [Billing](https://invisit.dev/docs/guides/billing.md#the-free-plan-is-a-persons). ## Deleting - **A site** is deleted from **Settings → General**, with all of its analytics. - **A workspace** can be deleted by its owner. Every site in it is deleted with its data, and members and invitations are removed. A workspace with an active subscription must be cancelled first. - **Your account** deletes the workspaces where you are the only member, the same way, and removes you from the rest. It is refused while you are the sole owner of a workspace that has other members: hand it over first. Anyone left without a workspace, after leaving, being removed or a deletion, gets a fresh one automatically. --- # Plans and billing > What each plan includes, what counts as an event, what happens at the limit, trials, upgrades, downgrades and how the free plan is shared. Source: https://invisit.dev/docs/guides/billing Plans belong to a **workspace** and are priced by monthly events. Starter and Pro have the same features and differ only in events; Pro is one plan sold in four volumes. Yearly is nine months' price for twelve. Prices are in USD, with taxes added at checkout where they apply. | Plan | Events a month | Monthly | Yearly | Sites | Short links | | --- | --- | --- | --- | --- | --- | | Free | 20,000 | $0 | $0 | 4 | 10 | | Starter | 100,000 | $14 | $126 | Unlimited | Unlimited | | Pro | 250,000 | $24 | $216 | Unlimited | Unlimited | | Pro | 1,000,000 | $49 | $441 | Unlimited | Unlimited | | Pro | 3,000,000 | $99 | $891 | Unlimited | Unlimited | | Pro | 10,000,000 | $199 | $1,791 | Unlimited | Unlimited | On Free, revenue shows totals and charts (individual transactions need a paid plan), custom link domains are not available, and shared dashboards carry an invisit watermark. Members, alerts, API keys and MCP are on every plan without extra limits. Above 10M events a month, [contact us](mailto:help@invisit.dev). Analytics data is kept forever on every plan. There is no retention window and no paywall on history. The one exception is a free site that gets no events at all for about six months: it is [cleaned up](#inactive-free-sites) after two emails. ## What counts as an event A bill counts what visitors do: - page views, custom events and conversions, including the outbound link clicks and file downloads the tracker records on its own (turn either off with [`outboundLinks` or `fileDownloads`](https://invisit.dev/docs/web/installation.md#options)); - short-link clicks that reached a destination (a redirect, a matched rule, or a correct password). Free on every plan: crawler and bot reads, web vitals, engagement pings, heatmap clicks and scroll depth, `identify()` calls and feedback messages. Crawler reads have a fair-use line of three times the plan's events; past it they stop being recorded until the next month, and visitor analytics are unaffected. Usage is counted per **calendar month in UTC**, whatever day a subscription renews. ## At the limit The same rule on every plan: 1. **At 80%** of the month's events, the owner gets a heads-up. 2. **Over 100% for the first time**, everything is still recorded for the rest of the month, and the owner is told what upgrading would cost. 3. **Over again the next month**, once that month reaches the limit, new events stop being recorded until the owner upgrades or the month turns. The tracker always gets a normal answer, so your visitors' experience never changes, and the dashboard counts what went unrecorded. Short links always redirect, in every state; only their click analytics pause. ## The free plan is a person's The free allowance belongs to the **owner**, not to each workspace: every free workspace a person owns shares it. Workspaces you are only a member of don't count against you. A paid or trialing workspace leaves the shared allowance and has its own limits. The owner sees the whole allowance and its breakdown by workspace in **Account → Plan & Usage**. ## Free trial 14 days of every paid feature, with room for 100,000 events a month and no card needed. The owner starts it from the workspace's **Billing** tab; it never starts by itself. There is one trial per account and one per workspace. When it ends, the workspace returns to Free. ## Paying and changing plans Payments go through [Polar](https://polar.sh), the merchant of record: it charges, handles taxes and sends receipts. - **Upgrading** (more events, Starter to Pro, or monthly to yearly) applies at once, and the difference for the rest of the period is charged then. - **Downgrading** (fewer events, or yearly to monthly) waits for the end of the paid period. Nothing is refunded. - **Cancelling** keeps the plan to the end of the paid period, and it can be resumed until then. - **Billing history** in the workspace's **Billing** tab lists every charge with its PDF invoice. Cards and receipts are managed in Polar's customer portal. ## After a downgrade Going back to Free never deletes anything and never breaks anything visitors see. Sites keep tracking within the free allowance, and links keep redirecting. Past the free limits, new sites and links can't be added, and links can't be edited until you are back under the limit; deleting always works. ## Inactive free sites A free site that gets no events for 5 months is flagged by email, with a reminder 7 days before it is deleted, about 6 months in all. A single event resets the clock, and so does upgrading. Sites in paid workspaces are never cleaned up. --- # MCP for AI assistants > Connect Claude Code, Codex, Cursor or any MCP client to invisit and ask about your traffic, funnels, revenue and web vitals in plain language. Source: https://invisit.dev/docs/api/mcp invisit serves a remote [Model Context Protocol](https://modelcontextprotocol.io) endpoint, so an assistant can read your analytics, investigate them, and, if you allow it, change content and settings for you. ```txt title="Server URL" https://app.invisit.dev/mcp ``` It speaks MCP 2026-07-28 (stateless, one POST per message) and still answers clients on the 2025 protocol. ## Connect Add the address as a remote MCP server in your client. There is no key to paste. The client opens invisit in your browser, you sign in as usual, and a consent screen names the app and what it asks for. Allow, and it is connected. The dashboard shows the same steps, with the address filled in, under **Account → Connected apps**. ## Claude Code ```sh title="Terminal" claude mcp add --transport http invisit https://app.invisit.dev/mcp ``` Then run `/mcp` in Claude Code and pick invisit to sign in. ## Codex ```sh title="Terminal" codex mcp add invisit --url https://app.invisit.dev/mcp ``` Codex sees that invisit signs in with OAuth and opens the browser right away. `codex mcp login invisit` signs in again later. ## Cursor [Add invisit to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=invisit&config=eyJ1cmwiOiJodHRwczovL2FwcC5pbnZpc2l0LmRldi9tY3AifQ%3D%3D). Cursor asks before it installs; then open its MCP settings and sign in to invisit. To add it by hand, put this in `~/.cursor/mcp.json`: ```json title="~/.cursor/mcp.json" { "mcpServers": { "invisit": { "url": "https://app.invisit.dev/mcp" } } } ``` ## Other clients Add `https://app.invisit.dev/mcp` as a remote MCP server over HTTP (streamable HTTP). A client that cannot sign in with OAuth uses a [personal token](#clients-without-oauth) instead. ## What you grant What the app may do is what you grant on the consent screen: - **Read** (`mcp:read`): your sites' analytics, revenue, visitor feedback and site configuration. - **Write** (`mcp:write`): timeline notes, funnels, short links and link domains, event rules, feedback status and the public dashboard. On by default; you can switch it off. - **Manage** (`mcp:manage`): creating sites and changing their settings, domains and tracker features, alert triggers and channels, and revoking API keys. Off unless you switch it on. Every call is limited twice: by your workspace role and by what you granted. A viewer connected with manage still only reads; an owner connected read-only cannot write. Some things have no tool at all, whatever you grant, and stay in the dashboard: deleting a site, workspace or account; changing members, roles, invitations or ownership; billing; and anything that shows or takes a secret, such as creating API keys, adding alert channels, setting a share password or connecting a payment provider. A secret never passes through the conversation with your assistant. Connected apps are listed under **Account → Connected apps**. Disconnecting revokes the app at once. ## Clients without OAuth Headless agents, CI jobs and scripts that can only send a fixed header use a **personal token**, from **Account → Connected apps → Personal tokens**. Pick read, write and manage as on the consent screen, and when it expires. The secret is shown once. ```txt title="Client settings" URL: https://app.invisit.dev/mcp Header: Authorization: Bearer invisit_mcp_… ``` A personal token acts as you exactly as an OAuth connection does. Prefer OAuth when the client supports it: nothing long-lived is stored in the client. > [!NOTE] > MCP tokens and site API keys are not interchangeable. The REST API refuses MCP tokens, and `/mcp` refuses API keys. ## Tools The tool list follows the grant: a read-only connection never sees tools it could not use. Reports take `site_id` (from `list_sites`; a site name, slug or domain also resolves) and a window: `period` (`today`, `yesterday`, `7d`, `30d` by default, `90d`, `6mo`, `12mo`, `month`, `last_month`, `year`) or `from` and `to` dates, in your account's timezone unless a `timezone` is given. Tools that change something take the `site_id` itself. Each tool does one thing and says so in its annotations, so your client knows which ones only read (and can run without asking), which ones only add, and which ones replace or remove something (and should ask you first). Tools whose effect reaches past invisit, such as a public short link, a public dashboard or a test message to Slack, are marked as such too. ### Read | Tool | What it does | | ------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `list_sites` | The sites you can see, with ids, domains, workspace, your role and whether data arrives. | | `get_site` | One site's configuration and health. The first stop when data looks missing. | | `get_overview` | Visits, pageviews, events, bounce rate and visit duration against the previous window. | | `get_timeseries` | Visits and pageviews, or one custom event, per hour, day or week, with timeline notes. | | `get_breakdown` | Top values of one dimension: pages, sources, UTM fields, geography, devices, custom events, crawlers, links and more. | | `run_funnel` | Conversion through pages or custom events, or a saved funnel, optionally split by source, country or device. | | `get_web_vitals` | LCP, INP, CLS, FCP and TTFB by device, or the worst pages, countries or browsers for one metric. | | `get_realtime` | Who is on the site now and the latest events. | | `list_visits` | Individual visits over any period, newest first, or one visit step by step at any age. | | `get_revenue` | Gross, refunds, fees and net revenue against the previous window, per day, or the latest transactions. | | `list_feedback` | What visitors wrote in the feedback widget. | | `list_links` | The site's short links with their clicks in the last 30 days. | | `get_link_clicks` | Clicks per link, or one link's clicks by referrer, country, device and more. | | `list_link_domains` | Custom short link domains and the DNS records they still need. | | `list_event_rules` | Event rules and their versions. | | `get_heatmap` | Pages ranked by clicks, or one page's most clicked elements and how far visitors scrolled. | | `list_workspaces` | Your workspaces and your role in each. | | `list_workspace_members` | A workspace's members and roles, and its open invitations. | ### Write | Tool | What it does | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `create_annotation`, `update_annotation`, `delete_annotation` | Timeline notes. | | `create_funnel`, `update_funnel`, `archive_funnel` | Saved funnels. | | `create_link`, `update_link`, `archive_link` | Short links. | | `add_link_domain`, `verify_link_domain`, `remove_link_domain` | Custom short link domains. | | `create_event_rule`, `update_event_rule` | Custom events from clicks, form submits or page views, saved as draft versions. | | `publish_event_rule`, `archive_event_rule` | Make a version the one the tracker runs (an earlier one is republished), or stop it. | | `set_feedback_status`, `delete_feedback` | Resolve, reopen or delete feedback. | | `update_public_dashboard` | Turn the public dashboard on or off and choose what it shows. | ### Manage | Tool | What it does | | -------------------------------------------- | ----------------------------------------------------------------------- | | `create_site`, `update_site` | Add a site, or change its name, domains, currency and tracker settings. | | `update_alerts` | Turn alert triggers on or off, or mute alerts until a time. | | `test_alert_channel`, `delete_alert_channel` | Send a test message through an alert channel, or remove one. | | `list_api_keys`, `revoke_api_key` | See a site's API keys (never their secrets) and revoke one. | Filters narrow every visit report: `referrer_domain`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `country`, `city`, `device_type`, `browser`, `os`, `hostname`, `entry_page_path` and `page_path`. "Visitors from Hacker News on mobile" is `{ referrer_domain: "news.ycombinator.com", device_type: "mobile" }`. Results are compact text tables. The tool list costs about 6k tokens read-only, 10.5k with write and 12k with every grant, once per session; a typical call is 60 to 300. ## Charts In assistants that show [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview), such as Claude on the web and desktop, ChatGPT and VS Code, reports come with the dashboard's own view beside the answer: headline numbers with their change, traffic over time with your timeline notes, funnels, ranked sources and pages, heatmaps with scroll reach, web vitals per device and revenue. Open takes you to the same screen in the dashboard. Other clients get the same answer as text. The view is drawn from the report itself, loads nothing from elsewhere, and costs your assistant no extra tokens. ## Limits An assistant is held to the same limits as an API key: 50 requests a minute (`RateLimit-*` headers, `429` with `Retry-After`), a 400-day window per read, and a daily query-time budget per person. ## Troubleshooting - **The client says the server needs authentication but never opens a browser.** It cannot reach `/.well-known/oauth-protected-resource`; check that nothing between the client and invisit blocks it. - **"This app was connected with only read access; this needs …".** Disconnect the app under Account and connect it again, allowing what it names. - **`403` on every call.** The account was banned or deleted, or the app was disconnected. --- # REST API > Read every report and manage sites, funnels, links and more over HTTP with a site API key. OpenAPI description, ETags and cursor pages. Source: https://invisit.dev/docs/api/rest Everything the dashboard shows comes from the same API, and an API key reaches it from your own code. The API is described in OpenAPI: - Reference: [api.invisit.dev/openapi](https://api.invisit.dev/openapi) - Machine-readable: [api.invisit.dev/openapi/json](https://api.invisit.dev/openapi/json) ## Authentication Create a key under **Settings → API keys**. A key belongs to one site, not a person, and outlives whoever created it. Send it as a bearer token: ```sh title="Terminal" curl https://api.invisit.dev/v1/sites \ -H "Authorization: Bearer invisit_sk_..." ``` Each key carries scopes that bound what it may do: | Scope | Allows | | -------- | ------------------------------------------------------------------------------------- | | `read` | Analytics, revenue and realtime. | | `write` | Funnels, event definitions, annotations, links and feedback. | | `manage` | The site's settings. | | `ingest` | Server events, crawler reads and proxied visitors. See [the proxy](https://invisit.dev/docs/server/sdk.md). | Give each key only what its job needs. A key that only proxies the tracker should carry `ingest` alone. ## A first report The site's overview for October, compared with the month before: ```sh title="Terminal" curl "https://api.invisit.dev/v1/sites//analytics/overview?from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z&compare=true" \ -H "Authorization: Bearer invisit_sk_..." ``` Reports live under `/v1/sites/{site_id}/analytics/`: `overview`, `timeseries`, `pages`, `sources`, `geography`, `devices`, `languages`, `outbound`, `custom-events`, `performance`, `crawlers`, `links`, `sessions`, `visits`, `visit-log` and `funnel` among them. `sessions` counts engaged and bounced visits and their average active time. `visit-log` lists the visits that started in a range, newest first, with a `next_cursor` for the next page, and `visits/{visit_id}` returns one visit step by step at any age. Every report carries `meta`: its window, how fresh the data is, and whether it is exact. ## Conventions - **Times.** Instants are ISO-8601 UTC; ranges are half-open, `[from, to)`. Reports take a `timezone` for day and hour boundaries. - **Errors.** One envelope everywhere: `{ "error": { "code", "message", "request_id", "details" } }`. A validation failure lists `details.issues[{ path, code, message }]`. Quote the `request_id` when you contact support. - **Lists.** Pages come as `items`, `next_cursor` and `has_more`, with list-wide context under `meta`. - **Caching.** GET responses carry an `ETag`; send it back in `If-None-Match` for a `304`. ## Rate limits A key may make 50 requests a minute. Every answer reports the budget in `RateLimit-*` headers, and a refused request gets `429` with `Retry-After`. A read covers at most 400 days. ## For assistants Connecting an assistant? Use [MCP](https://invisit.dev/docs/api/mcp.md) instead of a key: it signs in as you, asks before it may write, and its tools wrap these endpoints in compact tables. --- # @invisitdev/web reference > Every function and type in @invisitdev/web, the browser tracker as a typed ES module, with signatures, return values and behavior. Source: https://invisit.dev/docs/reference/web ```ts title="@invisitdev/web" import { consent, feedback, flush, identify, init, sendFeedback, track } from '@invisitdev/web' ``` Every function except `init()` forwards to the running tracker. Calls made before `init()` are queued and applied once it starts. On the server every function is a silent no-op. ## init ```ts title="@invisitdev/web" function init(options: InitOptions): Tracker | undefined ``` Starts the tracker once per page and returns it. Calling it again returns the same tracker, and warns when `debug` is on. The options are listed in [Install with npm](https://invisit.dev/docs/web/installation.md#options). It returns `undefined`: - on the server, with no `window` or `document`; - while a site owner has the heatmap overlay open; - for a browser that [opted out](https://invisit.dev/docs/guides/privacy.md#your-own-visits) with `#invisit-ignore`; - in a browser driven by automation, such as Playwright, Puppeteer or Selenium, where `navigator.webdriver` is true, unless `debug` is on; - without a `site`. If the script tag already started invisit on the page, `init()` reuses that tracker and ignores its own options. ## track ```ts title="@invisitdev/web" function track(name: string, properties?: Properties, options?: { actionId?: string }): void ``` Records a custom event. See [Custom events](https://invisit.dev/docs/guides/events.md) for naming rules and limits. ## identify ```ts title="@invisitdev/web" function identify(externalUserId: string): void ``` Links the visitor to your own user id, up to 128 characters, stored hashed. See [Identifying users](https://invisit.dev/docs/guides/events.md#identify-signed-in-users). ## consent ```ts title="@invisitdev/web" function consent(state: 'granted' | 'denied' | 'unknown'): void ``` Records the visitor's consent answer. `'denied'` stops all collection; with `requireConsent: true`, nothing is sent until `'granted'`; `'unknown'` forgets the answer. Remembered on the device unless `storage: 'none'`. ## feedback ```ts title="@invisitdev/web" function feedback(): void ``` Opens the feedback widget, when the site has it enabled in the dashboard. ## sendFeedback ```ts title="@invisitdev/web" function sendFeedback(input: { message: string; email?: string }): Promise ``` Sends one message from your own feedback form. Resolves to `true` once the collector accepted it, and to `false` when there is no running tracker. ## flush ```ts title="@invisitdev/web" function flush(): void ``` Sends queued events now instead of with the next batch. Useful right before a hard navigation you control. ## Tracker The object `init()` returns has every function above, plus: | Member | What it does | | ------------------------------- | --------------------------------------------------------------------------- | | `conversion(name, properties?)` | Records a conversion, reported apart from ordinary events. | | `pageview()` | Records a page view now. Only needed for navigation the tracker cannot see. | | `stop()` | Stops the tracker and removes its listeners. | | `version` | The tracker version. | ## Types ```ts title="src/analytics.ts" import type { ConsentState, FeedbackMessage, InitOptions, Properties, Tracker, } from '@invisitdev/web' ``` ## Source The package is MIT-licensed and [published in full](https://github.com/berkinory/invisit-sdk), built with signed npm provenance from the commit it names. --- # @invisitdev/server reference > Every function, option and error code in @invisitdev/server, the first-party proxy, crawler analytics and server-side events client. Source: https://invisit.dev/docs/reference/server ```ts title="@invisitdev/server" import { clientIp, createInvisit, InvisitError } from '@invisitdev/server' ``` ## createInvisit ```ts title="@invisitdev/server" function createInvisit(options: InvisitOptions): Invisit ``` | Option | Type | Default | What it is | | ------------ | -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `key` | `string` | required | An API key of the site with the ingest scope (`invisit_sk_…`). | | `baseUrl` | `string` | `https://api.invisit.dev` | The collector. Leave it unless support gives you another address. | | `clientIp` | `(request: Request) => string \| null` | [`clientIp`](#clientip) | Reads the visitor's address from a request. | | `fetch` | `typeof fetch` | global `fetch` | The fetch used to reach the collector, for instance to instrument it. | | `onError` | `(error: unknown) => void` | `console.error` | Receives failures that are not thrown: proxy 502s, crawler reads not accepted, `CLIENT_IP_UNRESOLVED` once per client. | | `respectGpc` | `boolean` | `false` | Turned on, leaves out events of visitors whose browser sends Global Privacy Control. | Throws a `TypeError` when the key is missing or empty, or is not an `invisit_sk_` key. Requests to the collector time out after 10 seconds. ## handle ```ts title="@invisitdev/server" handle(request: Request, options?: HandleOptions): Promise ``` The middleware one-liner. Proxies tracker requests and resolves to their response; for every other request it records a crawler read in the background and resolves to `null`. | Option | Type | What it is | | ----------- | ------------------------------------- | ------------------------------------------------------------------------- | | `prefix` | `string` | The path the tracker is served under. Default `'/_i'`. | | `ip` | `string \| null` | The visitor's address for this request. Overrides `clientIp`. | | `waitUntil` | `(promise: Promise) => void` | Keeps the crawler read alive after the response, such as `ctx.waitUntil`. | `handle` runs before your app, so crawler reads are recorded with status 200. Use `proxy` and `crawlers` separately for the real status. ## proxy ```ts title="@invisitdev/server" proxy(request: Request, options?: { prefix?: string; ip?: string | null }): Promise ``` Forwards a tracker request under `prefix` to the collector and resolves to its response, or to `null` for any other path. It never rejects: | Situation | Response | | ----------------------------------- | -------------------------------------- | | Method other than GET, HEAD or POST | `405` | | Body larger than 1 MiB | `413` | | Collector unreachable or timed out | `502`, and the error goes to `onError` | ## crawlers ```ts title="@invisitdev/server" crawlers(request: Request, options?: { status?: number }): Promise ``` Records the request as a crawler read when it is one. `status` is what your server answered, `200` by default. Never rejects. See [Crawler reads](https://invisit.dev/docs/server/sdk.md#crawler-reads). ## track ```ts title="@invisitdev/server" track(name: string, options: TrackOptions): Promise<{ accepted: number; duplicate: number }> ``` Sends a custom event for the visitor behind `options.request`, or for `options.visitor` when there is no request of theirs. Rejects with an `InvisitError`. How and when to use it: [Server-side events](https://invisit.dev/docs/server/sdk.md#server-side-events). | Option | Type | What it is | | ------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `request` | `Request` | The visitor's own request; their address, user agent, `Accept-Language` and `Sec-GPC` come from it. | | `ip` | `string \| null` | With `request`: the visitor's address when no header carries it. Overrides `clientIp`. | | `visitor` | `{ ip, userAgent, language?, gpc? }` | Instead of `request`, when only the visitor's details were kept (a Convex action, a queue consumer, a WebSocket server). | | `url` | `string` | The page the event belongs to. | | `properties` | `Record` | Event properties, under the [browser's rules](https://invisit.dev/docs/guides/events.md#names-and-properties). | | `userId` | `string` | Your id for the signed-in user, stored hashed. | | `eventId` | `string` | A UUID v7. Reuse it when retrying so the event counts once. Generated when absent. | Pass `request` or `visitor`, never both. A `visitor` whose `ip` is not an IP address rejects with `CLIENT_IP_UNRESOLVED`. ## clientIp ```ts title="@invisitdev/server" function clientIp(request: Request): string | null ``` The default address reader: the first present of `CF-Connecting-IP`, `X-Real-IP`, `Fly-Client-IP`, `True-Client-IP`, `X-Vercel-Forwarded-For` and the first entry of `X-Forwarded-For`. A malformed value gives `null` rather than falling through to a weaker header. IPv4-mapped IPv6 is reduced to IPv4. ## @invisitdev/server/node Adapters for Node `http`, in their own entry so the main one loads on edge runtimes. | Export | What it does | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `middleware(invisit, options?)` | Express and Connect middleware: proxies tracker requests and records crawler reads with your app's real status once the response finishes. | | `toRequest(req, options?)` | An `IncomingMessage` as a web `Request`. Reads the body of anything but GET and HEAD, up to 1 MiB. | | `sendResponse(res, response)` | Writes a web `Response` to a `ServerResponse`, streaming with backpressure. | `middleware` and `toRequest` take `{ trustProxy?: boolean }`, and `middleware` also takes `{ prefix?: string }`. Without `trustProxy` they use the socket address and drop forwarding headers. ## Errors `track` rejects with an `InvisitError`; `proxy`, `handle` and `crawlers` never reject and report to `onError`. ```ts title="src/routes/checkout.ts" import { InvisitError } from '@invisitdev/server' try { await invisit.track('purchase', { request }) } catch (error) { if (error instanceof InvisitError && error.code === 'RATE_LIMITED') { // back off } } ``` | Property | Type | What it is | | ----------- | ------------------------- | ----------------------------------------------------- | | `code` | `string` | A stable, machine-readable code. | | `message` | `string` | A human-readable explanation. | | `status` | `number \| null` | The collector's HTTP status, `null` with no response. | | `requestId` | `string \| null` | The collector's request id. Quote it to support. | | `details` | `Record` | Extra context, such as validation details. | | Code | Raised when | | ---------------------- | ------------------------------------------------------------------------------- | | `VALIDATION_FAILED` | The collector rejected the event's shape or values. | | `UNAUTHENTICATED` | The API key is wrong, revoked, or lacks the ingest scope. | | `RATE_LIMITED` | Too many requests. Retry later. | | `NETWORK_ERROR` | The collector could not be reached or timed out. The cause is in `error.cause`. | | `CLIENT_IP_UNRESOLVED` | No visitor address could be read. Pass `ip`, or a `clientIp` reader. | | `USER_AGENT_MISSING` | The request has no `User-Agent` header. | | `HTTP_` | The collector answered with an error and no recognizable body. | The Node adapter adds `BODY_CONSUMED` (a body parser ran first) and `PAYLOAD_TOO_LARGE` (a body over 1 MiB). ## Troubleshooting - **`createInvisit: no key given`.** The variable is missing where the code runs: check build steps, preview deploys and your runtime's secret store. - **`key must be an API key with the ingest scope`.** You passed the site id. The server needs an `invisit_sk_…` key; the site id belongs in the browser. - **Tracker POSTs get a 403.** Your framework's CSRF check rejects them; see the SvelteKit and Astro notes in the [server frameworks](https://invisit.dev/docs/server/frameworks.md). - **Crawler reads all show status 200.** You are using `handle`, which runs before the page. Use `proxy` before and `crawlers` after your app.