Integrate Braze with Optimizely Feature Experimentation
TL;DR
Braze is a customer engagement platform: it holds a profile per user, and it sends the email, push, in-app messages and Canvas journeys that profile qualifies for. Optimizely Feature Experimentation decides which variation of a flag each user receives. Forwarding those decisions into Braze puts the variation onto the profile as a custom attribute, which is the primitive every segment, Canvas branch and message filter in Braze reads.
The reason to do this is not reporting. It is that a customer engagement platform can act on an experiment while it is running. Users in the new onboarding variation get the onboarding email that matches what they actually saw. A Canvas branches on the variation instead of sending everyone the same message and hoping. And when the experiment ends, the segment that tells you which users were exposed to the losing arm is already there for the apology, the migration, or the follow-up.
Feature Experimentation has no Custom Analytics Integration UI, so the bridge is a decision notification listener: a callback registered on the Optimizely client that fires every time decide() resolves a flag.
How the Integration Works
Every decision becomes two things in Braze: a custom attribute on the profile, so the user can be segmented on it forever, and a custom event, so the moment of exposure is on the timeline and can trigger a Canvas.
flowchart LR
A["user.decide('checkout_redesign')"] --> B[DECISION notification fires]
B --> C{Where does the listener run?}
C -->|Browser or app| D["braze.getUser().setCustomUserAttribute()"]
C -->|Server| E["POST /users/track"]
D --> F[Braze user profile]
E --> F
F --> G[Segments by variation]
F --> H[Canvas entry and branching]
Decision Notification Data
The listener's decision info object carries these fields for a flag-type decision. The browser and Node SDKs expose camelCase keys; the Python SDK exposes snake_case keys.
Field (camelCase / snake_case) | Type | Description |
|---|---|---|
| string | The flag key that was evaluated |
| boolean | Whether the flag is on 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 |
Choosing Between the Two Options
There are two ways to get a decision into Braze, and they are alternatives rather than steps. Pick one per surface.
Option A: Braze SDK, client-side | Option B: | |
|---|---|---|
Runs in | Browser, iOS, Android | Any backend |
Identity | The user already set with |
|
Latency to profile | Next SDK flush | Next API call, immediate on flush |
Batching | Handled by the SDK | Yours: up to 75 objects of each type per request |
Anonymous users | Supported — Braze keeps an anonymous profile | Not supported; an |
Best when | Decisions are made where the Braze SDK already runs | Decisions are made on the server, or in bulk |
If your flags are evaluated in the browser and Braze is already initialized there, take Option A: it needs no credentials and no queueing. Take Option B when the decision happens on a backend, or when you are backfilling a population that has already been bucketed.
Prerequisites
Optimizely Feature Experimentation SDK for your platform — the JavaScript SDK v6 or later, the Node.js SDK, or the Python SDK.
For Option A: the Braze Web SDK initialized with your API key and the SDK endpoint for your cluster, and
braze.changeUser()already called for logged-in users.For Option B: a REST API key with the
users.trackpermission, and the REST endpoint for your cluster — Braze instances are region-specific and a key from one instance will not authenticate against another.A shared identifier. The Braze
external_idmust be the same string passed tocreateUserContext(). If they differ, the attribute lands on a profile that is not the user the experiment bucketed, and nothing about that failure looks like an error.A naming convention for the attribute. This article uses
flag_<flagKey>. Avoid a leading$, which Braze reserves for its own fields.
Option A: Forward from the Braze SDK
import * as braze from '@braze/web-sdk';
import { createInstance, enums } from '@optimizely/optimizely-sdk';
braze.initialize('<YOUR_BRAZE_API_KEY>', { baseUrl: '<YOUR_SDK_ENDPOINT>' });
braze.openSession();
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 value = enabled ? variationKey : 'off';
// Identify first: attributes set before changeUser() land on the
// anonymous profile and are merged only under Braze's own merge rules.
braze.changeUser(userId);
// Segmentable forever
braze.getUser().setCustomUserAttribute(`flag_${flagKey}`, value);
// Timestamped, and usable as a Canvas entry trigger
braze.logCustomEvent('Experiment Viewed', {
flag_key: flagKey,
variation_key: value,
rule_key: ruleKey,
});
},
);
const user = optimizely.createUserContext('user_123', { plan: 'pro' });
const decision = user.decide('checkout_redesign');
console.log('Variation:', decision.variationKey);
});
The Braze SDK batches its outbound requests, which is what you want on a page but not what you want in a test. When you need the data on the profile immediately — verifying the integration, or firing a Canvas the user should see in this session — call braze.requestImmediateDataFlush() after the event. Do not call it on every decision in production; that is one HTTP request per flag evaluation.
Option B: Forward with the REST API
On the server, post to /users/track. One request can carry an attributes object and an events object for the same user, so a decision costs one call rather than two.
const optimizelySdk = require('@optimizely/optimizely-sdk');
const { enums } = require('@optimizely/optimizely-sdk');
const BRAZE_ENDPOINT = process.env.BRAZE_REST_ENDPOINT; // e.g. https://rest.iad-01.braze.com
const BRAZE_KEY = process.env.BRAZE_REST_API_KEY;
async function trackDecision(userId, decisionInfo) {
const { flagKey, enabled, variationKey, ruleKey } = decisionInfo;
const value = enabled ? variationKey : 'off';
const body = {
attributes: [
{
external_id: userId,
[`flag_${flagKey}`]: value,
_update_existing_only: true,
},
],
events: [
{
external_id: userId,
name: 'Experiment Viewed',
time: new Date().toISOString(),
properties: { flag_key: flagKey, variation_key: value, rule_key: ruleKey },
},
],
};
const response = await fetch(`${BRAZE_ENDPOINT}/users/track`, {
method: 'POST',
headers: {
Authorization: `Bearer ${BRAZE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const result = await response.json();
// Braze returns 201 with an "errors" array for partially accepted requests:
// some objects were written and some were rejected. A 2xx is not success.
if (!response.ok || (result.errors && result.errors.length > 0)) {
console.error('braze /users/track rejected objects', result.errors || response.status);
}
}
const optimizely = optimizelySdk.createInstance({ sdkKey: process.env.OPTIMIZELY_SDK_KEY });
optimizely.onReady().then(() => {
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
({ type, userId, decisionInfo }) => {
if (type !== 'flag') return;
trackDecision(userId, decisionInfo).catch((error) => console.error(error));
},
);
});
_update_existing_only: true is the flag that decides whether this integration can create users. Left at its default, a decision for an ID Braze has never seen creates a profile — which is how a staging user ID, a load test, or an anonymous visitor ends up on your billable user count. Set it to true unless creating profiles is what you actually want.
The other detail worth internalising is the error shape. Braze accepts a request that contains rejected objects and reports them in an errors array alongside a success status. Code that checks only response.ok will report a clean integration while silently dropping every attribute whose name Braze disliked.
Batching Server-Side Decisions
One request per decision will exhaust your rate limit under real traffic. /users/track accepts up to 75 objects of each type per request, so buffer and send in batches:
const queue = [];
function enqueue(userId, decisionInfo) {
const value = decisionInfo.enabled ? decisionInfo.variationKey : 'off';
queue.push({
attribute: {
external_id: userId,
[`flag_${decisionInfo.flagKey}`]: value,
_update_existing_only: true,
},
event: {
external_id: userId,
name: 'Experiment Viewed',
time: new Date().toISOString(),
properties: { flag_key: decisionInfo.flagKey, variation_key: value },
},
});
if (queue.length >= 75) flushQueue();
}
async function flushQueue() {
const batch = queue.splice(0, 75);
if (batch.length === 0) return;
await fetch(`${BRAZE_ENDPOINT}/users/track`, {
method: 'POST',
headers: { Authorization: `Bearer ${BRAZE_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
attributes: batch.map((item) => item.attribute),
events: batch.map((item) => item.event),
}),
});
}
setInterval(flushQueue, 5000);
Python Implementation
The Python SDK's DECISION callback takes four positional arguments and exposes snake_case keys in decision_info. Reading decision_info.get('flagKey') returns None without raising, and Braze will happily store an attribute named flag_None.
import os
from datetime import datetime, timezone
import requests
from optimizely import optimizely
from optimizely.helpers import enums
BRAZE_ENDPOINT = os.environ['BRAZE_REST_ENDPOINT']
BRAZE_KEY = os.environ['BRAZE_REST_API_KEY']
def track_decision(user_id, flag_key, value, rule_key):
body = {
'attributes': [
{
'external_id': user_id,
f'flag_{flag_key}': value,
'_update_existing_only': True,
}
],
'events': [
{
'external_id': user_id,
'name': 'Experiment Viewed',
'time': datetime.now(timezone.utc).isoformat(),
'properties': {
'flag_key': flag_key,
'variation_key': value,
'rule_key': rule_key,
},
}
],
}
try:
response = requests.post(
f'{BRAZE_ENDPOINT}/users/track',
json=body,
headers={'Authorization': f'Bearer {BRAZE_KEY}'},
timeout=2,
)
payload = response.json()
if response.status_code >= 400 or payload.get('errors'):
print(f'braze rejected objects: {payload.get("errors", response.status_code)}')
except requests.RequestException as error:
print(f'braze request failed: {error}')
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')
track_decision(user_id, flag_key, variation_key if enabled else 'off', rule_key)
optimizely_client = optimizely.Optimizely(sdk_key=os.environ['OPTIMIZELY_SDK_KEY'])
optimizely_client.notification_center.add_notification_listener(
enums.NotificationTypes.DECISION, on_decision
)
user = optimizely_client.create_user_context('user_123', {'plan': 'pro'})
decision = user.decide('checkout_redesign')
print('Variation:', decision.variation_key)
In production, move the HTTP call off the request path. A Braze outage should slow nothing and break nothing: the flag decision has already been made by the time the listener fires, so queue the write and let a worker drain it.
Verifying the Integration
Trigger a decision for a test user whose
external_idyou know.On the client, call
braze.requestImmediateDataFlush(); on the server, wait for the batch to flush.In Braze, open Users > User Search and look up the
external_id.Confirm the Custom Attributes panel shows
flag_checkout_redesignwith the expected variation.Confirm Experiment Viewed appears in the user's event timeline with its properties.
Build a temporary segment on the attribute and confirm the user falls into it. A custom attribute that exists on a profile but is not selectable in the segment builder has not been registered yet — Braze needs to have seen it before it offers it.
Analyzing and Acting on Experiments in Braze
Segment by variation. Create a segment with the filter flag_checkout_redesign equals treatment. Everything else in Braze consumes segments, so this one filter unlocks targeting, reporting and message eligibility at once.
Branch a Canvas. Use Experiment Viewed as a Canvas entry trigger and add an Action Path that splits on the attribute. Each arm of your Optimizely experiment then receives messaging matched to what it saw, instead of the average of both.
Suppress the wrong message. The most common practical win is negative: exclude the treatment segment from a campaign that describes the old experience. A help email with screenshots nobody recognises does measurable damage to the very metric the experiment is trying to move.
Do not read results in Braze. Braze counts the users a message reached; it does not run a significance test on your experiment. Take the outcome from Optimizely's results page or your warehouse, and use Braze for the acting.
Troubleshooting
Symptom | Likely cause | Fix |
|---|---|---|
Attribute never appears |
| Use one identifier for both systems |
401 from | Key from a different Braze instance | Use the REST endpoint and key for your cluster |
2xx but nothing written | Objects rejected inside a partially accepted request | Read the |
New profiles appearing unexpectedly |
| Set it to |
Attribute rejected | Name begins with | Prefix with |
Client-side attributes on the wrong profile | Set before | Identify first, then set attributes |
Rate limit errors under load | One request per decision | Batch up to 75 objects and flush on a timer |
Segment counts exceed Optimizely's exposed users | Rollout decisions forwarded as well as experiment decisions | Filter on |
Related Reading
Integrate Segment with Optimizely Feature Experimentation — if Segment already feeds Braze, forward the decision once and let the CDP fan it out.
Integrate mParticle with Optimizely Feature Experimentation — the same argument for an mParticle-centred stack.
Integrate Localytics with Optimizely Feature Experimentation — another engagement platform reading the same decision listener.