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.
<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.
// 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.
// 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.
window.liftcro = window.liftcro || [];
liftcro.push(["track", "purchase", order.total]); // revenue metric: event "purchase", Value per visitorJavaScript 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 Manager | dataLayer.push({ event: "liftcro_exposure", liftcro_experiment, liftcro_experiment_name, liftcro_variant, liftcro_variant_name }) |
| Google Analytics 4 | gtag("event", "experience_impression", { exp_variant_string: "LCRO-<key>-<index>", experiment_id, experiment_name, variant_id, variant_name }) |
| Segment | analytics.track("Experiment Viewed", { experiment_id, experiment_name, variation_id, variation_name }) |
| Mixpanel | mixpanel.track("$experiment_started", { "Experiment name", "Variant name", experiment_key }) |
| Amplitude | amplitude.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 https://liftcro.io/api/v1/projects \
-H "Authorization: Bearer lcro_live_…"| GET /api/v1/projects | Projects (websites) in the workspace. |
| GET /api/v1/projects/{id}/experiments | Experiments, 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}/results | Visitors, SRM check, verdict, and per-metric lift, interval, chance to win and p-value. Same numbers as Reports. |
| POST /api/v1/experiments/{id}/status | Body { "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. |
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.
{
"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.
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-srcandconnect-src. Variant JavaScript is evaluated withnew Function, which requires'unsafe-eval'.