Integrate Braze with Optimizely Feature Experimentation

Loading...·8 min read

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

flagKey / flag_key

string

The flag key that was evaluated

enabled / enabled

boolean

Whether the flag is on for this user

variationKey / variation_key

string

The delivered variation key

ruleKey / rule_key

string

The experiment or delivery rule that matched

decisionEventDispatched / decision_event_dispatched

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: /users/track REST API, server-side

Runs in

Browser, iOS, Android

Any backend

Identity

The user already set with changeUser()

external_id you supply per object

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 external_id is required

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.track permission, 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_id must be the same string passed to createUserContext(). 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

  1. Trigger a decision for a test user whose external_id you know.

  2. On the client, call braze.requestImmediateDataFlush(); on the server, wait for the batch to flush.

  3. In Braze, open Users > User Search and look up the external_id.

  4. Confirm the Custom Attributes panel shows flag_checkout_redesign with the expected variation.

  5. Confirm Experiment Viewed appears in the user's event timeline with its properties.

  6. 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

external_id does not match the Optimizely user ID

Use one identifier for both systems

401 from /users/track

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 errors array, not just the status code

New profiles appearing unexpectedly

_update_existing_only left at its default

Set it to true

Attribute rejected

Name begins with $, a Braze reserved prefix

Prefix with flag_

Client-side attributes on the wrong profile

Set before changeUser()

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 decisionEventDispatched

Related Reading