# 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](https://clitrail.com/docs/frameworks) show where the tag goes in each framework.

## Add the tag

```html
<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](https://clitrail.com/docs/sdk#browser-storage); [prepared commands](https://clitrail.com/docs/handoff) 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:

```js
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](https://clitrail.com/docs/sdk#google-session-attributes) and the [TikTok and X click dates](https://clitrail.com/docs/sdk#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](https://clitrail.com/docs/distributions) and [reconstruction](https://clitrail.com/docs/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](https://clitrail.com/docs/data) 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](https://support.google.com/google-ads/answer/16194756): 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.js` without `data-website` and call `InstallAttribution.init({ …, enabled: true })` once the visitor agrees.
- **Turn it off.** `data-enabled="false"` or `enabled: false` stops 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](https://globalprivacycontrol.org/) (`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.

```js
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.<website>.<url id>.json` | The [marker](https://clitrail.com/docs/how-it-works#2-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](https://clitrail.com/docs/handoff) (`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.
