Hayeon Song

Growth · CRM · Article

Building a Braze IAM Survey Modal:
Validating a Promotion Plan with Real User-Response Data

A record of building — not a throwaway in-app, but a reusable multi-step branching survey engine — inside a single IAM message, and cross-checking the responses against the same users' actual behavioral data to finally close the question: “seasonality, or structure?”

Braze IAM Custom HTML / JS Custom Event · Attribute BigQuery Behavioral cross-check

Background

Why we decided to ask

For a marketer, promotions are homework that never ends. Which benefit will get a reaction, how to deliver it so participation grows, which mix will work this time — even after you wrap one up, the same questions come back next quarter. You vaguely know that the users who participated and the ones who never did want different things, but what that actually is, you patch over every time with internal hypotheses and past performance.

Then came a moment when that guesswork stopped working. The November promotion did well, but as we moved into December and January the efficiency visibly dropped off. The trouble was that the cause split two ways — was it the seasonality of the year-end holidays (an external factor), or a problem of structure like reach, benefit, or fatigue? The two call for opposite responses. If it's seasonality, you wait it out; if it's structural, you have to change the promotion design itself.

Existing performance data couldn't tell the two apart. Click-through and conversion rates show what dropped, but not why. So this time we stopped guessing and decided to ask users directly. Not “how was it?” — instead, we set up four hypotheses and designed the survey to validate them.

  • H1 ExposureThey didn't notice the promotion because notifications were OFF or channel reach was low
  • H2 ValueThe December–January benefit was less attractive than expected
  • H3 FatigueRepeated messages built up fatigue and they tuned it out
  • H4 SeasonalityExternal factors like year-end busyness or spending pressure

These four hypotheses became the skeleton of the survey itself.

Design principles

Sample design came before the screens

A survey doesn't get better just because you build the screens well. You have to decide first who to ask, and how many responses you need before you can trust them. Before building anything, we locked down three things.

  1. We split the audience by behavior. We graded activity level (Light · Mid · Heavy) by the frequency of core actions from January to October, then crossed that with November–January participation patterns in BigQuery to build segments. For each segment, we calculated in advance how many responses are needed to mean something, at a 90% confidence level and 8% margin of error. Rather than aiming for a high total response count, we set the goal as filling the minimum sample for each segment.
  2. We suppressed bias by design. Users with strong complaints raise their voices, benefit-sensitive users pile in, and the users who are actually too worn out and about to leave ignore the survey entirely. We put a mitigation in place for each type, and deliberately ran the first wave without any reward. Lead with a reward and you draw in only the benefit-sensitive users. We collected the pure responses first, then attached a reward in a second wave only to the segments that were short on sample.
  3. We made the responses joinable with behavioral data. This is ultimately what made us choose IAM over an external survey tool.

(The sample sizing and the question/branching design were refined together with an in-house UX researcher.)

Implementation · Why IAM

Why inside an IAM instead of an external survey tool

There are plenty of ways to collect a survey. You can build a separate survey page, or send a link by push or email. We still chose a Braze IAM modal, for three reasons.

  1. You ask in the very moment the user is already inside the app. There's no bouncing them out to an external page, so the barrier to responding is low.
  2. You can target the exposure by segment. Based on promotion participation, recent visits, or specific behavioral events, you can give participants and non-participants different paths.
  3. The biggest reason is the data. The responses attach directly to that user's Braze profile. External survey responses are a dataset that lives off on its own, but IAM responses stay as Custom Events and Attributes — they become segments, and they can even be cross-checked against the same user's actual behavioral data. That's exactly what made the analysis covered later possible.

Implementation · Page router

Turning one static message into a multi-step flow

A Braze IAM is, by default, a single static message — not a multi-step survey tool. So I put an SPA-style page router directly inside one Custom HTML message. Every question lives as a .bz-page[page-id] unit, and only one is shown active at a time.

The actual modal is made up of screens like these — intro, single-/multi-select, monthly event cards, all the way to a 5-point scale, all switching within one message.

An example reproducing the actual IAM modal screens. A radio advances to the next page the instant it's selected; a checkbox requires at least one selection before “Next” becomes active.
Static message · 1 HTML page .bz-page[q0-1] · display:block .bz-page[q0-0] · none .bz-page[q0-1a] · none .bz-page[q0-1b] · none goToPage() pageHistory · back stack q0-1 ← current q0-0 goBack() → pop → previous page
From a single static message, toggling only .bz-page[page-id] switches pages like an SPA, and pageHistory implements “back”.
goToPage()
var pageHistory = [];

function goToPage(pageId) {
  var currentPage = document.querySelector('.bz-page.active');

  // On entering the first question from the intro, fire the 'survey started' event
  if (currentPage && currentPage.getAttribute('page-id') === 'intro' && pageId !== 'intro') {
    surveyStarted = true;
    logSurveyStarted();
  }

  logProgressEvent(pageId);   // Save progress to the user profile

  if (currentPage) pageHistory.push(currentPage.getAttribute('page-id'));
  document.querySelectorAll('.bz-page').forEach(function (page) {
    page.classList.toggle('active', page.getAttribute('page-id') === pageId);
  });
}

A pageHistory stack also supports a “Back” button. For radio (single-select) questions, I hid the “Next” button and made them auto-advance the instant a choice is made; checkboxes (multi-select) only activate the button once at least one is chosen. Reducing the time spent on any one question page directly affects completion rate.

Implementation · Branching design

Same modal, a different survey for each respondent

The core of this survey was that we don't throw the same questions at everyone. The path branches based on the response, and at the end the user is classified into one of four segments.

segmentLabels
var segmentLabels = {
  'A': 'Unaware (never saw the event)',
  'B': 'Participant (joined the event)',
  'C': 'Non-participant (saw it but didn't join)',
  'E': 'Low-frequency user'
};

Each question's “Next” isn't a plain page move — it's wired to a handler that reads the response value and decides the next path. For example, if the annual request count is “none,” it branches to the low-frequency path; otherwise, to the general path. And after all the monthly event responses are in, the very next batch of questions changes depending on what type the person is.

handleQ0_1a() · finishEventQuestions()
function handleQ0_1a() {
  var value = getPageValue('q0-1a');
  if (value === 'none') {           // Low-frequency (E) path
    surveyAnswers.isLowFrequency = true;
    goToPage('q0-1-1');
  } else {
    surveyAnswers.isLowFrequency = false;
    goToPage('q0-1b');
  }
}

function finishEventQuestions() {
  if (seenEvents.length === 0)        return goToPage('a1'); // Unaware (A)
  if (participatedEvents.length > 0)  return goToPage('b1'); // Participant (B)
  goToPage('c1');                                            // Non-participant (C)
}

This branching isn't a UX device to smooth out the screen flow — it's a design for validating the hypotheses. The Unaware (A) path splits the exposure hypothesis — was there no touchpoint at all, or did they see it and ignore it? Participants (B) are asked about friction, value, and trust in the participation process; non-participants (C) are asked why they didn't join, split across value, fatigue, and circumstance. At the end, the common question where all paths converge asks “how has your intent changed versus November,” separating seasonality from a structural problem. This is exactly why different questions go out depending on who the respondent is.

SegmentDefinitionWhat the branch digs into (hypothesis)
A UnawareNever saw the eventExposure — was there no touchpoint, or did they see it and ignore it (H1)
B ParticipantJoined the eventFriction · value · trust in the participation process (H2)
C Non-participantSaw it but didn't joinReason for not joining — value · fatigue · circumstance (H2 · H3)
E Low-frequencyLow activity / request frequencyLow-frequency user path
The four paths get different questions but converge at the end on a common question (change in intent vs. November) to separate seasonality (H4) vs. structure.

The full survey flow, branching by user type, looks like this.

Screening · common First-use timing · frequency · notification opt-in Nov/Dec/Jan event awareness → path → participation · monthly repeat branch by response A · Unaware Didn't see the eventNo touchpoint, ordeliberate ignore? hypothesis · H1 B · Participant Participation experience:friction · value · trust hypothesis · H2 C · Non-participant Aware but didn't joinvalue · fatigue · circumstance hypothesis · H2·H3·H4 E · Low-frequency Promotion touchpoint ·barrier to joining hypothesis · H1·H2 Common convergence Change in intent vs. November → cause of fatigue Separating seasonality vs. structure Demographics Done
Each type takes a different path but converges at the common question. Every step's response is logged as a Custom Event/Attribute, so it's preserved even on drop-off.

Implementation · Dynamic generation

Don't hardcode — generate the monthly questions dynamically

This survey had to ask about three separate promotions — November, December, and January. Repeating “did you see the [month] event → how did you find out → did you participate → (satisfaction / reason for not joining)” for each month means dozens of static pages. So I kept a single question page (event-question) and re-rendered its contents each time with a template function.

event-question 1 template page showEventQuestion() eventList November December January × 3 Monthly question set · Saw it? · How did you find out? · Did you participate? · Satisfaction / reason for not joining
Instead of dozens of static pages, a function re-renders one page (event-question) for each month.
showEventQuestion()
var eventList = ['nov_event', 'dec_event', 'jan_event'];

function showEventQuestion() {
  var event = eventData[eventList[currentEventIndex]];
  var modal = document.getElementById('event-question-modal');

  if      (currentQuestionType === 'seen')         modal.innerHTML = buildSeenQuestion(event);
  else if (currentQuestionType === 'channel')      modal.innerHTML = buildChannelQuestion(event);
  else if (currentQuestionType === 'participated') modal.innerHTML = buildParticipatedQuestion(event);
  // …attract / non_participate_reason

  goToPage('event-question');
}

Even if events grow, you just add one key to the eventList array. Question text and images are injected from eventData, so the screen is a single page while in practice as many screens as month × question-type are generated dynamically.

By this point, the router, branching, dynamic generation, and data logging combine into a single reusable survey engine. The next survey isn't built from scratch — you just swap in the question definitions (eventData) and the branching rules. It's not a one-off where you reconstruct the in-app each time, but an asset designed to be implemented over and over.

Implementation · Data logging

From responses to data — and the 255-character wall

The most important thing in a survey modal isn't displaying the question — it's leaving the responses in a form usable in later campaigns. I logged them two ways. At completion, all responses are fired as a single Custom Event (promo_survey_completed), carrying the segment (path) and each answer together as properties.

※ The Custom Event/Attribute names in this article (promo_survey_completed, promo_survey_responses, etc.) are examples to illustrate the structure.

Log on each steplogProgressEvent()Accumulate Attributesurvives drop-offChunk split255 chars↑ · _2 · _3Completion event[Q] readable keysQuery Builder extractexternal_user_id
In-progress responses accumulate in an Attribute (preserved even on drop-off); completion goes to an Event. Extraction is via Query Builder.
saveSurveyData()
function saveSurveyData(status) {
  var segment = surveyAnswers.path || 'screenout';
  var eventProperties = {
    survey_status: status,
    segment: segment,
    segment_label: segmentLabels[segment] || segment,
    completed_at: new Date().toISOString()
  };
  Object.keys(surveyAnswers).forEach(function (key) {
    if (key === 'path' || key === 'isLowFrequency') return;
    var v = surveyAnswers[key];
    eventProperties[key] = Array.isArray(v) ? v.join(',') : String(v);
  });

  var bridge = window.brazeBridge || window.appboyBridge;
  bridge.logCustomEvent('promo_survey_completed', eventProperties);
  bridge.requestImmediateDataFlush();
}

I added one more detail here. I separated the on-screen text from the logged value, but at the same time also kept a human-readable form alongside it. If only code values pile up (q0_1a_request_count = none), you'd need a lookup table every time you read a report later. So I stored [Q] question text = answer text together with it.

Inside getPageValue()
surveyAnswers[key] = value;                        // Code value for analysis
surveyAnswers['[Q] ' + questionText] = answerText; // Human-readable value

The problem was mid-survey drop-off. If the event is only logged on completion, the responses of users who don't finish are all lost. So every time a page advanced, I saved the progress to a Custom Attribute — even on drop-off, the responses up to that point stay on the profile. This is a clear advantage over collecting responses with an external survey tool like Google Forms. With an external tool, any response not submitted to the end vanishes wholesale; here, the responses as of any page you've advanced past are preserved on the user profile. But Braze Attribute string values have a 255-character limit, so once responses pile up, a single JSON blob exceeds it. I split it into 250-character chunks across multiple Attributes, and cleaned up by emptying chunks no longer in use.

promo_survey_responses = { …accumulating response JSON… } over 255 chars split into 250-char chunks Chunk 1 · 250 chars → _responses Chunk 2 → _2 Chunk 3 → _3 _4 · _5 … unused → null
Work around the Braze Attribute 255-character string limit by splitting into 250-char chunks, and empty the chunks no longer in use.
logProgressEvent() — chunk split
var fullJson = JSON.stringify(responseData);
var CHUNK_SIZE = 250;
var totalChunks = Math.ceil(fullJson.length / CHUNK_SIZE);
var user = bridge.getUser();

for (var i = 0; i < totalChunks; i++) {
  var chunk = fullJson.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
  var attrName = i === 0 ? 'promo_survey_responses' : 'promo_survey_responses_' + (i + 1);
  user.setCustomUserAttribute(attrName, chunk);
}
// Clean up chunks that were larger in a previous round
for (var j = totalChunks; j < 5; j++) {
  var name = j === 0 ? 'promo_survey_responses' : 'promo_survey_responses_' + (j + 1);
  user.setCustomUserAttribute(name, null);
}

In practice, the responses accumulate like this. While in progress, partial responses pile up in a Custom Attribute; completed responses stack up as an event. This event is recorded to Braze right away, but in our in-house analytics environment (Amplitude) it wasn't visible, because the Custom Event forwarding option in Currents was turned off. Turning forwarding on spikes the event volume and strains the analytics tool's ingestion limit, so for the purpose of extracting only the survey responses, the Braze Query Builder was the better fit. Pulled with Query Builder, it comes out as rows holding both the code values and the human-readable questions and answers.

Custom Attribute · promo_survey_responses (accumulating in progress)
// Entering q0-0 — start
{"current_page":"q0-0"}

// Right after the first response
{"current_page":"q0-1a","q0_0_first_use":"within_6m"}

// After a branch response — values keep accumulating
{"current_page":"q0-1b","q0_0_first_use":"within_6m","q0_1a_request_count":"3_4"}

// Complete — even on drop-off, this much is preserved on the profile
{ …, "q0_1b_usage_period":"1week", "isLowFrequency":false }

The main keys stored in the attribute are as follows.

keymeaningexample value
current_pageLast page the user stayed onq0-1b
q0_0_first_useFirst-use timingwithin_6m · 1year_plus
q0_1a_request_countCore actions in the last year3_4 · none
q0_1b_usage_periodPeriod of concentrated use1week · year_round
isLowFrequencyWhether they entered the low-frequency pathtrue · false
While in progress, these values accumulate in the attribute. The completion event (promo_survey_completed) adds segment · segment_label · [Q] human-readable question/answer on top of these.
Braze Query Builder · SQL
SELECT
  external_user_id AS external_id,
  TO_TIMESTAMP(time) AS event_time_utc,
  CONVERT_TIMEZONE('UTC', 'Asia/Seoul', TO_TIMESTAMP(time)) AS event_time_kst,
  properties
FROM
  USERS_BEHAVIORS_CUSTOMEVENT_SHARED
WHERE
  name = 'promo_survey_completed'
  AND time >= DATE_PART('epoch_second', CONVERT_TIMEZONE('Asia/Seoul', 'UTC', TO_TIMESTAMP('2026-02-01 00:00:00')))
ORDER BY
  time DESC;

There was one trap here. The initial draft query I was handed called the user_id column, but that value comes out in a hashed form that didn't match the user identifiers we operate on. Switching to call the external_user_id column directly, the real, unhashed external_id was extracted correctly — the query above is that final form.

EXTERNAL_IDEVENT_TIME_UTCEVENT_TIME_KSTPROPERTIES · promo_survey_completed
u_10··abcd2026-02-23T03:19Z2026-02-23 12:19{"survey_status":"completed","segment":"A","segment_label":"Unaware","q0_0_first_use":"6m_1year","[Q] When did you first use it?":"6 months–1 year ago", …}
u_15··efgh2026-02-22T18:18Z2026-02-23 03:18{ …,"segment":"B","segment_label":"Participant","[Q] Was the benefit attractive?":"3 points", …}
u_99··wxyz2026-02-21T07:24Z2026-02-21 16:24{ …,"segment":"E","segment_label":"Low-frequency","[Q] Preferred benefit?":"Guaranteed type", …}
Example of the structure of the Braze Query Builder extract result (not actual data/identifiers). The code values and the [Q] human-readable questions and answers are held together in one row.
Stored values (attribute · event)Segment conditionFollow-up targeting[Q] Preferred benefit = GuaranteedGuaranteed-pref usersGuaranteed-emphasis campaignsegment = A (Unaware)Unaware usersReach-boost campaigncurrent_page = value questionDropped at value stepBenefit-message retargeting
The stored values become segment conditions, which carry through to the targeting of follow-up campaigns.

By continuously updating responses and promotion-related records into user attributes like this, each user's profile itself becomes a segment condition. Responses such as “users who said they prefer a guaranteed benefit” or “users who said they didn't notice the event” are used directly as targeting conditions, becoming the branching and send audience of follow-up campaigns. The survey isn't a one-and-done collection of results — it becomes an input for segmentation and follow-up targeting. Beyond simply collecting responses, it's designed to bank the responses as a CRM asset.

Results

Cross-checking responses against behavioral data

Here is where “why inside an IAM” pays off. Because the responses are attached to the user profile, we could cross-check the survey responses against the same users' actual activity, participation, and prize data. With an external survey tool, that analysis would have been impossible. (The specific figures are confidential, so below they're replaced with qualitative phrasing.)

Reach was already broken from the start. Over 80% of visitors didn't notice the event at all, and more than half of those for structural reasons like no notification capture or no visit.
What users wanted wasn't a lottery — it was certainty. A majority preferred a guaranteed benefit, while the lottery type came down to a tiny minority, and the top reason for not participating was “I didn't think I'd win.” The current lottery structure itself was blocking participation.

The strongest finding came from the cross-check.

A large majority of the users who actually participated in the November promotion answered that they “didn't see” that event, and half of the actual prize winners answered that they “didn't receive” the benefit.

They acted, but it never registered as an experience — neither the participation nor the win stayed in the user's memory.

The promotion wasn't creating new demand — it was harvesting existing demand. Visitors with no core actions at all during the event period — a not-insignificant share of the whole — essentially didn't participate in the promotion, whereas the most positive group was new signups (highest participation positivity and lowest indifference).

And so the original question — seasonality or structure — was answered and closed. When we asked drop-off users why their intent had cooled, “unchanged” and “don't remember” were most of it, and the share citing fatigue came down to a small minority. The sharp efficiency drop wasn't due to the year-end mood or message fatigue, but a structural problem: it simply wasn't reaching them in the first place (the unawareness rate topped 80%), and even when it reached them it didn't register (a large majority of participants “didn't see” it). The seasonality hypothesis was rejected, and exposure and experience recall remained the core.

Next experiment

Ask, record, and experiment again

The direction the cross-check pointed to was clear. Users respond to an immediately felt, guaranteed reward over an uncertain lottery, and above all, if an action doesn't register as a result, neither participation nor a win means anything. The survey modal, then, wasn't an opinion-collection tool. It was the starting point of a loop — ask users first, leave the responses as data, change the structure of the next campaign with that data, and check performance again. MarTech doesn't end at operating tools. It's the work of designing a structure to ask, record, and experiment again.

In the next article, I'll cover the next promotion experiment that reflects these findings and insights.