Tracking
Install the tracking snippet
Add the first-party script, pass consent from your cookie banner, serve it from your own domain and check that it works.
The snippet is the only part of Joinsight that sees an individual visitor. Billing tells you who paid, Search Console tells you which queries brought clicks, but only the snippet records where one particular person came from, so that identify() can later tie that visit to the customer they became. Without it you still get MRR, search and store data. With it you get attribution.
It is deliberately small. It records one event per session, keeps the first touch only, sets no cookies and stores nothing in the browser until the visitor consents.
What it records
When the snippet runs with consent, it sends one event for the current browser tab session, from the page the visitor landed on. The event holds:
- The landing path.
location.pathnameonly, never the query string or hash. Joinsight lowercases it and removes a trailing slash, so/Pricing/and/pricingare the same page. - The referrer host. The hostname of
document.referrer, not the full URL. A page reached from your own hostname is not a landing page: it never starts a session, so internal navigation counts neither as a referral nor as Direct. - UTM parameters.
utm_source,utm_medium,utm_campaign,utm_contentandutm_term, each cut to 200 characters. - Which ad click ID was present. The first of
gclid,gbraid,wbraid,msclkidorfbclidfound in the URL. Only the parameter's name is sent, never its value. - An anonymous ID, a random ID generated in the browser and kept in
localStorageasjs_aid, and your site key.
A typical event looks like this:
{
"k": "js_site_...",
"a": "0b6f1c9e-5d2a-4f0e-9a7b-3c1d2e4f5a6b",
"path": "/blog/stripe-vs-paddle",
"ref": "www.google.com",
"source": "newsletter",
"medium": "email",
"campaign": "issue-2026-03"
}
On the server, Joinsight classifies the event into a channel (see Channels, UTM tags and campaign links) and stores it. The first event for an anonymous ID becomes that visitor's first touch. Later sessions from the same browser are counted as sessions but never replace the first touch: a returning visitor keeps their original landing page.
What it doesn't record
- No pageviews after landing. Once the event for a tab session is sent, the snippet stays quiet until the tab is closed. Clicking around your site sends nothing.
- No cookies. It uses
localStorageandsessionStorage, and only after consent. - No IP addresses stored. The ingest endpoints use the client IP as a rate-limit key and nothing else. It is not written to the database.
- No user agent, screen size or other device details. The event contains the fields above and nothing more.
- No custom events. The snippet has two commands,
consentandidentify, and no way to send anything else.
The trade-off is intentional. Joinsight answers "which first touch led to this customer", and that needs one event per session, not a clickstream. For page-level traffic, connect GA4 or Plausible.
Install it
Copy the two tags from Connections → Tracking snippet in the app. They already contain your site key. They look like this:
<script>window.joinsight=window.joinsight||function(){(joinsight.q=joinsight.q||[]).push(arguments)}</script>
<script defer src="https://joinsight.app/j.js" data-site="js_site_..."></script>
Put them on every page, ideally in the <head>. The first line is the queue stub: it defines joinsight() before the real script arrives and stores any calls made in the meantime. When j.js loads, it replays the queue in order. That is what lets your cookie banner call joinsight("consent", true) or your signup code call joinsight("identify", ...) without checking whether the script has loaded. Without the stub, a call made before the script has loaded fails with an error.
The script reads its settings from its own tag:
| Attribute | Required | What it does |
|---|---|---|
data-site | Yes | Your public site key. Without it the script does nothing. |
data-consent="granted" | No | Start tracking on load without waiting for a consent call. |
data-api | No | Base URL for the API, when you serve the snippet from your own domain. See below. |
The site key is public by design and safe to put in HTML. It only lets someone send events to your workspace; it can't read anything. The secret key, used for server-side identify, is a different key and must never appear in a page.
Consent
By default the snippet loads, reads the landing details into memory and waits. Nothing is stored and nothing is sent until consent is given. Call these from your cookie banner:
joinsight("consent", true); // the visitor accepted analytics
joinsight("consent", false); // the visitor refused, or withdrew consent
What each does in the browser:
| Call | Effect |
|---|---|
consent(true) | Sets js_consent to 1 in localStorage. Reuses js_aid from localStorage or creates it. If the page is a landing page, not one reached from your own hostname, and sessionStorage has no js_s yet, sets js_s and sends the session event. Calling it again in the same page does nothing. |
consent(false) | Removes js_aid and js_consent from localStorage and js_s from sessionStorage, and stops tracking for the rest of the page. |
Consent is remembered. On later page loads, a stored js_consent starts the snippet automatically, so you don't need to call consent(true) on every page. A refusal is not stored: the snippet simply has nothing to start from. Withdrawing consent clears the browser side only. Sessions already recorded stay in Joinsight.
If you don't show a banner for analytics, or consent is collected elsewhere, add data-consent="granted" to the script tag instead. The snippet then behaves as if consent(true) was called on every page load. Note that consent(false) can't override the attribute for long: the next page load starts tracking again. Whether you need consent at all depends on where your visitors are and how you read the rules; analytics consent and GDPR covers the background.
The touch is read when the script loads, not when consent arrives. On a single-page app the page doesn't reload, so a visitor who accepts the banner after a few clicks is still recorded with their real landing page. On a multi-page site, consent given on the third page arrives on a page your own site linked to. The real landing page and referrer are gone by then, so that session isn't recorded or counted, rather than being recorded as a Direct visit to the third page. The anonymous ID is still created, and the visitor's next session is recorded as usual. Showing the banner on the first page view is what keeps first touches complete.
Framework examples
Plain HTML, Hugo, Jekyll, Eleventy
Paste the two tags into the <head> of your base template, the one every page extends. Nothing else is needed.
Next.js App Router
Load both scripts from the root layout with next/script. The stub uses the beforeInteractive strategy, which Next.js only allows in the root layout and which runs before your app hydrates, so it exists before any of your components can call it. Inline scripts need an id. The snippet itself uses afterInteractive, and its data-* attributes are forwarded to the final <script> element.
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script id="joinsight-queue" strategy="beforeInteractive">
{`window.joinsight=window.joinsight||function(){(joinsight.q=joinsight.q||[]).push(arguments)}`}
</Script>
<Script src="https://joinsight.app/j.js" data-site="js_site_..." strategy="afterInteractive" />
</body>
</html>
);
}
Declare the global once so TypeScript knows about it:
// joinsight.d.ts
interface Window {
joinsight: (command: "consent" | "identify", ...args: unknown[]) => void;
}
Then call it from any client component, for example in your cookie banner's accept handler: window.joinsight("consent", true).
React single-page app (Vite, Create React App)
Paste the two tags into index.html, inside <head>, and add the same joinsight.d.ts declaration. Because the page never reloads, the snippet runs once per visit and records the URL the visitor landed on, whatever routes they open afterwards.
Astro
Astro bundles and processes <script> tags by default. Mark both as is:inline so they are output exactly as written:
---
// src/layouts/Base.astro
---
<html lang="en">
<head>
<script is:inline>window.joinsight=window.joinsight||function(){(joinsight.q=joinsight.q||[]).push(arguments)}</script>
<script is:inline defer src="https://joinsight.app/j.js" data-site="js_site_..."></script>
</head>
<body>
<slot />
</body>
</html>
Serve it from your own domain
Ad blockers and privacy extensions block requests to known analytics hosts. Every blocked visitor is a customer Joinsight can't attribute, which lowers your attributed share. Serving the snippet and its API through your own domain avoids most of this; first-party analytics and ad blockers explains why.
The snippet sends to data-api plus /api/. Without data-api, it uses the origin of its own src. So if you set:
<script defer src="https://yourdomain.com/js/j.js" data-site="js_site_..." data-api="https://yourdomain.com/js"></script>
the browser requests these three URLs, and your proxy must forward each to Joinsight:
| Your domain | Forward to |
|---|---|
https://yourdomain.com/js/j.js | https://joinsight.app/j.js |
https://yourdomain.com/js/api/ingest | https://joinsight.app/api/ingest |
https://yourdomain.com/js/api/identify | https://joinsight.app/api/identify |
Two details matter. Leave the trailing slash off data-api, or the requests go to /js//api/ingest. And always set data-api when you proxy: without it, the snippet would post to https://yourdomain.com/api/ingest, where your own app's routes usually live. The /js prefix is only an example; any prefix works as long as the three paths match it.
Next.js rewrites
// next.config.ts on your site
import type { NextConfig } from "next";
const config: NextConfig = {
async rewrites() {
return [
{ source: "/js/j.js", destination: "https://joinsight.app/j.js" },
{ source: "/js/api/ingest", destination: "https://joinsight.app/api/ingest" },
{ source: "/js/api/identify", destination: "https://joinsight.app/api/identify" },
];
},
};
export default config;
Cloudflare Worker
Attach the Worker to the route yourdomain.com/js/*. It forwards the three paths and nothing else:
const UPSTREAM = "https://joinsight.app";
const PATHS = new Set(["/j.js", "/api/ingest", "/api/identify"]);
export default {
async fetch(request) {
const path = new URL(request.url).pathname.replace(/^\/js/, "");
if (!PATHS.has(path)) return new Response("Not found", { status: 404 });
return fetch(new Request(UPSTREAM + path, request));
},
};
nginx
location = /js/j.js {
proxy_pass https://joinsight.app/j.js;
proxy_set_header Host joinsight.app;
proxy_ssl_server_name on;
}
location = /js/api/ingest {
proxy_pass https://joinsight.app/api/ingest;
proxy_set_header Host joinsight.app;
proxy_ssl_server_name on;
}
location = /js/api/identify {
proxy_pass https://joinsight.app/api/identify;
proxy_set_header Host joinsight.app;
proxy_ssl_server_name on;
}
Rate limits are counted per client IP (see below). Behind a proxy, Joinsight sees your proxy's address instead of each visitor's, so all your visitors share one budget: 120 sessions and 30 identify calls a minute. Joinsight doesn't take the visitor's address from a forwarded header such as
X-Forwarded-Forinstead, because any client can set one. For most sites the shared budget is far more than enough, since each visitor sends one event per session, but a sudden spike of traffic can hit it, and events above it are dropped.
Size and caching
j.js is about 1.7 KB minified and under 1 KB compressed. It is served with Cache-Control: public, max-age=3600, so browsers may keep a copy for up to an hour, and with Access-Control-Allow-Origin: *. Events go out with navigator.sendBeacon, falling back to fetch with keepalive, and use a text/plain body so the browser doesn't need a CORS preflight request.
If your site sends a Content Security Policy, allow the script host in script-src and the API host in connect-src (your own domain when proxying), and allow the inline queue stub with a nonce or hash.
Subdomains
localStorage belongs to one origin. If your marketing site is example.com and your app is app.example.com, each has its own anonymous ID, and a visitor who lands on the blog and signs up in the app is two anonymous visitors. The app's session then records example.com as a referral. Install the snippet on both, and either keep signup on the origin where people land or forward the landing origin's js_aid to your server and pass it to server-side identify.
Check that it works
- Deploy, then open your site in a private window by typing its address. If you rely on a banner, accept it on that first page: a page reached through a link on your own site doesn't start a session.
- In the browser's developer tools, the Network tab should show one
POSTto.../api/ingestwith status204. Chrome lists beacon requests with the typeping. - In Joinsight, open Connections → Tracking snippet, or the Install the snippet step in Setup. Last session changes from "Waiting for the first event" to a time. It is green while the last session is less than 24 hours old. Last identify() shows the same for identify calls, or "None yet".
Reloading the page won't send a second event, because js_s is already in sessionStorage. To test again, open a new private window. Both timestamps are updated at most once every five minutes, so they lag a little behind steady traffic, but the very first event always shows up straight away.
If the request returns something other than 204:
| Status | Meaning |
|---|---|
400 | The event didn't validate, for example a path longer than 500 characters or a body over 4 KB. |
404 | The site key doesn't match a workspace. Copy the tag again from the app. |
429 | Rate limited. The event is dropped, not retried. |
No request at all usually means consent wasn't given, the data-site attribute is missing, or an extension blocked the script. Troubleshooting goes through the common cases.
Rate limits
The public endpoints are limited per client IP: 120 requests per minute to /api/ingest and 30 per minute to /api/identify. The counts are approximate and kept per Cloudflare location. With one event per session, a single visitor never comes close. The limits exist so that a script hammering the endpoint can't fill your workspace with fake sessions.