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.
| Prefix | Where it belongs | What 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.
<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:
| Attribute | Default | What it does |
|---|---|---|
| data-key | None | Required. Without it the SDK does nothing at all. |
| data-api | the origin the script was loaded from | Rarely 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-consent | unset | Set to required and the SDK makes no network call, and touches no storage, until you call loop.consent(). |
| data-autocapture | on | Set to off to stop capturing clicks and SPA route changes. |
| data-surfaces | on | Set to off to fetch no in-app guides and render nothing into your page. |
| data-experiments | on | Set 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
npm install @loop/sdkimport { 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.
curl -s https://api.loop.app/v1/events/health \
-H "x-loop-key: wk_live_YOUR_KEY"{ "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?
loadingLoading your project ingest status.
Is data arriving?
receiving12,481
events today · last one 40 seconds ago
Is data arriving?
emptyNo 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?
errorWe 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 accessYou 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?
partial9,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.
loop.debug(){
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
}| You are asking | Read | What 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, dropped | The 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, lastDispatch | queued that grows and never shrinks: every send is failing. |
| Is the server refusing me? | lastDispatch.status | 401 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, privacySignal | idPersisted: false means storage is blocked, DNT/GPC is set, or consent has not been given. |
If you need consent first
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.
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.