Developer documentation

Everything you need to install LiftCRO, write variants, and read results correctly.

Install

Add the snippet to the <head> of every page, as early as possible and without async or defer. Synchronous loading is what lets variants apply before the first paint. Your project-specific URL is on the Install snippet page of each project.

index.html
<link rel="preconnect" href="https://liftcro.io">
<script src="https://liftcro.io/sdk/lc_yourprojectkey.js"></script>

The response contains the runtime and your live experiment config in one file (~7 kB gzipped). It is cached for 60 seconds, so saved changes reach visitors within about a minute. While a visitor's variant is applied the page body is hidden; if anything is slow, it is revealed after the project's anti-flicker timeout (default 1.5 s).

Test types

  • A/B/n — control plus up to 9 variations, each with its own JavaScript and CSS and a custom traffic split.
  • Split URL — visitors landing on the original page are redirected to a different URL per variation before the page renders. Query strings and hashes are carried over by default.
  • Multivariate — define 2–4 factors (e.g. headline, CTA) with 2–5 levels each. Every combination becomes a variation (max 32); results include each level's pooled main effect.

Variations and their split lock once an experiment starts — duplicate the experiment to change its structure. Code, targeting and metrics stay editable.

Writing variant code

Each variant has its own JavaScript and CSS. CSS is injected into <head> immediately. JavaScript runs when the DOM is ready (or immediately, per experiment setting). Optional shared code runs for every variant — including control — before variant code.

variant-b.js
// waitFor(selector, callback, timeoutMs = 10000) resolves when the element exists,
// even if your framework renders it after load.
waitFor(".hero h1", (el) => {
  el.textContent = "Ship experiments, not tickets";
});

// Track a custom event from inside a variant
waitFor("#signup", (btn) => btn.addEventListener("click", () => lcro.track("hero_signup_click")));

// lcro.variant / lcro.experiment describe where this code is running
console.log(lcro.experiment.key, lcro.variant.name);

Errors in variant code are caught and logged to the console with the experiment key; they never break the host page. For a split-URL test, set a Redirect on the variant — the current query string and hash are preserved.

Targeting

URL rules support contains, exact (query string ignored unless the pattern has one; path-only patterns like /pricing allowed), startsWith, wildcard (* matches anything) and regex. With no include rules, every page qualifies. Exclusions always win. You can also target devices, new vs returning visitors, and a custom JavaScript condition:

Audience conditions combine attributes with operators (is, contains, starts with, regex, is any of, exists, greater than…). Attributes: page URL, path, hostname, query parameter, referrer, UTM source/medium/campaign (captured on the landing page of the session), device, browser, OS, language, new vs returning, viewport width, cookie, localStorage item and any JavaScript variable (e.g. dataLayer.0.plan). Save conditions as a reusable Audience and attach it to many experiments.

custom condition
// Must return true for the visitor to qualify
return document.cookie.includes("plan=free") && !location.search.includes("utm_source=email");

Traffic allocation decides what share of qualifying visitors enter the experiment. Bucketing is deterministic (hash of visitor ID + experiment ID), sticky across visits, and independent of traffic percentage — raising traffic never moves an existing visitor to a different variant.

Metrics & tracking

Metrics are defined per project and attached to experiments in one of three roles: goal (the first goal is primary and decides the winner), secondary (context) and guardrail (flagged if significantly worse).

  • Page visit — the visitor reached a URL (e.g. /checkout/thank-you).
  • Click — the visitor clicked an element matching a CSS selector (or any of its children).
  • Custom event — fired from your code with liftcro.push(["track", name, value?]).

Each metric is measured as a conversion rate (did the visitor convert at least once) or as a value per visitor (e.g. revenue — the sum of values, averaged over all exposed visitors). Only conversions after a visitor's first exposure, and before the experiment ended, are counted.

purchase.js
window.liftcro = window.liftcro || [];
liftcro.push(["track", "purchase", order.total]); // revenue metric: event "purchase", Value per visitor

JavaScript API

Use the queue form window.liftcro = window.liftcro || []; liftcro.push([...]) anywhere — calls made before the snippet loads are replayed. After load, window.liftcro also exposes the methods directly.

["track", name, value?]Record a custom event. value is summed for value metrics.
["activate", key]Activate an experiment set to Manual activation.
["refresh"]Re-evaluate targeting (route changes are detected automatically).
["consent", true|false]Grant or deny consent when consent mode is on.
["optOut"]Permanently exclude this browser from experiments and tracking.
["on", "exposure", fn]Callback when a visitor is bucketed: { experiment, experimentName, variant, variantName }.
liftcro.getVariant(key)Variant index for this visitor, or null.

When Push exposures to dataLayer is on, every exposure also pushes { event: "liftcro_exposure", liftcro_experiment, liftcro_experiment_name, liftcro_variant, liftcro_variant_name } for GTM / GA4.

Analytics tools

In Project settings → Privacy & analytics, choose which tools get each exposure. Nothing is sent when a tool isn't on the page, and nothing is sent in QA mode.

Google Tag ManagerdataLayer.push({ event: "liftcro_exposure", liftcro_experiment, liftcro_experiment_name, liftcro_variant, liftcro_variant_name })
Google Analytics 4gtag("event", "experience_impression", { exp_variant_string: "LCRO-<key>-<index>", experiment_id, experiment_name, variant_id, variant_name })
Segmentanalytics.track("Experiment Viewed", { experiment_id, experiment_name, variation_id, variation_name })
Mixpanelmixpanel.track("$experiment_started", { "Experiment name", "Variant name", experiment_key })
Amplitudeamplitude.track("Experiment Viewed", {…}) and user property "[LiftCRO] <key>" = variant name

Check it's working in GA4: open your site with ?lcro_debug=1, then go to GA4 → Admin → DebugView and look for experience_impression. To break reports down by variation, register exp_variant_string as an event-scoped custom dimension (Admin → Custom definitions). It takes up to 24 hours to appear in standard reports.

Single-page apps

LiftCRO hooks history.pushState, replaceState and popstate. On every route change it removes CSS of experiments that no longer match, applies newly matching ones and checks page-visit goals. JavaScript can't be un-run, so write variant code that is safe to re-apply (use waitFor and check before you mutate).

QA & previews

Click the eye icon next to any variation (Variations step) to get a preview link. Links force experiments to chosen variations with ?lcro_force=experiment-key:B,other-key:A — A is control, B the first variation (numbers 0, 1, 2… work too). Combine several experiments in one link to QA how they look together. Running experiments are referenced by their key; drafts by the link generated in the app. QA mode lasts for the browser tab, shows a badge listing every forced experiment with an Exit link, and never tracks anything. ?lcro_force=off exits; ?lcro_debug=1 logs every decision to the console.

Privacy & consent

LiftCRO uses a first-party cookie (lcro_vid, random ID, 1 year) and localStorage for sticky assignments. No personal data is collected. With Require consenton, nothing runs and nothing is stored until your consent manager calls liftcro.push(["consent", true]). Bots and headless browsers are skipped. Events are only accepted from your project's allowed domains.

Statistics

Every metric is reduced to per-visitor sufficient statistics. Relative lift (variant ÷ control − 1) and its standard error come from the delta method, which correctly accounts for uncertainty in both arms.

  • Bayesian (default): with a flat prior, the posterior on lift is approximately normal. We report chance to beat control, a credible interval, and expected loss. A variant is called when its chance to win crosses your threshold (95% by default).
  • Frequentist: two-sided z-test with p-values and confidence intervals. Decide your sample size up front and don't stop early when the p-value dips — or turn on sequential testing.
  • Sequential testing (frequentist, optional): intervals and p-values come from asymptotic confidence sequences (Waudby-Smith et al., 2021), which hold at every sample size at once. You can check daily and stop as soon as a result is significant without inflating false positives. They are tightest around the “expected visitors” you set and somewhat wider than a fixed-horizon test.
  • Variance reduction (CUPED), on by default: a regression adjustment on covariates measured before exposure — the visitor's value of the same metric in the previous 14 days, new vs returning, and mobile vs desktop. The coefficient is fitted on pooled within-variant data, so the lift estimate stays unbiased while its interval narrows. Reports show how much noise was removed.
  • SRM check: a χ² test of visitor counts against configured weights. p < 0.001 raises a warning — something is losing visitors in one arm.

Run tests for at least one full business cycle (usually 1–2 weeks) and until every variant has a meaningful number of conversions.

REST API

Create a key in Workspace → API & webhooks. Read-only keys can list and read; read & write keys can also start, pause and stop experiments. Send it as a bearer token. Limits: 600 requests per minute per key. Responses are JSON: { data } on success, { error: { code, message } } otherwise.

curl
curl https://liftcro.io/api/v1/projects \
  -H "Authorization: Bearer lcro_live_…"
GET /api/v1/projectsProjects (websites) in the workspace.
GET /api/v1/projects/{id}/experimentsExperiments, newest first. ?status=running|draft|paused|completed|archived, ?limit (≤100), ?cursor from nextCursor.
GET /api/v1/experiments/{id}Settings, variations (index, name, weight) and metrics.
GET /api/v1/experiments/{id}/resultsVisitors, SRM check, verdict, and per-metric lift, interval, chance to win and p-value. Same numbers as Reports.
POST /api/v1/experiments/{id}/statusBody { "action": "start" | "pause" | "resume" | "stop" | "archive" | "unarchive" }. stop also takes winner (variation index), outcome (won | lost | inconclusive | dnf), conclusion and rollOut.
GET /api/v1/metrics?projectId={id}Metrics defined in a project.
Stop an experiment and roll out the winner
curl -X POST https://liftcro.io/api/v1/experiments/EXPERIMENT_ID/status \
  -H "Authorization: Bearer lcro_live_…" -H "Content-Type: application/json" \
  -d '{"action":"stop","winner":1,"outcome":"won","rollOut":true,"conclusion":"Shorter form converts better"}'

Changes made through the API appear in the audit log under the key's name and trigger the same alerts and webhooks as changes in the dashboard.

Webhooks & alerts

Alerts (Project settings → Alerts) go to a Slack channel and by email to workspace admins and the experiment owner: a clear winner or loser, a traffic-split problem (SRM), launches and stops, and 80% / 100% of the plan's visitors. Results are checked every hour and each alert is sent once.

Webhooks (Workspace → API & webhooks) receive a POST for the events you pick: experiment.started, experiment.paused, experiment.stopped, experiment.significant, experiment.srm, usage.limit. Respond with any 2xx within 10 seconds. Failures are retried 3 times, and a webhook is paused after 20 failed deliveries in a row.

Payload
{
  "id": "evt_lq3x9k2a",
  "type": "experiment.significant",
  "createdAt": "2026-10-27T09:00:00.000Z",
  "summary": "Checkout CTA: Variation B is winning",
  "url": "https://liftcro.io/p/…/experiments/…/reports",
  "data": { "experiment": { "id": "…", "key": "checkout-cta", "name": "Checkout CTA" }, "verdict": "winning", "lift": 0.084 }
}

Verify every request: LiftCRO-Signature is v1= + HMAC-SHA256 of `${timestamp}.${rawBody}` with your signing secret, where timestamp is the LiftCRO-Timestamp header. Reject requests older than 5 minutes.

verify.js (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyLiftCRO(rawBody, headers, secret) {
  const ts = headers["liftcro-timestamp"];
  const sig = (headers["liftcro-signature"] || "").replace(/^v1=/, "");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Troubleshooting

  • No visitors? Check the snippet loads (Network tab), the experiment is Running, the URL matches your targeting, and your domain is in the allowed list. Add ?lcro_debug=1.
  • Flicker? Make sure the snippet is synchronous and high in <head>; use CSS for visual changes where possible.
  • Content Security Policy: allow the LiftCRO origin in script-src and connect-src. Variant JavaScript is evaluated with new Function, which requires 'unsafe-eval'.
Developer docs · LiftCRO