Loopdocs

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.

every option, with its default
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

track and page
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.

What autocapture sends, and what you will see in Data → Events.
EventPropertiesWhen
$pageviewurlOn start, and on every pushState, replaceState and back/forward.
$autocaptureaction: click, tag, and whichever of el_id, classes, label, href existA 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

at sign-up or sign-in
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.

app/checkout.tsx
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

Every public method on the Loop instance.
MethodReturnsWhat it does
track(name, properties?)voidQueue an event. Dropped, not queued, without analytics consent.
page(url?)voidTrack a $pageview. Handled for you unless autocapture is off.
identify(id, traits?)voidName the person, and stitch the anonymous history on the first call.
reset()voidForget this browser's person and mint a fresh anonymous id.
consent(state)voidGrant 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 | nullSynchronous 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()voidTake 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()LoopDebugSnapshotThe troubleshooting snapshot. No network, never throws.
getDistinctId()stringThe id events are being sent under right now.
previousDistinctId()string | undefinedThe 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.