Website SDK
browser.js records visits to your install page and leaves the receipt the installer hook looks for. It has no dependencies, sets no cookies and never asks the visitor for a permission. The framework guides show where the tag goes in each framework.
#Add the tag
<script defer src="https://YOUR_SERVICE/browser.js"
data-website="YOUR_WEBSITE_ID"></script>
With data-website present, the SDK starts on load and exposes the result as the promise InstallAttribution.ready. Load it after your analytics tags so it can read their IDs. The page must be served from your website's primary origin or one of its additional domains, over https (or localhost during development).
| Attribute | Default | Purpose |
|---|---|---|
data-website | none | Your website ID. Starts the SDK automatically. data-project is accepted as an alias. |
data-service | the script's origin | Your CLItrail service address. |
data-enabled | true | false turns the SDK off: nothing is read, stored or sent. |
data-mode | storage | handoff writes no OPFS receipt and reads or writes no web storage; prepared commands carry a token instead, with the source the current page's URL and referrer show. |
data-measurement-id | none | A GA4 measurement ID to read client and session IDs from, in addition to the ones found automatically. |
data-url-id | automatic | A page ID registered for the website. Most sites leave it unset and use the automatic ID. |
#Start it yourself
Load browser.js without data-website and call init when your code is ready, for example after consent:
const attribution = await InstallAttribution.init({
service: 'https://YOUR_SERVICE',
project: 'YOUR_WEBSITE_ID',
measurementIds: ['G-XXXXXXXXXX'],
enabled: analyticsAllowed
});
| Option | Type | Purpose |
|---|---|---|
service | string | Required. Your CLItrail service address. |
project | string | Required. Your website ID. |
enabled | boolean | false returns { ok: false, reason: 'disabled' } and does nothing else. |
measurementIds | string[] | GA4 measurement IDs to read. Combined with IDs from your GA4 destinations and the page's own tags, up to 10. |
measurementId | string | A single GA4 measurement ID. |
providers | string[] | Which ad identifiers to read: google_ads, meta, tiktok, x (and ga4). Defaults to the website's setting, which starts with all five. The service keeps only the platforms that setting includes. GA4 IDs are read whenever a measurement ID is known. |
mode | string | 'handoff' writes no OPFS receipt and uses no web storage: the session source, Google's session attributes and the TikTok and X click dates come from the current page only and stay in memory. |
getContext | async function | Supplies GA4 IDs yourself instead of asking gtag. Return { clientId, sessionId } for the first measurement ID, or { identities } to replace every automatic identifier. Answers after 2 seconds are ignored. |
urlId | string | A registered page ID; see data-url-id. |
#The result
On success, init (and InstallAttribution.ready) resolves to { ok: true, urlId, hasAnalyticsContext, expiresAt }. hasAnalyticsContext is true when a GA4 client ID was captured; expiresAt is when the receipt expires, in milliseconds. It never rejects. On failure it resolves to { ok: false, reason }:
| Reason | Cause |
|---|---|
disabled | enabled: false or data-enabled="false". |
invalid_configuration | The website ID or URL-ID is not 1 to 64 letters, digits, _ or -. |
handoff_selected | Handoff mode: no receipt was written. prepareInstall still works. |
opfs_unavailable | The browser has no OPFS, or the page is not a secure context. |
| an error name | For example TimeoutError, or Error when the service refused the visit. Check that the page's origin is registered. |
#What the SDK reads
Only what the page can already see and, for ad platforms, only for the providers in effect: init's providers option, or else the website's setting. The service drops the identifiers of platforms the website's setting leaves out. One exception: on a landing, Google's session attributes and the TikTok and X click dates are stored before the website's setting has loaded, so only init's providers option (leaving out google_ads, tiktok or x) or handoff mode prevents that. Missing identifiers are never made up.
| Provider | Identifiers | Where from |
|---|---|---|
| GA4 | client ID, session ID | gtag('get', …) for each known measurement ID. IDs are found in gtag('config', …) commands and gtag.js script URLs, or come from your GA4 destinations and options. |
| Google Ads | gclid, wbraid, gbraid, _gcl_aw, session attributes | Page URL, first-party cookie, localStorage |
| Meta | fbclid, _fbp, _fbc | Page URL, first-party cookies |
| TikTok | ttclid, _ttp, and ttclid_at when the SDK saw that click land | Page URL (or a ttclid cookie), first-party cookie, localStorage |
| X | twclid, and twclid_at when the click’s landing time is known | Page URL, the _twclid cookie X’s pixel sets, localStorage |
| Session source | utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id, external referrer | Page URL, document.referrer |
The page URL and referrer are sent too; the service keeps only their origin and path. The SDK also sends a few coarse details its own request headers may hide, for the browser and system breakdowns and reconstruction: the low-entropy user-agent client hints and, in Chromium browsers, the CPU architecture and bitness; whether the browser is Brave; the number of touch points (an iPad asking for the desktop site); the time zone and the language. See What is collected for what is stored.
#Google session attributes
When a landing URL carries a gad_ parameter, gclid or gbraid, the SDK builds Google's session attributes: every gad_* parameter, session_start_time_usec and landing_page_user_agent, encoded as base64url JSON. The landing URL and referrer are left out, as Google asks when they cannot be sent in full. The value is stored in localStorage under google_session_attributes, the key Google's own helper uses, unless init's providers option leaves out google_ads (the website's setting has not loaded yet at that point). Its session_start_time_usec is the session source's landing time, which tells the landing session from later ones that merely reuse the stored value. In handoff mode it is built for the current page only and never stored. It is sent only when google_ads is among the providers in effect, and kept only when the website's setting includes it.
#TikTok and X click dates
TikTok's pixel keeps the latest ttclid in a cookie without a date. When a landing URL carries ttclid, the SDK stores { ttclid, at } in localStorage under clitrail_ttclid (unless init's providers option leaves out tiktok, or in handoff mode), and a later visit whose ttclid matches sends ttclid_at to the service. X's twclid works the same way, under clitrail_twclid; when X's own _twclid cookie carries the landing time, that time is used. The dates place each click for channels and destinations and are never sent to TikTok or X.
#Consent and opt-out
Follow your site's analytics preferences. Two patterns work:
- Start after consent. Load
browser.jswithoutdata-websiteand callInstallAttribution.init({ …, enabled: true })once the visitor agrees. - Turn it off.
data-enabled="false"orenabled: falsestops all reading, writing and requests.
If a visitor changes their choice after a command was prepared, prepare the command again: prepareInstall({ enabled: false, … }) returns your original command unchanged.
#Global Privacy Control
When the browser sends Global Privacy Control (navigator.globalPrivacyControl === true), the SDK adds gpc: true to the visit and to handoff requests. The service stores the flag with the visit and never shares that visit with an ad platform:
- Google Ads, Meta, TikTok and X Ads never receive a touch recorded under GPC. If no other touch remains in the journey, nothing is sent to them for that install.
- GA4 and webhooks are your own measurement and still receive the install. Webhook touches carry
"gpc": true. - A later handoff request with GPC marks an existing visit too. A request without GPC never clears the flag.
- The dashboard marks these visits with a GPC badge in the visit list and in each install's journey.
#Custom context
Attach product-specific values, such as a plan or an experiment variant, with an adapter. Adapters are read when the visit is recorded, so register them before calling init, or in a script that runs right after the tag.
InstallAttribution.registerAdapter('experiment', async () => ({
variant: 'b',
plan: 'team'
}));
Names are 1 to 64 letters, digits, _ or -. Each adapter has 1 second to answer. The service keeps up to 10 adapters per visit, with up to 10 string fields each, of up to 256 characters. Webhooks with Include captured identifiers receive them under identifiers.custom.
#Your Content Security Policy
If your site sends a CSP, allow your CLItrail service for scripts and requests:
script-src 'self' https://YOUR_SERVICE;
connect-src 'self' https://YOUR_SERVICE;
worker-src 'self' blob:;
worker-src blob: is needed only for Safari before version 26, which writes OPFS files from a small worker.
#Browser storage
| Where | Name | Content |
|---|---|---|
| OPFS | opfs-install-attribution. | The marker. Expires after 30 days. |
sessionStorage | clitrail_source | The tab session's source: referrer, UTM values and landing time. |
localStorage | google_session_attributes | Google's session attributes, when a Google Ads click landed. |
localStorage | clitrail_ttclid | The last TikTok click that landed and when: { ttclid, at }. |
localStorage | clitrail_twclid | The last X click that landed and when: { twclid, at }. |
The session source is replaced when a page URL carries a utm_ or gad_ parameter or a click ID, or when the referrer is another origin. Your other domains count as another origin. In handoff mode (data-mode="handoff" or init's mode: 'handoff') the SDK writes no OPFS receipt and neither reads nor writes sessionStorage or localStorage; it still reads the ad and analytics cookies your own tags set. A handoff then carries only what the current page's URL and referrer show, so a campaign that landed on an earlier page of the tab is lost after internal navigation. Blocked storage never breaks the page; the SDK records less.
The SDK is 18.0 KB, or 6.6 KB compressed with gzip.