SDKs
Browser SDK
@loop/sdk: autocapture, identity, in-app guides and experiment arms, in a package with no dependencies that is contracted never to break your page.
The contract
- It never throws into your page. Every entry point is error-contained. A failed guide fetch shows nothing; it does not white-screen your marketing site.
- One global, window.loop, and one prototype patch: history.pushState and replaceState, wrapped so SPA route changes are visible. It is reversible, idempotent, and only installed when autocapture is on.
- CSP-friendly: no inline style, no inline script, no eval, no blob:.
- Pre-consent events are dropped, never queued. Nothing is retained to replay later.
Configuration
init() takes the three config fields plus five lifecycle options.
import { init } from "@loop/sdk";
const loop = init({
key: "wk_live_YOUR_KEY", // required
apiHost: "https://api.loop.app", // only to point at a Loop that isn't ours
consentRequired: false,
autocapture: true, // clicks + SPA pageviews
surfaces: true, // fetch and render in-app guides
experiments: true, // fetch arm assignments for your own UI
pageview: true, // fire $pageview on start
flushIntervalMs: 3000
});The field is key. It is never writeKey, apiKey or token.
Sending events
loop.track("checkout_started", { plan: "pro", seats: 4 });
// Only if you set autocapture: false — otherwise route changes
// already fire $pageview and calling this double-counts them.
loop.page(location.href);Events are queued and flushed on a timer (3 seconds by default) and on pagehide. Pagehide, because it is the only lifecycle event that fires reliably on mobile Safari, where a backgrounded tab is frozen rather than unloaded.
Event names
Names are validated at the edge: snake_case, object then action, up to 200 characters, matching the pattern caret dollar-optional lowercase. The dollar prefix is reserved for events Loop generates.
| Event | Properties | When |
|---|---|---|
| $pageview | url | On start, and on every pushState, replaceState and back/forward. |
| $autocapture | action: click, tag, and whichever of el_id, classes, label, href exist | A click anywhere inside a button, a link, a role=button, an input type=submit or a summary. |
Autocapture is descriptors only: tag, id, up to five classes, a label of at most 60 characters from a button or link, and an href with the query string and the fragment stripped. It never captures input values or arbitrary text nodes.
Identity
loop.identify("user_8412", {
email: "ada@example.com",
plan: "pro"
});
// on sign-out — or the next human at a shared laptop
// inherits this person's anonymous history
loop.reset();The first identify() of a browser also sends the anonymous id it was using, which is what stitches the pre-signup journey onto the known person. It is offered once: after that the id is spent, so a second person on the same laptop cannot be merged into the first.
Experiments on your own UI
Await once, then read synchronously, including on every later render and re-mount. That is the whole point: no spinner, and no flash of the control painting before the treatment swaps in.
await loop.ready();
const arm = loop.variant("exp_new_checkout");
// "treatment" | "holdout" | any variant key you named | null
if (arm === "treatment") renderNewCheckout();
else renderCheckout();null and holdout are not the same answer.
null means this person is not in that experiment: it is not running, they are not eligible, or the decision has not landed. Holdout means they are in it and were deliberately held back, so render the untreated path on purpose.
Method reference
| Method | Returns | What it does |
|---|---|---|
| track(name, properties?) | void | Queue an event. Dropped, not queued, without analytics consent. |
| page(url?) | void | Track a $pageview. Handled for you unless autocapture is off. |
| identify(id, traits?) | void | Name the person, and stitch the anonymous history on the first call. |
| reset() | void | Forget this browser's person and mint a fresh anonymous id. |
| consent(state) | void | Grant or deny analytics, messaging, surface. Mirrored to the server. |
| ready() | Promise<void> | Resolves once arm assignments have landed, or once we know they never will. |
| variant(id) | string | null | Synchronous read of the arm this person is in. |
| showSurfaces(context?) | Promise<void> | Decide and render guides. Safe on load and on navigation; nothing renders twice. |
| hideSurfaces() | void | Take every guide back off the page: mounts, listeners, observers, timers. |
| refreshExperiments(context?) | Promise<void> | Arms only, rendering nothing (for surfaces: false). |
| send() | Promise<number> | Flush now. Returns how many events were accepted. |
| flush() | LoopEvent[] | Drain the queue without sending. A test seam. |
| debug() | LoopDebugSnapshot | The troubleshooting snapshot. No network, never throws. |
| getDistinctId() | string | The id events are being sent under right now. |
| previousDistinctId() | string | undefined | The id in use before the last identify(). |
Delivery and retries
A batch is tried three times with exponential backoff: 250ms, 500ms, then 1000ms. Any 2xx is success, because a proxy that rewrites our 202 to a 200 has still delivered it. Retrying is an allow-list: a timeout (408), a rate limit (429), a 5xx and a dead network are worth waiting out, and the batch is re-queued. Everything else the server says is final and the batch is dropped, because retrying cannot change the answer: a malformed batch stays malformed (400), a bad key stays bad (401), a disallowed origin stays disallowed (403), a missing route stays missing (404), and a batch that is too large is permanently too large (413). Dropping it is deliberate. The queue is re-filled at the front, so a permanently refused batch would otherwise block every event behind it for the life of the page. The queue is capped at 300 events, so a dead network cannot grow your page's memory without bound.
Any other script on your page can call window.loop.
That is inherent to installing with one script tag, and it is a documented boundary rather than an oversight. consent() mirrors a decision your page already made; it is not independent proof that a human consented. Keep the record of why consent is valid server-side.