Build a Custom Analytics Integration for Optimizely Web Experimentation

Loading...·14 min read

Optimizely Web Experimentation ships with native integrations for popular analytics platforms — Google Analytics, Amplitude, Mixpanel, Segment, and others. For many teams, those integrations are sufficient. But teams that run internal analytics dashboards, use niche behavioral analytics tools, route data through custom event pipelines, or push experiment decisions into data lakes often find that the built-in options do not cover their infrastructure.

The Custom Analytics Integration framework is Optimizely's solution to this gap. It lets you define your own integration using a JSON configuration object with a track_layer_decision callback — a JavaScript snippet that executes in the visitor's browser on every experiment bucketing decision. Your callback has access to the full decision context and can call any JavaScript API available on the page: a fetch call to an internal endpoint, a custom window.__analytics object, a tag manager data layer, or anything else your stack exposes.

This guide walks through the complete framework: how it works, how to create an integration, the full variable and API surface available inside the callback, practical examples, and common debugging patterns.

How Custom Analytics Integrations Work

When a visitor arrives on a page running the Optimizely snippet, Optimizely evaluates all active experiments and personalization campaigns. For each experiment where the visitor qualifies and is bucketed, Optimizely fires a bucketing decision. At that point, it iterates over all enabled analytics integrations for that experiment and executes their track_layer_decision callbacks in sequence.

flowchart LR
    A[Visitor arrives on page] --> B[Optimizely evaluates experiments]
    B --> C[Visitor is bucketed into experiment]
    C --> D[Optimizely iterates enabled integrations]
    D --> E[Fires track_layer_decision callback]
    E --> F[Your code sends data to your analytics tool]
    F --> G[Internal dashboard / data lake / custom pipeline]

Each enabled integration fires its callback independently. If you have three integrations enabled for an experiment, all three callbacks execute. The decision context is the same for each — the callbacks do not share state.

The callback runs synchronously in the browser at the moment of bucketing. This happens once per page load for each experiment the visitor is bucketed into. If the visitor is already bucketed (their variation is stored in a cookie or local storage), the callback still fires on subsequent page loads.

Creating an Integration

There are two ways to create a custom analytics integration in Optimizely Web Experimentation: using JSON directly, or using the visual editor. Both approaches configure the same underlying integration object.

Using JSON

The JSON approach gives you the most control and is the recommended method for non-trivial integrations. The full schema is:

{
  "plugin_type": "analytics_integration",
  "name": "My Custom Integration",
  "form_schema": [],
  "description": "Sends experiment data to my analytics tool",
  "options": {
    "track_layer_decision": "// your callback code here"
  }
}

Each field serves a specific purpose:

Field

Required

Description

plugin_type

Yes

Must be "analytics_integration". Tells Optimizely what kind of plugin this is.

name

Yes

Display name shown in the Optimizely UI under Settings > Integrations and in Manage Campaign > Integrations per-experiment toggles.

form_schema

Yes

Array of configurable fields that appear in the Optimizely UI per-experiment. Use [] for no custom fields.

description

No

Human-readable description shown in the integrations list.

options.track_layer_decision

Yes

Your callback code as a string. This is a top-level script, not a function body.

To create an integration using JSON:

  1. In your Optimizely project, go to Settings > Integrations.

  2. Click Create Analytics Integration.

  3. Select Using JSON.

  4. Paste your JSON configuration.

  5. Click Save.

The integration appears in the integrations list immediately. To use it, you must enable it globally (from Settings > Integrations) or per-experiment (from Manage Campaign > Integrations inside the experiment editor).

Using the Visual Editor

Optimizely also provides a visual form editor for creating integrations. The visual editor is useful for simpler integrations where you want to configure fields using a UI rather than writing JSON by hand. Under the hood, the visual editor generates the same JSON structure. For complex integrations — particularly those with non-trivial track_layer_decision callbacks or multiple form_schema fields — the JSON approach is more practical.

The track_layer_decision Callback

The track_layer_decision value is a JavaScript string that Optimizely evaluates as a top-level script when a bucketing decision occurs. Understanding what is available inside this callback, and how to use it correctly, is the core skill for building custom integrations.

Available Variables

The following variables are injected into scope when your callback executes:

Variable

Type

Description

campaignId

string

The campaign (layer) ID in Optimizely

experimentId

string

The experiment ID within the campaign

variationId

string

The variation ID assigned to this visitor

isHoldback

boolean

Whether the visitor is in the holdback group (i.e., excluded from the experiment)

campaign

object

Campaign metadata: { id, name, policy }

extension

object

Values from form_schema fields configured for this integration, keyed by field name

The campaign.policy field indicates the campaign type: "single_experiment" for standard A/B tests, "random" for multi-armed experiments, or "ordered" for personalization campaigns.

Note that campaignId, experimentId, and variationId are all numeric values formatted as strings. If your analytics tool expects integers, use parseInt() when passing these values.

The State API

Access the State API via window['optimizely'].get('state'). This API provides human-readable experiment and variation names, which are more useful than raw IDs in most analytics contexts.

state.getDecisionObject({ campaignId })

Returns an object with human-readable names for the current experiment and variation:

var state = window['optimizely'].get('state');
var decision = state.getDecisionObject({ campaignId: campaignId });
// Returns: { experiment: "Checkout Button Color", variation: "Blue Button" }
// Returns: null if "Mask descriptive names" is enabled in project settings

Always check for a null return value. If your Optimizely project has Mask descriptive names enabled (a privacy setting that strips human-readable names from the snippet), getDecisionObject returns null. Your callback must handle this case and fall back to numeric IDs.

state.getCampaignStates({ isActive: true })

Returns an object containing all currently active campaigns, keyed by campaign ID. Each entry includes bucketing state, experiment assignment, and variation assignment. This is useful when you need context about the full set of active experiments beyond the current decision.

The Utils API

Access the Utils API via window['optimizely'].get('utils'). The most important utility for custom integrations is waitUntil.

utils.waitUntil(predicateFn)

Returns a thenable (a promise-like object). Optimizely polls the predicate function at regular intervals and resolves the thenable when the predicate returns true.

var utils = window['optimizely'].get('utils');
utils.waitUntil(function() {
  return typeof window.myAnalytics !== 'undefined';
}).then(function() {
  // window.myAnalytics is now available
  window.myAnalytics.track('Experiment Viewed', { experimentId: experimentId });
});

Always use waitUntil when your analytics SDK may not be loaded by the time the Optimizely callback fires. Analytics SDKs loaded asynchronously are common, and calling window.myAnalytics.track() before the SDK initializes throws a runtime error and silently drops the event.

Important Constraints

The callback is a top-level script, not a function body. This is the most common source of errors in custom integrations. The track_layer_decision string is evaluated directly — it is not wrapped in a function. Using a bare return statement causes the error:

Invalid options specified: 'return' outside of function

Use early-exit patterns with if blocks instead of return to guard against missing preconditions.

All callback code is publicly readable. The Optimizely snippet is a public JavaScript file loaded in every visitor's browser. Never include API keys, authentication tokens, secrets, or credentials in the callback. If your analytics endpoint requires authentication, use a proxy endpoint that handles auth server-side, or store a write-only public API key in a form_schema field with the understanding that it will be visible in the snippet.

Keep the callback lightweight. The callback executes synchronously on the main thread. Avoid heavy computation, large loops, or synchronous network requests. The utils.waitUntil pattern using async .then() chains is the correct way to defer work.

Building a Complete Integration: Step by Step

The following walkthrough builds a complete integration that sends experiment decision data to an HTTP endpoint using navigator.sendBeacon. This pattern works for any webhook receiver, internal analytics ingestion API, or event pipeline.

Step 1: Define the JSON Structure

Start with the skeleton:

{
  "plugin_type": "analytics_integration",
  "name": "Webhook Integration",
  "form_schema": [],
  "description": "Sends experiment decisions to a webhook endpoint via sendBeacon",
  "options": {
    "track_layer_decision": "// callback goes here"
  }
}

Step 2: Add form_schema Fields

form_schema fields let you configure the integration differently per-experiment, directly from the Optimizely editor UI. Each field becomes available in the callback via extension.fieldName.

"form_schema": [
  {
    "default_value": "https://ingest.example.com/experiment-events",
    "field_type": "text",
    "name": "endpointUrl",
    "label": "Endpoint URL"
  },
  {
    "default_value": "experiment_viewed",
    "field_type": "text",
    "name": "eventName",
    "label": "Event Name"
  },
  {
    "default_value": "enabled",
    "field_type": "dropdown",
    "name": "trackingMode",
    "label": "Tracking Mode",
    "options": {
      "values": ["enabled", "disabled", "debug"]
    }
  }
]

Field values are accessed inside the callback as extension.endpointUrl, extension.eventName, extension.trackingMode. When a field is not configured per-experiment, the default_value is used.

Step 3: Write the Callback

The callback needs to:

  1. Check the trackingMode to respect per-experiment disable/debug settings

  2. Look up human-readable names from the State API

  3. Wait for navigator.sendBeacon availability (it's available in all modern browsers, but the pattern is instructive)

  4. Send the payload

// Check tracking mode from form_schema
if (extension.trackingMode === 'disabled') {
  // Do nothing for this experiment
} else {
  var utils = window['optimizely'].get('utils');
  var state = window['optimizely'].get('state');

  // Look up human-readable names
  var decision = state.getDecisionObject({ campaignId: campaignId });
  var experimentName = decision ? decision.experiment : 'unknown';
  var variationName = decision ? decision.variation : 'unknown';

  var payload = {
    event: extension.eventName || 'experiment_viewed',
    campaignId: campaignId,
    experimentId: experimentId,
    variationId: variationId,
    experimentName: experimentName,
    variationName: variationName,
    isHoldback: isHoldback,
    timestamp: Date.now()
  };

  if (extension.trackingMode === 'debug') {
    console.log('[Optimizely Custom Integration] Decision payload:', payload);
  }

  utils.waitUntil(function() {
    return typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function';
  }).then(function() {
    var endpoint = extension.endpointUrl || 'https://ingest.example.com/experiment-events';
    navigator.sendBeacon(endpoint, JSON.stringify(payload));
  });
}

Step 4: Assemble and Install the Full JSON

Inline the callback into the options.track_layer_decision field. Because JSON strings cannot contain raw newlines, either minify the callback or use \n for newlines. The complete integration JSON:

{
  "plugin_type": "analytics_integration",
  "name": "Webhook Integration",
  "description": "Sends experiment decisions to a webhook endpoint via sendBeacon",
  "form_schema": [
    {
      "default_value": "https://ingest.example.com/experiment-events",
      "field_type": "text",
      "name": "endpointUrl",
      "label": "Endpoint URL"
    },
    {
      "default_value": "experiment_viewed",
      "field_type": "text",
      "name": "eventName",
      "label": "Event Name"
    },
    {
      "default_value": "enabled",
      "field_type": "dropdown",
      "name": "trackingMode",
      "label": "Tracking Mode",
      "options": {
        "values": ["enabled", "disabled", "debug"]
      }
    }
  ],
  "options": {
    "track_layer_decision": "if (extension.trackingMode !== 'disabled') { var utils = window['optimizely'].get('utils'); var state = window['optimizely'].get('state'); var decision = state.getDecisionObject({ campaignId: campaignId }); var experimentName = decision ? decision.experiment : 'unknown'; var variationName = decision ? decision.variation : 'unknown'; var payload = { event: extension.eventName || 'experiment_viewed', campaignId: campaignId, experimentId: experimentId, variationId: variationId, experimentName: experimentName, variationName: variationName, isHoldback: isHoldback, timestamp: Date.now() }; if (extension.trackingMode === 'debug') { console.log('[Optimizely Custom Integration] Decision payload:', payload); } utils.waitUntil(function() { return typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function'; }).then(function() { var endpoint = extension.endpointUrl || 'https://ingest.example.com/experiment-events'; navigator.sendBeacon(endpoint, JSON.stringify(payload)); }); }"
  }
}

Paste this into Settings > Integrations > Create Analytics Integration > Using JSON, then save.

To enable the integration for experiments, either enable it globally (all new and existing experiments) or enable it per-experiment from Manage Campaign > Integrations inside the editor.

form_schema Reference

form_schema fields appear in the Optimizely editor under Manage Campaign > Integrations when the integration is enabled for an experiment. They let experiment owners configure the integration without editing JSON. All values are accessed inside the callback via extension.fieldName.

text

Free-form text input. Use for URLs, event names, property names, or any string value.

{
  "field_type": "text",
  "name": "eventName",
  "label": "Event Name",
  "default_value": "Experiment Viewed"
}

Access in callback: extension.eventName

dropdown

Select from a predefined list of options. Use for mode controls, environment selectors, or any field with a fixed set of valid values.

{
  "field_type": "dropdown",
  "name": "environment",
  "label": "Environment",
  "default_value": "production",
  "options": {
    "values": ["production", "staging", "development"]
  }
}

Access in callback: extension.environment

checkbox

Boolean toggle. Useful for feature flags within the integration — for example, enabling verbose logging or controlling whether holdback decisions are tracked.

{
  "field_type": "checkbox",
  "name": "trackHoldback",
  "label": "Track holdback visitors",
  "default_value": "false"
}

Access in callback: extension.trackHoldback — note that checkbox values are strings ("true" / "false"), not booleans. Use extension.trackHoldback === 'true' for conditional checks.

Field name requirements: Field names must be valid JavaScript identifiers. No spaces, no hyphens, no special characters. Use camelCase (eventName, not event-name or event name).

Real-World Examples

Example 1: Send to a Webhook Endpoint

This pattern is useful for teams with internal data pipelines, custom ingestion APIs, or tools like n8n, Zapier, or Make that accept webhook payloads.

navigator.sendBeacon is the preferred method for sending analytics data from the browser: it is fire-and-forget, does not block page unload, and works even when the page is closing. The data is sent as a POST request with Content-Type: text/plain.

var utils = window['optimizely'].get('utils');
var state = window['optimizely'].get('state');

var decision = state.getDecisionObject({ campaignId: campaignId });

var payload = JSON.stringify({
  experimentId: experimentId,
  experimentName: decision ? decision.experiment : null,
  variationId: variationId,
  variationName: decision ? decision.variation : null,
  campaignId: campaignId,
  isHoldback: isHoldback,
  timestamp: new Date().toISOString(),
  url: window.location.href,
  userId: window.optimizely_user_id || null
});

utils.waitUntil(function() {
  return typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function';
}).then(function() {
  navigator.sendBeacon('https://hooks.example.com/optimizely-decisions', payload);
});

If your webhook endpoint requires a specific Content-Type, use fetch with a keepalive: true flag instead of sendBeacon. keepalive: true ensures the request completes even if the page unloads before it finishes.

Example 2: Tag a Session Replay Tool

Session replay tools (tools in the category of Hotjar, FullStory, LogRocket, and similar products) let you tag sessions with custom attributes. Tagging sessions with experiment and variation context lets you filter recordings to sessions in specific variations — invaluable for understanding why a variation performed the way it did.

The API differs across tools, but the pattern is the same: wait for the SDK to initialize, then call its tagging method with experiment context.

var utils = window['optimizely'].get('utils');
var state = window['optimizely'].get('state');

var decision = state.getDecisionObject({ campaignId: campaignId });
var experimentName = decision ? decision.experiment : 'exp_' + experimentId;
var variationName = decision ? decision.variation : 'var_' + variationId;

// Wait for the session replay SDK to load
utils.waitUntil(function() {
  return typeof window.__replay !== 'undefined' && typeof window.__replay.tag === 'function';
}).then(function() {
  // Tag the session with experiment context
  // Adapt this to your specific session replay tool's API
  window.__replay.tag({
    'Optimizely Experiment': experimentName,
    'Optimizely Variation': variationName,
    'Optimizely Campaign ID': campaignId,
    'Optimizely Is Holdback': isHoldback
  });
});

For tools that use a push-based API (e.g., window._replayer = window._replayer || []; window._replayer.push(...)) you can call the push directly without waiting:

var state = window['optimizely'].get('state');
var decision = state.getDecisionObject({ campaignId: campaignId });

window._replayer = window._replayer || [];
window._replayer.push(['experiment', {
  id: experimentId,
  name: decision ? decision.experiment : experimentId,
  variation: decision ? decision.variation : variationId
}]);

Example 3: Push to a Data Layer

Teams that route analytics through a tag manager (Google Tag Manager, Tealium, Adobe Launch) often use a window.dataLayer array as the central event bus. Pushing experiment decisions into the data layer makes them available to all tags configured in the tag manager, without requiring changes to the Optimizely integration each time you add a new downstream tool.

var state = window['optimizely'].get('state');
var decision = state.getDecisionObject({ campaignId: campaignId });

// Push decision into GTM data layer
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  event: 'optimizely_decision',
  optimizely: {
    campaignId: campaignId,
    experimentId: experimentId,
    experimentName: decision ? decision.experiment : null,
    variationId: variationId,
    variationName: decision ? decision.variation : null,
    isHoldback: isHoldback
  }
});

This does not require waitUntil because window.dataLayer is initialized inline in the GTM snippet, which loads before Optimizely in a correctly configured setup. The fallback window.dataLayer = window.dataLayer || [] handles any edge cases where GTM has not yet initialized.

In Google Tag Manager, create a trigger on the custom event optimizely_decision and use data layer variables to access optimizely.experimentName and optimizely.variationName in your tag configurations.

Debugging Your Integration

Console Logging

Add console.log statements directly in the callback while developing. The simplest approach is to log the full decision context at the top of the callback:

console.log('[MyIntegration] Decision fired', {
  campaignId: campaignId,
  experimentId: experimentId,
  variationId: variationId,
  isHoldback: isHoldback,
  extension: extension
});

Remove or gate these logs with a trackingMode === 'debug' check before deploying to production. console.log calls in the Optimizely snippet run for every visitor and will appear in the browser console of any visitor who opens developer tools.

Optimizely Preview Mode

Preview mode in the Optimizely editor forces a bucketing decision into a specific variation without affecting production traffic. Use it to test your callback without running a live experiment. Open the experiment editor, click Preview, select a variation, and load your site. The callback fires immediately on page load.

Network Tab

Open browser developer tools and filter the Network tab for requests to your analytics endpoint. For navigator.sendBeacon calls, filter by Ping or search for your endpoint URL. Verify the request is sent, the status is 200, and the payload contains the expected fields.

Common Errors

"Invalid options specified" — This error appears in the browser console and means the track_layer_decision script failed to parse or execute. Common causes: JavaScript syntax errors in the callback string, unescaped quotes inside the JSON string, or a bare return statement at the top level.

Callback not firing — Verify the integration is enabled for the specific experiment (check Manage Campaign > Integrations), the visitor qualifies for the experiment (audience conditions are met), and the experiment is running. Use the Optimizely Debugger browser extension to inspect the active state.

Data not appearing in analytics — Check for JavaScript errors in the console. If using waitUntil, verify the predicate ever returns true. Add a timeout fallback if the analytics SDK may not load (for example, if blocked by an ad blocker). Network tab should show outbound requests.

Gotchas

No bare return at the top level. The callback is evaluated as a script, not a function body. return at the top level is a syntax error. Use if/else blocks to handle conditional logic instead.

All code is public. The Optimizely snippet is a JavaScript file served from Optimizely's CDN with no access control. Any visitor can read the full contents — including your track_layer_decision callbacks and any values in form_schema. Never include API secrets, private tokens, or credentials. Use write-only public keys (such as analytics ingest keys with no read access) if authentication is required.

getDecisionObject returns null when Mask descriptive names is enabled. This is a project-level privacy setting. If your project uses it, all state.getDecisionObject() calls return null. Always have a fallback to numeric IDs and never assume the return value is non-null.

Callbacks fire on every page load. The callback fires each time a bucketed visitor loads a page where the experiment is active — not just on the first visit. If your analytics tool counts unique experiment exposures by de-duplicating on session or user ID, this is fine. If it counts every event, implement deduplication in the callback using sessionStorage to track whether the event has already been sent in this session.

form_schema field names must be valid JavaScript identifiers. Field names become property names on the extension object. Names with spaces, hyphens, or special characters are not valid. Use camelCase (endpointUrl, not endpoint-url or endpoint url).

Multiple integrations fire independently. If several custom integrations are enabled for the same experiment, each fires its own callback. They do not share state and cannot communicate. Each callback sees the same campaignId, experimentId, variationId, and extension (where extension contains the fields specific to that integration's form_schema).

Troubleshooting

Callback Not Firing

  1. Check that the integration is enabled for the experiment. Go to Manage Campaign > Integrations inside the experiment editor. The integration must be toggled on.

  2. Check that the visitor qualifies for the experiment. Audience conditions, URL targeting, and traffic allocation must all pass. Use the Optimizely Debugger extension to see which experiments are active.

  3. Check that the experiment is running. Paused, archived, or draft experiments do not fire callbacks.

  4. Verify the Optimizely snippet is on the page. Open the Network tab and confirm the snippet loads before any other scripts that depend on it.

"Invalid options specified" Error

This error means the track_layer_decision value is not valid JavaScript. Common causes:

  • Unescaped quotes: The callback is a JSON string value. Single quotes inside the callback are fine, but double quotes must be escaped as \". Alternatively, use single quotes throughout the callback code.

  • Bare return statement: Replace return; with an if block that wraps the code you want to skip.

  • Syntax error in the callback code: Copy the callback string out of the JSON, paste it into the browser console, and run it. The console will highlight the syntax error with a line number.

Data Not Reaching Analytics

  1. SDK not loaded: Add a console.log immediately before the waitUntil call and inside the .then() callback to verify both points are reached.

  2. waitUntil predicate never resolves: If the analytics SDK fails to load (network error, ad blocker, script error), the predicate never returns true and the .then() callback never executes. Add error monitoring or a timeout to detect this case.

  3. Network request blocked: Ad blockers commonly block requests to analytics endpoints by domain pattern. Test in an incognito window with extensions disabled to rule this out.

  4. Payload format rejected: If your endpoint returns a non-2xx status, the event is dropped. Inspect the response in the Network tab.

Wrong Experiment or Variation Names

If state.getDecisionObject() returns null, Mask descriptive names is enabled in your project settings. Optimizely strips human-readable names from the snippet at publish time when this setting is active. Your callback must handle null returns and fall back to numeric IDs. If you need human-readable names and your project uses this setting, maintain a separate ID-to-name mapping outside the callback (for example, a lookup table populated from your analytics tool's own metadata).