Loopdocs

Getting started

Install Loop

Two ways in: a script tag, or npm install. Both run the same lifecycle (a first pageview, autocapture, in-app guides, a flush timer, and a flush on the way out), so neither can drift into behaving differently from what this page says.

Get a write key

Every request you make carries a write key, and the key is what decides which project the data lands in. Loop never takes a project_id from the client.

Key kinds. The prefix tells you which one you are holding, and debug() keeps it visible when it masks the rest.
PrefixWhere it belongsWhat protects it
wk_live_The browser. It ships in your page's HTML, public by design.A per-key origin allowlist. The browser sets Origin, and page script cannot forge it.
sk_live_Your servers only. Never a browser, never a mobile bundle.Being secret. A server sends no Origin, so there is no allowlist to check. Stored as a hash and shown to you exactly once.
wk_dev_ · sk_dev_Seeded development environments only.The console mints live keys. A _dev_ prefix in production means you shipped the wrong key, which is why debug() keeps the prefix visible.

A secret key sent from a browser is refused, on purpose.

If an Origin, Sec-Fetch-Site or Sec-Fetch-Mode header arrives with an sk_live_ key, the request 403s and the refusal is recorded. A browser sent it, which means the key has leaked, and quietly accepting it would be worse than refusing, because it would work.

Option A: the script tag

Paste this into your <head>. It exposes exactly one global, window.loop.

index.html
<script
  src="https://cdn.loop.app/loop.js"
  data-key="wk_live_YOUR_KEY"
  async></script>

cdn.loop.app is not serving yet.

That hostname is the intended production form and nothing else; it does not resolve today. While it is being stood up, the API serves the same bundle itself at GET /loop.js, so point src at your API host. Nothing else changes: the SDK sends to the origin it was loaded from, so pointing src somewhere else is the whole configuration.

The snippet reads six data- attributes and nothing else:

Snippet attributes, as read in packages/sdk/src/browser.ts.
AttributeDefaultWhat it does
data-keyNoneRequired. Without it the SDK does nothing at all.
data-apithe origin the script was loaded fromRarely needed. The SDK sends to whichever origin served it, so a self-hosted or local Loop needs nothing here. Set it only when you proxy Loop through your own domain and the script and the events part company.
data-consentunsetSet to required and the SDK makes no network call, and touches no storage, until you call loop.consent().
data-autocaptureonSet to off to stop capturing clicks and SPA route changes.
data-surfacesonSet to off to fetch no in-app guides and render nothing into your page.
data-experimentsonSet to off to skip experiment arm assignment as well.

Any value other than the one named turns the feature on: the checks are !== "off" and === "required". There is no data-autocapture="true".

Option B: npm

terminal
npm install @loop/sdk
app/loop.ts
import { init } from "@loop/sdk";

export const loop = init({ key: "wk_live_YOUR_KEY" });

loop.track("checkout_started", { plan: "pro" });

Use init(), not a bare new Loop({ key }).

new Loop({ key }) is a real constructor and it will happily accept every track() you give it, but nothing inside the class ever calls send(). On its own it queues events in memory and never delivers one. init() runs the lifecycle (pageview, autocapture, guides, flush timer, flush on pagehide), which is the same lifecycle the script tag runs. If you need the constructor directly, pair it with startLifecycle(loop, options), which is exactly what the snippet does.

Verify that data is arriving

One request answers it. GET /v1/events/health is scoped to the project behind your key, so it can only ever tell you about your own data.

terminal
curl -s https://api.loop.app/v1/events/health \
  -H "x-loop-key: wk_live_YOUR_KEY"
200 · illustrative response
{ "receiving": true, "last_event_at": "2026-07-29 10:14:22" }

Two fields, and that is the whole response. receiving is true when at least one event landed in the last 90 days; last_event_at is null when it is not. The exact timestamp formatting comes straight from the event store, so treat the value above as illustrative, not as a format guarantee.

Every state this panel can be in

The console shows the same answer as a panel. Receiving is the answer; the other five are the ways the question can fail to have one: the five data states every Loop surface has to handle. None of them is a spinner over a blank rectangle.example data

Is data arriving?

loading

Loading your project ingest status.

Is data arriving?

receiving

12,481

events today · last one 40 seconds ago

Is data arriving?

empty

No event has ever reached this project.

Paste the snippet into your <head> and reload your app. The first $pageview usually lands within a second or two.

Copy the snippet

Is data arriving?

error

We could not read the event store.

This is our side, not yours: your events are still being accepted and queued. Nothing has been lost. Try again in a moment.

Retry

Is data arriving?

no access

You do not have access to this project data.

Ingest status needs the events:read permission on this project. An owner or admin of the organization can grant it from Settings → Members.

Is data arriving?

partial

9,204of 24h

This is not all of it.

Two hours of the window could not be read, so the count is a floor, not a total. The unread hours are drawn flat and grey, never as zero.

Nothing is arriving

Open your browser console and run loop.debug(). It touches no network, never throws, and is safe to call from a page that is already on fire. It returns a plain object, so you can paste it into a support thread.

browser console
loop.debug()
a snapshot of a project that is stuck on consent
{
  version: "0.0.1",
  key: "wk_live_…c4f1",
  apiHost: "https://api.loop.app",
  consentRequired: true,
  consent: { analytics: false, messaging: false, surface: false },
  distinctId: "anon_9f2c…",
  anonymousId: "anon_9f2c…",
  knownId: undefined,
  idPersisted: false,
  privacySignal: false,
  queued: 0,
  delivered: 0,
  dropped: 14,
  lastDispatch: undefined,
  surfacesShown: 0
}
What each field is actually telling you.
You are askingReadWhat a bad answer looks like
Is my key even loaded?key, apiHost"(none)", or a wk_dev_ prefix in production.
Is consent blocking me?consentRequired, consent, droppedThe snapshot above. Pre-consent events are dropped, not queued, so there is nothing in a network tab to find.
Is anything being sent?queued, delivered, lastDispatchqueued that grows and never shrinks: every send is failing.
Is the server refusing me?lastDispatch.status401 bad key · 403 origin not on the allowlist · 400 malformed batch · "network-error" offline, DNS, CORS, or an ad-blocker.
Why is every page a new person?idPersisted, privacySignalidPersisted: false means storage is blocked, DNT/GPC is set, or consent has not been given.

In consent-required mode the SDK makes no request of any kind (not even a guide decision) and stores nothing, because every call it makes carries an identifier for a person who has agreed to nothing. Grant per channel when your consent manager resolves.

app/loop.ts
import { init } from "@loop/sdk";

const loop = init({
  key: "wk_live_YOUR_KEY",
  consentRequired: true
});

// later — when your CMP resolves
loop.consent({ analytics: true, surface: true, messaging: false });

Each channel you name is mirrored to POST /v1/consent, which is where it is enforced. A guide decision the SDK refused to make before consent is not lost; it is made for the first time the moment surface consent arrives.