Integrate Klaviyo with Optimizely Web Experimentation
TL;DR
Klaviyo already knows most of what an experiment on an ecommerce site wants to measure. Its onsite tracking records product views, cart adds, checkout starts and signups, keyed to a profile that survives across sessions and devices. Optimizely Web Experimentation knows something Klaviyo does not: which variation the visitor was shown. Connecting the two is mostly a matter of deciding which of Klaviyo's events deserve to be Optimizely conversions — and the correct answer is far fewer than all of them.
This guide covers forwarding Klaviyo onsite track events into Optimizely as custom events, keeping the event budget under control while doing it, and writing the active variation onto the Klaviyo profile so flows and segments can use it. Before building anything, size the test on the sample size calculator: checkout-start rates on most stores are low enough that the answer changes what you are willing to test.
How Klaviyo and Optimizely Web Experimentation Meet in the Page
Klaviyo's onsite script exposes a global queue. Historically it was _learnq; current installs expose klaviyo with push, track and identify. Both are arrays under the hood, which is what makes them wrappable: you can observe everything the store already sends without touching a single call site.
flowchart TD
A[Visitor loads a store page] --> B[Optimizely snippet decides the variation]
B --> C[track_layer_decision identifies the Klaviyo profile]
B --> D[klaviyo.js loads]
D --> E[Store code fires a Klaviyo track event]
E --> F{Event in the allowlist?}
F -->|yes| G[Push onto the Optimizely queue]
F -->|no| H[Dropped, silently]
G --> I[Custom event recorded against the variation]
C --> J[Variation stored as a profile property]Prerequisites
The Optimizely Web Experimentation snippet URL for your project.
Klaviyo onsite tracking installed, either through the Shopify integration or the manual
klaviyo.jsembed with your public API key.Custom events created in Optimizely for each Klaviyo event you forward, with API names noted. An event pushed under a name that does not exist in Optimizely is discarded without an error.
Event properties created in Optimizely for anything you want to segment on — fifteen per event, five predefined names plus ten custom slots.
A Klaviyo profile property for the variation, which you create simply by sending it once.
Step 1: Load the Snippet Before klaviyo.js
Klaviyo's script can load whenever it likes; Optimizely's cannot. Put the snippet first in the head and leave Klaviyo's tag wherever your platform puts it.
<head>
<script src="https://cdn.optimizely.com/js/YOUR_PROJECT_ID.js"></script>
<!-- klaviyo.js below, in whatever order the platform emits it -->
</head>
On Shopify this means editing theme.liquid directly rather than adding Optimizely through an app, because app scripts are injected into content_for_header and land below it.
Step 2: Forward Klaviyo Track Events as Optimizely Custom Events
The Event Map
Klaviyo event names are human-readable strings with spaces, fixed by whoever instrumented the store. Optimizely API names are snake case and frozen the moment a metric depends on one.
{
"events": {
"Started Checkout": "checkout_started",
"Added to Cart": "add_to_cart",
"Subscribed to Newsletter": "newsletter_signup"
},
"currencyProperty": "$value",
"numericProperty": "ItemCount",
"properties": [
{ "from": "Categories", "to": "Category" },
{ "from": "ProductID", "to": "SKU" },
{ "from": "Brand", "to": "Brand" }
]
}
$value is Klaviyo's own convention for the monetary worth of an event, and it is a plain number of currency units. Optimizely's revenue tag is an integer number of cents, so the conversion is not optional: a $value of 54.99 forwarded unconverted is discarded by Optimizely and the revenue metric reports nothing at all rather than something visibly wrong. value is Optimizely's reserved plain-number tag, and an item count belongs there.
Why Viewed Product Is Deliberately Excluded
Viewed Product is usually the highest-volume event a Klaviyo store emits — often an order of magnitude above everything else — and forwarding it is the most common mistake in this integration. It buys almost nothing analytically, because a product view is a weak signal about a checkout-page test, and it costs a great deal: every forwarded event counts against your Optimizely event volume, and a metric built on it will reach significance on noise long before the metric you care about reaches it on signal.
If you do want a browsing-depth metric, forward a derived event your own code emits after three product views, not the raw one. The segmentation guide covers how to read such a metric once you have it.
There is a second, less obvious exclusion to make deliberately. On Shopify stores, Klaviyo's Placed Order event does not come from the browser at all — it arrives through the server-side integration, after the visitor has left, keyed to the Klaviyo profile rather than to a browser session. Nothing you wrap in the page will ever see it. If order revenue is the metric that decides the test, forward the Started Checkout event as a proxy and take the revenue figure from Shopify's own checkout events instead, or accept that the Klaviyo-side purchase number and the Optimizely-side one are answering different questions. Teams who discover this after two weeks of a flat revenue metric usually conclude the integration is broken; it is working exactly as built, against an event that was never in the page.
The Forwarder
const FORWARDED_EVENTS = {
'Started Checkout': 'checkout_started',
'Added to Cart': 'add_to_cart',
'Subscribed to Newsletter': 'newsletter_signup',
};
function forwardToOptimizely(eventName, payload) {
const optimizelyEvent = FORWARDED_EVENTS[eventName];
if (!optimizelyEvent) {
return;
}
const properties = payload || {};
const tags = {};
if (typeof properties.$value === 'number') {
// Optimizely records revenue in cents, as an integer.
tags.revenue = Math.round(properties.$value * 100);
}
if (typeof properties.ItemCount === 'number') {
tags.value = properties.ItemCount;
}
window['optimizely'] = window['optimizely'] || [];
window['optimizely'].push({
type: 'event',
eventName: optimizelyEvent,
tags: tags,
properties: {
Category: properties.Categories,
SKU: properties.ProductID,
Brand: properties.Brand,
},
});
}
window._learnq = window._learnq || [];
const nativePush = window._learnq.push;
window._learnq.push = function (command) {
if (Array.isArray(command) && command[0] === 'track') {
forwardToOptimizely(command[1], command[2]);
}
return nativePush.apply(this, arguments);
};
Wrapping the queue rather than editing call sites matters more on Klaviyo than elsewhere, because on Shopify stores a large share of the track calls come from Klaviyo's own theme integration and are not yours to edit.
What the Payload Looks Like
A checkout start for a £54.99 basket of two items produces this:
{
"type": "event",
"eventName": "checkout_started",
"tags": { "revenue": 5499, "value": 2 },
"properties": { "Category": ["Footwear"], "SKU": "RUN-114-BLK", "Brand": "Aster" }
}
The Klaviyo integration demo runs this exact map, including a Viewed Product sample that is dropped — which is the behaviour the section above is arguing for, made visible.
Step 3: Write the Variation onto the Klaviyo Profile
Once the variation is a profile property, Klaviyo segments and flows can use it: a "was shown variation B and abandoned cart" segment is a genuinely different email than the generic abandonment flow, and it is the kind of thing that justifies the integration on its own.
Optimizely Web Experimentation supports this through a custom analytics integration. The track_layer_decision field is a top-level script, not a function body, so a bare return in it produces 'return' outside of function and nothing runs.
{
"plugin_type": "analytics_integration",
"name": "Klaviyo Profile Property",
"options": {
"track_layer_decision": "var experimentName = campaign.name;\nvar variationName = decision.variation_name;\nwindow['optimizely'].get('utils').waitUntil(function () {\n return window.klaviyo && typeof window.klaviyo.identify === 'function';\n}).then(function () {\n window.klaviyo.identify({\n optimizely_experiment: experimentName,\n optimizely_variation: variationName\n });\n});"
}
}
campaign and decision are read before waitUntil, not inside the deferred callback, because they are in scope at call time and the whole point of the wait is that Klaviyo's script may not have arrived yet.
Gotchas
Identify only attaches to a known profile.klaviyo.identify on an anonymous visitor creates or updates the cookie-held profile, and the properties land on a real profile only once an email is captured. Variation data for visitors who never identify is not lost, but it will not appear in a segment until they do.
Profile properties are overwritten, not appended. A visitor in two experiments will hold only the most recent variation under a single property name. If you run concurrent experiments, either use per-experiment property names or accept that the property answers "the last experiment they saw" — the concurrent tests article is worth reading before you rely on it.
_learnq versus klaviyo. Newer installs expose both, and some Klaviyo features push to one and not the other. Wrap _learnq for track events, as above, and use the klaviyo object for identify.
Consent. Where Klaviyo's script is held behind a consent banner, forwarded events stop for non-consenting visitors while Optimizely bucketing continues. The two systems will disagree on totals, legitimately.
Verifying the Integration
Load a store page with the console open and confirm window.optimizely exists before klaviyo.js has loaded. Fire an allowlisted event by hand — _learnq.push(['track', 'Added to Cart', { $value: 12.5, ItemCount: 1 }]) — and confirm a push reaches the Optimizely queue with revenue: 1250.
Then fire Viewed Product and confirm nothing is pushed. That silence is the design, not a fault, and seeing it once is what stops a future flat metric from being blamed on the code.
For the return direction, activate an experiment, subscribe with a test address, and check the profile in Klaviyo for both properties. Finally, build a segment on the variation property and compare its size against Optimizely's visitor count for that arm: the segment will be much smaller, because it contains only identified profiles, but it should grow at a proportional rate. If it does not grow at all, the identify is firing before Klaviyo is ready and the waitUntil condition needs checking.