Integrate Tealium with Optimizely Feature Experimentation
TL;DR
Tealium is a customer data platform with two surfaces: Tealium iQ Tag Management, the client-side tag manager that exposes the browser utag API, and Tealium EventStream / Collect, the server-side ingestion layer that accepts events over HTTP and feeds AudienceStream profiles and downstream connectors. Optimizely Feature Experimentation makes flag and variation decisions that can happen in the browser, on your server, or both. Bridging the two means taking each flag decision the SDK reports and forwarding it into Tealium, so the variation a user actually received travels with every event Tealium collects and on to whatever analytics, advertising, or warehouse destinations you have connected.
Unlike Web Experimentation, Feature Experimentation has no Custom Analytics Integration UI, and unlike the Web side there is no official Optimizely-to-Tealium plugin to lean on — Optimizely's documented Tealium integration (the Tealium Optimizely Events Connector) runs the other direction, sending Tealium events into Optimizely's Event API. To send Optimizely decisions to Tealium you register a decision notification listener on the Optimizely client: a callback that fires every time decide() resolves a flag. Where that callback runs determines which Tealium surface you call. In the browser you call Tealium iQ's utag.link() directly. On the server the browser utag object does not exist, so you call Tealium's Collect HTTP API over HTTP — and identity matching becomes the critical detail, because the server has no access to Tealium's client-side visitor ID.
How the Integration Works
The decision notification listener receives the flag key, whether the flag is enabled, the variation key, and the rule key for every decision. The integration forwards this to Tealium as an event named optimizely_decision. In the browser that is a utag.link() call; on the server it is a POST to the Tealium Collect HTTP API. From there Tealium distributes the event to its connectors and uses it to enrich visitor profiles.
flowchart LR
A["optimizely.decide('flag')"] --> B[DECISION notification fires]
B --> C{Where does the listener run?}
C -->|Browser| D["utag.link tealium_event = optimizely_decision"]
C -->|Server| E["POST collect.tealiumiq.com/event"]
D --> F[Tealium iQ tag distribution]
E --> G[Tealium EventStream / Collect]
F --> G
G --> H[AudienceStream profiles + connectors]
H --> I[Warehouse, ad platforms, analytics by variation]
Decision Notification Data
The DECISION listener's decisionInfo object contains the following fields for a flag-type decision:
Field | Type | Description |
|---|---|---|
| string | The flag key that was evaluated |
| boolean | Whether the flag is enabled for this user |
| string | The delivered variation key |
| string | The experiment or delivery rule that matched |
| boolean | Whether Optimizely sent an impression for this decision |
decisionEventDispatched is worth checking: when it is false, Optimizely did not count an impression (for example, a flag delivered as a targeted rollout rather than an experiment), so forwarding that decision to Tealium may inflate your experiment counts relative to Optimizely's results.
Tealium Event Format
The integration sends a single Tealium event per decision. The reserved tealium_event key names the interaction; the remaining keys carry the experiment metadata:
tealium_event = optimizely_decision
optimizely_flag_key = checkout_redesign
optimizely_variation_key = treatment
optimizely_rule_key = checkout_experiment
optimizely_enabled = true
Keeping the flag and variation in separate keys (rather than one concatenated string) means each can be mapped independently to downstream destinations in Tealium.
Prerequisites
Before starting the integration:
Optimizely Feature Experimentation SDK installed for your platform (JavaScript SDK v5+, Node.js SDK, or Python SDK).
Tealium iQ installed on the page (browser path) — confirm
window.utagexists — and/or a Tealium account, profile, and data source for the server path. The Collect HTTP API needs yourtealium_accountandtealium_profile; an HTTP API data source key (tealium_datasource) is recommended so events route to the right data source in the Customer Data Hub.HTTP client for server-side use — Node.js 18+ has
fetchbuilt in; Python uses therequestslibrary.Consistent visitor identity — the visitor ID you send to Tealium's Collect API (
tealium_visitor_id) must correspond to the identity Tealium uses for that user in the browser. Without a matching visitor ID, server-side decisions land on a different Tealium visitor than the browser session, breaking cross-surface profile stitching.
JavaScript / Browser Implementation
When the Optimizely FX SDK runs in the browser and Tealium iQ is on the page, call utag.link() directly from the decision listener. No HTTP calls or API keys are required — Tealium iQ owns identity and distribution.
import { createInstance, enums } from '@optimizely/optimizely-sdk';
const optimizely = createInstance({
sdkKey: '<YOUR_SDK_KEY>',
});
optimizely.onReady().then(() => {
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
({ type, userId, decisionInfo }) => {
if (type !== 'flag') return;
const { flagKey, enabled, variationKey, ruleKey } = decisionInfo;
if (typeof window.utag === 'undefined' || typeof window.utag.link !== 'function') {
return; // Tealium not ready; see note on load order below
}
window.utag.link({
tealium_event: 'optimizely_decision',
optimizely_flag_key: flagKey,
optimizely_variation_key: enabled ? variationKey : 'off',
optimizely_rule_key: ruleKey,
optimizely_enabled: enabled,
});
}
);
const user = optimizely.createUserContext(userId, userAttributes);
const decision = user.decide('checkout_redesign');
console.log('Variation:', decision.variationKey);
});
This is the simplest path: Tealium iQ resolves identity from its own visitor cookie, so the decision automatically attaches to the correct Tealium visitor. As with the Web Experimentation custom integration, utag.link() only places data into Tealium's pipeline — you still configure in Tealium iQ which tags and connectors consume the optimizely_decision event.
Load Order in the Browser
If the Optimizely SDK initializes and decide() runs before Tealium's utag.js has loaded, window.utag.link will not yet be a function and the event is dropped. The guard above skips cleanly in that case. If decisions can fire that early, queue them and flush once Tealium is ready, or defer the first decide() until after utag.js loads.
Node.js Implementation
On the server, the browser utag object does not exist. Use Tealium's Collect HTTP API. There is no official Tealium server SDK for Node; POST the event to the Collect endpoint directly with fetch.
The v1 Collect endpoint (https://collect.tealiumiq.com/event) is unauthenticated and is the simplest target. If your account uses the authenticated v3 API, the endpoint is https://collect.tealiumiq.com/v3/collect/event and each request carries an Authorization: Bearer <token> header; the body is otherwise the same. The example below uses v1 and shows where the v3 auth header goes.
import { createInstance, enums } from '@optimizely/optimizely-sdk';
const TEALIUM_ACCOUNT = process.env.TEALIUM_ACCOUNT;
const TEALIUM_PROFILE = process.env.TEALIUM_PROFILE; // default profile is "main"
const TEALIUM_DATASOURCE = process.env.TEALIUM_DATASOURCE; // HTTP API data source key
const TEALIUM_COLLECT_URL = 'https://collect.tealiumiq.com/event';
// v3 authenticated alternative:
// const TEALIUM_COLLECT_URL = 'https://collect.tealiumiq.com/v3/collect/event';
// const TEALIUM_BEARER = process.env.TEALIUM_BEARER_TOKEN;
async function sendToTealium(visitorId, properties) {
const headers = { 'Content-Type': 'application/json' };
// For the v3 endpoint, add:
// headers['Authorization'] = `Bearer ${TEALIUM_BEARER}`;
const body = {
tealium_account: TEALIUM_ACCOUNT,
tealium_profile: TEALIUM_PROFILE,
tealium_datasource: TEALIUM_DATASOURCE,
tealium_event: 'optimizely_decision',
tealium_visitor_id: visitorId,
...properties,
};
const res = await fetch(TEALIUM_COLLECT_URL, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
console.error(`Tealium Collect failed: ${res.status}`, await res.text());
}
}
const optimizely = createInstance({
sdkKey: process.env.OPTIMIZELY_SDK_KEY,
});
optimizely.onReady().then(() => {
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
async ({ type, userId, decisionInfo }) => {
if (type !== 'flag') return;
const { flagKey, enabled, variationKey, ruleKey } = decisionInfo;
await sendToTealium(userId, {
optimizely_flag_key: flagKey,
optimizely_variation_key: enabled ? variationKey : 'off',
optimizely_rule_key: ruleKey,
optimizely_enabled: enabled,
});
}
);
});
// In your request handler
async function handleRequest(userId, userAttributes) {
const user = optimizely.createUserContext(userId, userAttributes);
const decision = user.decide('checkout_redesign');
// The DECISION listener fires and forwards the decision to Tealium
return decision;
}
Visitor ID Matching
The Collect API attaches the event to a visitor by tealium_visitor_id. For AudienceStream stitching to work, the value you send server-side must reconcile with the visitor ID Tealium assigns in the browser. The cleanest approach is to standardize on a known user ID (the same userId you pass to Optimizely's createUserContext) as a custom identifier mapped to Tealium's visitor ID in your data layer, rather than trying to reproduce Tealium's anonymous cookie value on the server. Whatever you choose, use the same identifier on both surfaces.
Deduplication
If your application calls decide() on every request for the same user and flag, the listener fires repeatedly and posts duplicate events. The Collect API does not provide built-in idempotency, so deduplicate before sending: keep a per-request or per-session Set of userId:flagKey:variationKey combinations and skip the POST when the combination was already sent, or only forward decisions where decisionInfo.decisionEventDispatched === true so rollout re-evaluations do not each emit an event.
Listener Registration Timing
The notification listener must be registered before any decide() call. The Optimizely SDK does not replay past decisions to newly registered listeners.
// CORRECT: register the listener before decide()
optimizely.onReady().then(() => {
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
callback
);
const user = optimizely.createUserContext(userId, attributes);
const decision = user.decide('flag_key'); // Listener fires
});
// WRONG: registering after decide() misses the decision
optimizely.onReady().then(() => {
const user = optimizely.createUserContext(userId, attributes);
const decision = user.decide('flag_key'); // Decision is lost
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
callback // Too late
);
});
Python Implementation
The Python SDK uses the same notification listener pattern. Use the requests library to POST to Tealium's Collect HTTP API. Remember the DECISION callback receives four positional arguments (decision_type, user_id, attributes, decision_info), and decision_info keys are snake_case.
import os
import requests
from optimizely import optimizely
from optimizely.helpers import enums
TEALIUM_ACCOUNT = os.environ['TEALIUM_ACCOUNT']
TEALIUM_PROFILE = os.environ.get('TEALIUM_PROFILE', 'main')
TEALIUM_DATASOURCE = os.environ.get('TEALIUM_DATASOURCE') # HTTP API data source key
TEALIUM_COLLECT_URL = 'https://collect.tealiumiq.com/event'
# v3 authenticated alternative:
# TEALIUM_COLLECT_URL = 'https://collect.tealiumiq.com/v3/collect/event'
# TEALIUM_BEARER = os.environ['TEALIUM_BEARER_TOKEN']
def send_to_tealium(visitor_id, properties):
headers = {'Content-Type': 'application/json'}
# For the v3 endpoint, add:
# headers['Authorization'] = f'Bearer {TEALIUM_BEARER}'
body = {
'tealium_account': TEALIUM_ACCOUNT,
'tealium_profile': TEALIUM_PROFILE,
'tealium_datasource': TEALIUM_DATASOURCE,
'tealium_event': 'optimizely_decision',
'tealium_visitor_id': visitor_id,
**properties,
}
res = requests.post(TEALIUM_COLLECT_URL, headers=headers, json=body, timeout=5)
if not res.ok:
print(f'Tealium Collect failed: {res.status_code} {res.text}')
def on_decision(decision_type, user_id, attributes, decision_info):
if decision_type != 'flag':
return
flag_key = decision_info.get('flag_key')
enabled = decision_info.get('enabled')
variation_key = decision_info.get('variation_key')
rule_key = decision_info.get('rule_key')
send_to_tealium(
user_id,
{
'optimizely_flag_key': flag_key,
'optimizely_variation_key': variation_key if enabled else 'off',
'optimizely_rule_key': rule_key,
'optimizely_enabled': enabled,
},
)
# Initialize Optimizely
optimizely_client = optimizely.Optimizely(sdk_key=os.environ['OPTIMIZELY_SDK_KEY'])
# Register the listener BEFORE any decide() calls
optimizely_client.notification_center.add_notification_listener(
enums.NotificationTypes.DECISION, on_decision
)
# Make a decision — the listener fires automatically
user = optimizely_client.create_user_context('user_123', {'plan': 'pro'})
decision = user.decide('checkout_redesign')
print('Variation:', decision.variation_key)
The Collect API call is fire-and-forget from Optimizely's perspective: a slow or failed Tealium request should never block your flag decision. Wrap the HTTP call in a short timeout (as above) and, in production, move it off the request path into a queue or background worker so analytics latency never affects the user. The Collect API enforces a rate limit of roughly 100 requests per second per account, so a high-throughput service should batch via the /v3/collect/bulk-event endpoint or sample before sending.
Analyzing Experiments in Tealium
Tealium is a data pipeline, not a reporting tool — you do not read experiment results in Tealium itself. You use it to enrich and route the variation data, then analyze it in the destinations Tealium feeds.
Visitor Attributes in AudienceStream
Build AudienceStream visitor attributes from the optimizely_decision event — for example a string attribute "Last Optimizely Variation" set from optimizely_variation_key, or a boolean badge "In Checkout Redesign". Because the server forwards decisions keyed by visitor ID, these attributes accumulate on the unified visitor profile across both browser and server decisions.
Audiences and Activation
Create AudienceStream audiences combining a variation attribute with behavioral criteria ("Saw treatment AND has not converted"), then use Tealium connectors to activate that audience into ad platforms, email tools, or messaging — acting on experiment exposure across channels.
Warehouse Export for SQL Analysis
If you run a Tealium connector to a warehouse (Snowflake, BigQuery, Redshift) or cloud storage, the optimizely_flag_key and optimizely_variation_key attributes land as columns on event rows. Compute conversion rates by variation in SQL, joining optimizely_decision events to downstream conversions on the visitor ID. Because Tealium stitches identity across the browser and server surfaces, the variation label attaches to one unified visitor record — which is the main reason to route FX decisions through Tealium rather than integrating each destination separately.
Gotchas
Identity Matching Is Critical
The Collect API attaches data by tealium_visitor_id. If your server sends one identifier but the browser's Tealium visitor uses a different value, the server-side decision and the browser session belong to two different Tealium visitors and never join. Standardize on one identifier across both surfaces — typically your authenticated user ID mapped to Tealium's visitor ID — before relying on cross-surface analysis.
Browser utag.link vs Server Collect API Are Not Interchangeable
utag.link() exists only in the browser; the Collect HTTP API is the server path. Use the browser call when the decision happens client-side and the Collect API when it happens on your backend. Sending the same decision through both produces duplicate Tealium events for one decision.
v1 vs v3 Collect Endpoint
The v1 endpoint (collect.tealiumiq.com/event) is unauthenticated; the v3 endpoint (collect.tealiumiq.com/v3/collect/event) requires an Authorization: Bearer token and supports a /v3/collect/bulk-event batch endpoint (up to 10 events per request). Confirm which your account is provisioned for. Sending an unauthenticated body to the v3 endpoint returns 401; sending a Bearer header to v1 is simply ignored.
Use utag.link, Not utag.view, in the Browser
In the browser path, the decision is an interaction, not a navigation. Use utag.link(). utag.view() signals a page view and will inflate pageview counts in Tealium and every downstream analytics tag.
decisionEventDispatched and Count Inflation
Forwarding every decision — including flags delivered as targeted rollouts, where decisionEventDispatched is false — can inflate Tealium counts relative to Optimizely's experiment results. If you only want experiment impressions, gate the forward on decision_info.get('decision_event_dispatched') (Python) or decisionInfo.decisionEventDispatched (JS/Node) being truthy.
Collect API Rate Limit and Payload Flattening
The Collect API caps at roughly 100 requests per second per account; high-throughput services should batch with /v3/collect/bulk-event or sample. Note also that Tealium flattens nested JSON objects by lower-casing and joining keys with an underscore — keep the decision payload flat (as the examples do) so attribute names are predictable downstream.
tealium_event Is a Reserved Key
tealium_event names the interaction in Tealium and must be present for the event to be identifiable in EventStream, Trace, and connector mappings. Do not omit it or repurpose it for other data; keep it set to optimizely_decision (or another stable, intentional name).
Troubleshooting
Events Not Appearing in Tealium
Server path — wrong account/profile/datasource: A typo in
tealium_account,tealium_profile, ortealium_datasourcesends the event into the void with an HTTP 200. Verify the values against your Tealium Customer Data Hub and confirm the data source key in the Data Sources dashboard.v3 auth: A 401 from the v3 endpoint means a missing or expired Bearer token. The v1 endpoint does not authenticate, so a 401 there indicates you are actually hitting the v3 path.
Listener registered too late: Register the DECISION listener before the first
decide()call.Browser blocked: For the browser path, ad blockers block Tealium;
window.utagnever initializes and the guard skips the call.
Server Events Not Merging with Browser Visitor
The visitor ID does not match. Verify the tealium_visitor_id sent server-side reconciles with the identity Tealium uses in the browser. Until both agree, Tealium treats them as separate visitors and AudienceStream attributes split across two profiles.
Decision Listener Fires but Data Is Wrong
In Python, confirm you are reading snake_case keys (
flag_key,variation_key) — camelCase keys returnNone.Confirm you are filtering on
type === 'flag'; the same listener also receivesab-test,feature-test, and other decision types depending on the SDK method used.
Event Reaches Tealium but No Connector Fires
As with the browser path, utag.link() and the Collect API only put data into Tealium. Routing is a Tealium-side configuration step:
Confirm the Optimizely attributes are recognized in the Tealium data layer / data source.
Confirm the target connector has a mapping from the Optimizely attributes to its fields.
Confirm any event or trigger filter on the connector matches
optimizely_decision.Use Trace in the Tealium UI to follow a single visitor's events through EventStream and confirm where they stop.
Data Discrepancies Between Platforms
Counting unit: Optimizely counts unique users; Tealium resolves identity through its own stitching, and each downstream destination counts differently again.
Non-experiment decisions: Forwarding decisions where
decisionEventDispatchedisfalseinflates Tealium relative to Optimizely.Async failures: Dropped or timed-out Collect API calls silently lower Tealium counts; log failures so you can quantify the gap.
Dedup and sampling: Server-side dedup or sampling lowers Tealium counts by design; account for it when comparing.
Expect a 5–15% discrepancy for the same experiment, with a larger gap if you sample or dedup server-side, or if a connector's filter suppresses some events.