Install the SDK
The browser SDK is dependency-free and ships in two builds: an IIFE served at /sdk.js that self-initializes from a data-key attribute, and an ESM build for bundlers. Current size is 6.6KB raw and 2.8KB gzipped; the build fails if it ever crosses 10KB gzipped.
Snippets on this page use https://betterloopai.com as the host and a placeholder write key. Your project's Setup tab renders the same snippets with your real key.
Script tag (any site)
Put it anywhere in the document. It is async, so it never blocks rendering, and it starts by recording the current pageview.
<script async src="https://betterloopai.com/sdk.js" data-key="bl_your_write_key"></script>
| Attribute | Behaviour | |
|---|---|---|
| data-key | required | The project write key. |
| data-endpoint | optional | Override the ingest URL. Defaults to /api/ingest resolved against the script src, so the default is correct whenever you load sdk.js from your Betterloop instance. |
| data-debug | optional | Set to "true" to log events to the console. |
The script-tag build always respects Do Not Track and Global Privacy Control - there is no data attribute to turn that off. Only the npm build exposes respectDNT.
npm / ESM
The package is named @betterloop/sdk and lives in packages/sdk of the Betterloop repo. It is not published to the public npm registry yet, so today you either use the script tag above or build the package from a checkout (npm run build in packages/sdk emits dist/betterloop.esm.js). The API below is what the package exports either way.
import { init } from "@betterloop/sdk";
init({
key: "bl_your_write_key",
endpoint: "https://betterloopai.com/api/ingest",
});Always pass endpoint on this build. Its compiled-in default points at https://betterloop.dev/api/ingest, which is only correct once that domain is the instance you actually use.
Exports
| Export | Signature | Notes |
|---|---|---|
| init | init(options) | Idempotent - a second call while running is a no-op. Records the first pageview and installs every hook. |
| track | track(name, attrs?) | Custom event. Name is truncated to 80 characters. Names starting with $ are reserved. |
| shutdown | shutdown() | Flushes with sendBeacon, removes every listener, disconnects the observers, and restores the patched history methods. Safe to call in an effect cleanup. |
| autoInit | autoInit() | Used by the script-tag build to read the data attributes off its own tag. You do not normally call this yourself. |
init options
| Option | Type | Default | Behaviour |
|---|---|---|---|
| key | string | required | Your project write key (starts with fp_). Public by design, like any analytics key. init() returns silently if it is missing. |
| endpoint | string | https://betterloop.dev/api/ingest | Where batches are POSTed. The script-tag build derives this from its own src, so you only set it on the npm build. |
| debug | boolean | false | Logs every captured event to the console with a [betterloop] prefix. |
| respectDNT | boolean | true | When true, init() collects nothing if navigator.doNotTrack is "1" or Global Privacy Control is on. |
React (Vite, CRA, anything bundled)
Initialize once at module scope in your entry file, outside the component tree. Nothing else is needed: React Router and every other client router go through history.pushState, which the SDK hooks.
// src/main.tsx - run init once, at module scope, before render
import { createRoot } from "react-dom/client";
import { init } from "@betterloop/sdk";
import App from "./App";
init({
key: import.meta.env.VITE_BETTERLOOP_KEY,
endpoint: "https://betterloopai.com/api/ingest",
});
createRoot(document.getElementById("root")!).render(<App />);Next.js (App Router)
The simplest route is next/script in the root layout. Extra props are forwarded to the tag, so the data attributes work exactly as they do in plain HTML, and the layout stays a server component.
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://betterloopai.com/sdk.js"
data-key="bl_your_write_key"
strategy="afterInteractive"
/>
</body>
</html>
);
}If you would rather bundle the SDK, wrap it in a small client component. Render it once inside <body> in the root layout - the cleanup call keeps React Strict Mode's double-invoke in development from leaving stale listeners behind.
// app/betterloop.tsx
"use client";
import { useEffect } from "react";
import { init, shutdown } from "@betterloop/sdk";
export default function Betterloop() {
useEffect(() => {
init({
key: process.env.NEXT_PUBLIC_BETTERLOOP_KEY!,
endpoint: "https://betterloopai.com/api/ingest",
});
return () => shutdown();
}, []);
return null;
}
// app/layout.tsx - render it once inside <body>
// <Betterloop />Vue
Same shape: init before mount. Vue Router navigations are captured through the history hooks, so there is no router guard to register.
// src/main.ts
import { createApp } from "vue";
import { init } from "@betterloop/sdk";
import App from "./App.vue";
init({
key: import.meta.env.VITE_BETTERLOOP_KEY,
endpoint: "https://betterloopai.com/api/ingest",
});
createApp(App).mount("#app");SPA behaviour
- Routing. pushState, replaceState, and popstate are all hooked. A pageview fires when pathname + search actually changes.
- Query-string state. A replaceState that keeps the same pathname (search-as-you-type, filter syncing) does not emit a pageview. Only the recorded path is updated.
- Time on page. Leaving a route emits a custom event named $time_on_page with the previous path and its duration in milliseconds. The same event fires when the tab is hidden.
- Sessions. The session id is 16 hex characters from crypto.getRandomValues, stored in sessionStorage under _fp_sid. It is per tab, and it is not a cookie. If sessionStorage throws (private modes, blocked storage) the id lives in memory for the page instead.
- Teardown. Call shutdown() in micro-frontend or module-federation setups where your bundle can be unmounted, and on hot reload if your dev setup re-executes the entry file.
Batching and transport
- Events queue in memory and flush every 5 seconds, or immediately at 50 queued events.
- On visibilitychange to hidden and on pagehide, the queue is flushed with navigator.sendBeacon so nothing is lost on unload. If the beacon is rejected, the SDK falls back to fetch with keepalive.
- A failed request is retried on the next flush. The retry backlog is capped at 100 events, dropping oldest first, so a long outage can never grow unbounded memory.
- Requests are plain application/json POSTs. The ingest endpoint answers CORS preflights and accepts any origin unless you configure an allowlist in project settings.
Custom events
// script tag build - Betterloop is a global
Betterloop.track("signup_completed", { plan: "free", source: "pricing" });
// npm build
import { track } from "@betterloop/sdk";
track("signup_completed", { plan: "free", source: "pricing" });Attributes are stored as JSON on the event and must serialize to 8192 bytes or less, otherwise ingest rejects the whole batch with 400 invalid_payload. Do not put personal data in there - see Privacy and data.
Verify the install
- Open your project's Setup tab. It polls and flips to live the moment the first event is stored.
- Add data-debug="true" (or debug: true) and watch for [betterloop] lines in the console.
- Check the network tab for a POST to /api/ingest. A 200 with {ok: true} means it landed.
Seeing nothing at all? Do Not Track or Global Privacy Control being on stops collection before anything is sent, by design. Getting a 403? Your project has an origin allowlist that does not include the site you are testing from. The full error list is in the API reference.