Integrate Pendo with Optimizely Feature Experimentation
TL;DR
Pendo is a product experience platform: it autocaptures page views and feature clicks in the browser, layers funnels, paths, and retention on top, and ships in-app guides, polls, and NPS surveys. 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 attaching it to the Pendo visitor — as a Track Event and a metadata update — so you can segment Pendo's analytics by the variation a user actually received and target guides at it.
Unlike Web Experimentation, Feature Experimentation has no Custom Analytics Integration UI. The bridge is a decision notification listener: a callback you register on the Optimizely client that fires every time decide() resolves a flag. Where that callback runs determines which Pendo surface you call. In the browser, you call Pendo's agent directly (pendo.track() and pendo.identify()). On the server, the browser pendo object does not exist, so you call Pendo's Track Events API over HTTP (POST https://app.pendo.io/data/track) — and identity matching becomes the critical detail, because the server has no access to Pendo's browser-side visitor. Throughout, remember that Pendo is primarily an in-app/web product: server-side event ingestion is supported only through the Track Events API, not a full server SDK.
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 Pendo as a visitor metadata update (so the experiment context attaches to the visitor profile and becomes a segment rule) and an "Experiment Viewed" Track Event (so it is searchable and chartable in Data Explorer).
flowchart LR
A["optimizely.decide('flag')"] --> B[DECISION notification fires]
B --> C{Where does the listener run?}
C -->|Browser| D["pendo.identify() + pendo.track()"]
C -->|Server| E["POST https://app.pendo.io/data/track"]
D --> F[Pendo visitor]
E --> F
F --> G[Segments, funnels, retention, guide targeting 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: it is true for an experiment (A/B test or multi-armed bandit) and false for a targeted delivery (rollout), because no impression event is dispatched for a rollout. Forwarding decisions where it is false can inflate your Pendo counts relative to Optimizely's experiment results.
Visitor Metadata Format
The integration writes a single visitor metadata field per flag, keyed by flag and valued by variation:
[Optimizely] checkout_redesign = treatment
This mirrors the convention used across Optimizely's own analytics integration examples and keeps multiple concurrent flags from colliding, since each flag writes to its own key.
Prerequisites
Before starting the integration:
Optimizely Feature Experimentation SDK installed for your platform (JavaScript SDK v6+, Node.js SDK, or Python SDK).
Pendo agent on the page (browser path) with
pendo.initialize()called for known visitors, and the Track Events feature enabled for your subscription on web SDK 2.14.3 or later —pendo.track()does nothing without it.Pendo Track Event credentials (server path): the Track Events API requires authentication. Pendo's documentation states server-side Track Events use the Track Event shared secret for your app (Settings > Subscription Settings > Applications > App Details > "Show" next to Track Event shared secret), and the
/data/trackendpoint is called with thex-pendo-integration-keyheader. Provision both your integration key (Settings > Integrations > Integration Keys) and the Track Event shared secret, and follow Pendo's Track endpoint API documentation for the exact header your account requires.HTTP client for server-side use — Node.js 18+ has
fetchbuilt in; Python uses therequestslibrary.Consistent visitor identity — the
visitorIdyou send to the Track Events API must match thevisitor.idPendo uses for that user in the browser (set viapendo.initialize({ visitor: { id } })). Without a matchingvisitorId, server-side experiment data lands on a different Pendo visitor than the browser activity, breaking cross-surface analysis.
JavaScript / Browser Implementation
When the Optimizely FX SDK runs in the browser, call the Pendo agent directly. The agent is already on the page, so no HTTP calls or API keys are required.
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;
const propertyName = `[Optimizely] ${flagKey}`;
const propertyValue = enabled ? variationKey : 'off';
// Visitor metadata: persists on the Pendo visitor profile
if (window.pendo && typeof window.pendo.identify === 'function') {
window.pendo.identify({ visitor: { [propertyName]: propertyValue } });
}
// Discrete, searchable Track Event (requires the Track Events feature)
if (window.pendo && typeof window.pendo.track === 'function') {
window.pendo.track('Experiment Viewed', {
flag_key: flagKey,
variation_key: variationKey,
rule_key: ruleKey,
enabled: enabled,
});
}
}
);
const user = optimizely.createUserContext(userId, userAttributes);
const decision = user.decide('checkout_redesign');
console.log('Variation:', decision.variationKey);
});
This is the simplest and most reliable path: Pendo resolves the visitor from its own browser state, so the experiment data automatically attaches to the correct visitor. Remember that pendo.track() only emits data when the Track Events feature is enabled for your subscription.
Node.js Implementation
On the server, the browser pendo object does not exist. Use Pendo's Track Events API over HTTP. There is no full Pendo server SDK — call the REST endpoint directly with fetch. Pendo's server-side ingestion is limited to Track Events (and the metadata API); features like guides and autocapture are browser-only.
import { createInstance, enums } from '@optimizely/optimizely-sdk';
const PENDO_INTEGRATION_KEY = process.env.PENDO_INTEGRATION_KEY;
const PENDO_TRACK_URL = 'https://app.pendo.io/data/track';
async function sendToPendo(visitorId, eventName, properties) {
const res = await fetch(PENDO_TRACK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-pendo-integration-key': PENDO_INTEGRATION_KEY,
},
body: JSON.stringify({
type: 'track',
event: eventName,
visitorId, // must match the browser pendo visitor.id
accountId: properties.account_id, // optional; include if you have it
timestamp: Date.now(), // epoch milliseconds
properties,
}),
});
if (!res.ok) {
console.error(`Pendo track 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;
const propertyValue = enabled ? variationKey : 'off';
await sendToPendo(userId, 'Experiment Viewed', {
flag_key: flagKey,
variation_key: propertyValue,
rule_key: ruleKey,
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 data to Pendo
return decision;
}
Authentication: Integration Key vs Track Event Shared Secret
Two credentials are involved on the server, and they are easy to confuse:
The integration key (Settings > Integrations > Integration Keys) authenticates Pendo's public API and is passed in the
x-pendo-integration-keyheader.The Track Event shared secret is a separate per-app credential that Pendo's documentation lists as required to send server-side Track Events.
Provision both and follow Pendo's Track endpoint API documentation for the exact credential your /data/track request must carry — the header in the example above (x-pendo-integration-key) is the one used by Pendo's own connector implementations. If you receive an authentication error, verify the integration key has write permission and has not been regenerated, and confirm whether your account requires the shared secret on this endpoint.
Updating Visitor Metadata Server-Side
The Track Events API records the event but does not set durable visitor metadata. To write the [Optimizely] field onto the visitor profile from the server, use Pendo's metadata API — POST https://app.pendo.io/api/v1/metadata/visitor/value with the same x-pendo-integration-key header:
async function setPendoVisitorMetadata(visitorId, fields) {
const res = await fetch('https://app.pendo.io/api/v1/metadata/visitor/value', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-pendo-integration-key': PENDO_INTEGRATION_KEY,
},
body: JSON.stringify([{ visitorId, values: fields }]),
});
if (!res.ok) {
console.error(`Pendo metadata update failed: ${res.status}`, await res.text());
}
}
The target custom metadata field must already exist in Pendo's metadata settings before values land on the profile.
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
);
});
Deduplication
If your application calls decide() on every request for the same user, the listener fires repeatedly and sends duplicate Track Events. Pendo's Track Events API does not expose an idempotency key, so deduplicate before sending: keep a per-session or short-TTL Set of visitorId:flag_key:variation_key combinations and skip the HTTP call when the combination was already sent.
const sent = new Set();
function alreadySent(visitorId, flagKey, variationKey) {
const key = `${visitorId}:${flagKey}:${variationKey}`;
if (sent.has(key)) return true;
sent.add(key);
return false;
}
Python Implementation
The Python SDK uses the same notification listener pattern. Use the requests library to call Pendo's Track Events 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 time
import requests
from optimizely import optimizely
from optimizely.helpers import enums
PENDO_INTEGRATION_KEY = os.environ['PENDO_INTEGRATION_KEY']
PENDO_TRACK_URL = 'https://app.pendo.io/data/track'
HEADERS = {
'Content-Type': 'application/json',
'x-pendo-integration-key': PENDO_INTEGRATION_KEY,
}
def send_to_pendo(visitor_id, event_name, properties, account_id=None):
body = {
'type': 'track',
'event': event_name,
'visitorId': visitor_id, # must match the browser pendo visitor.id
'timestamp': int(time.time() * 1000), # epoch milliseconds
'properties': properties,
}
if account_id:
body['accountId'] = account_id
res = requests.post(PENDO_TRACK_URL, headers=HEADERS, json=body, timeout=5)
if not res.ok:
print(f'Pendo track 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')
property_value = variation_key if enabled else 'off'
send_to_pendo(
user_id,
'Experiment Viewed',
{
'flag_key': flag_key,
'variation_key': property_value,
'rule_key': rule_key,
'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 Track Events API call is fire-and-forget from Optimizely's perspective: a slow or failed Pendo 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. Note that Pendo rejects Track Events whose timestamp is more than seven days old, so do not backfill stale decisions through this path.
Analyzing Experiments in Pendo
Segments
Build a segment per variation: in the segment builder add a Track Events rule on Experiment Viewed with an event-property filter where flag_key equals your flag AND variation_key equals a specific variation, then save it ("Checkout Redesign — treatment"). Reuse the segments across Data Explorer, funnels, paths, and retention. For guide targeting, build the segment on the [Optimizely] visitor metadata field instead, since guide-eligibility segments cannot use event-property filters.
Funnels and Retention by Variation
Create a funnel, apply a variation segment, and compare step conversion between variations. For retention, set the entry to the Experiment Viewed Track Event filtered by your flag, set the return event to a key feature or page, and compare retention curves across variation segments.
Targeting Guides by Variation
The reason to pair Pendo with Feature Experimentation rather than a chart-only tool is in-app guidance. Use a variation segment (built on the [Optimizely] visitor metadata field) as the audience for a guide, poll, or NPS survey to message exactly the users a given variation reached.
Gotchas
Identity Matching Is Critical
The Track Events API attaches data by visitorId. If your server sends visitorId = "user_123" but the browser identified the Pendo visitor with a different value (or pendo.initialize() ran anonymously), the server-side experiment data and the browser activity belong to two different Pendo visitors and never join. Standardize on one visitorId across both surfaces before relying on cross-surface analysis.
Track Events Feature Must Be Enabled
On the browser path, pendo.track() only emits data when the Track Events feature is enabled for your subscription and the agent is web SDK 2.14.3 or later. If it is off, the call silently sends nothing. The server /data/track endpoint is governed by the same Track Events capability and credentials.
Browser Agent vs Track Events API Are Not Interchangeable
pendo.track() exists only in the browser; POST /data/track exists only server-side. Use the agent when the decision happens client-side and the API when it happens on your backend. Sending the same decision through both produces duplicate Track Events.
decisionEventDispatched and Count Inflation
Forwarding every decision — including targeted deliveries (rollouts), where decisionEventDispatched is false — inflates Pendo counts relative to Optimizely's experiment results. If you only want experiment impressions, gate the forward on decision_info['decision_event_dispatched'] is True (Python) or decisionInfo.decisionEventDispatched === true (JavaScript).
Timestamp Must Be Recent and in Milliseconds
The Track Events API expects timestamp in epoch milliseconds. Pendo will not process events whose timestamp is more than seven days old, occurred before the Track Event name was defined, or predates the subscription. Send the current time at the moment the decision occurs; do not backfill stale decisions.
Property Naming and Size Limits
Pendo enforces Track Event property constraints: names use only letters, numbers, and underscores, must be under 32 characters, must not start with a number, and must not begin or end with double underscores. The entire properties object must stay under 512 bytes, and a property map over 25KB is discarded and replaced with a system-generated error property — so avoid using error as a property name. The flat keys used here (flag_key, variation_key, rule_key, enabled) comply.
No Idempotency Key on the Track Events API
Unlike some ingestion APIs, Pendo's /data/track endpoint does not accept an idempotency key. Deduplicate before sending (per-session Set of visitorId:flag_key:variation_key) if your service calls decide() repeatedly for the same user.
Custom Metadata Fields Must Pre-Exist
Writing the [Optimizely] field via the metadata API (or pendo.identify() in the browser) only sticks if the corresponding custom metadata field is configured in Pendo. Track Event properties need no setup; visitor metadata fields do.
Troubleshooting
Events Not Appearing in Pendo
Track Events not enabled: The most common cause. Confirm the Track Events feature is enabled and (browser) the agent is web SDK 2.14.3 or later.
Auth: Confirm the
x-pendo-integration-keyheader carries a valid integration key with write permission, and that you have the Track Event shared secret available if your account requires it on/data/track. A 401/403 means the credential is wrong, lacks permission, or was regenerated.Listener registered too late: Register the DECISION listener before the first
decide()call.Browser blocked: For the browser path, ad blockers block
cdn.pendo.io; thependoobject never initializes and queued calls never flush.Stale timestamp: An event older than seven days is not processed on the normal schedule.
Server Events Not Merging with Browser Activity
The visitorId does not match. Verify the value passed to the Track Events API equals the visitor.id used in the browser's pendo.initialize(). Until both agree, Pendo treats them as separate visitors.
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 SDK and method used.
Data Discrepancies Between Platforms
Counting unit: Optimizely counts unique users; Pendo counts by its own visitor/account identity model.
Non-experiment decisions: Forwarding decisions where
decisionEventDispatchedisfalseinflates Pendo relative to Optimizely.Async failures: Dropped or timed-out Track Events API calls silently lower Pendo counts; log failures so you can quantify the gap.
Processing delay and stale events: Track Events appear after standard Pendo processing, and events older than seven days are dropped.
Expect a 5–15% discrepancy for the same experiment, with a larger gap if server-side forwarding fails silently or identities do not match.