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?”
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
Event Experience Survey
Takes about 1–2 minutes
Your input makes for better benefits.
When did you first
use the service?
Have you seen the
November event?
Was the benefit
attractive?
Pick the channels where you
get event news
Select all that apply
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.
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.
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.
| Segment | Definition | What the branch digs into (hypothesis) |
|---|---|---|
| A Unaware | Never saw the event | Exposure — was there no touchpoint, or did they see it and ignore it (H1) |
| B Participant | Joined the event | Friction · value · trust in the participation process (H2) |
| C Non-participant | Saw it but didn't join | Reason for not joining — value · fatigue · circumstance (H2 · H3) |
| E Low-frequency | Low activity / request frequency | Low-frequency user path |
The full survey flow, branching by user type, looks like this.
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.
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.
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.
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.
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.
// 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.
| key | meaning | example value |
|---|---|---|
| current_page | Last page the user stayed on | q0-1b |
| q0_0_first_use | First-use timing | within_6m · 1year_plus |
| q0_1a_request_count | Core actions in the last year | 3_4 · none |
| q0_1b_usage_period | Period of concentrated use | 1week · year_round |
| isLowFrequency | Whether they entered the low-frequency path | true · false |
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_ID | EVENT_TIME_UTC | EVENT_TIME_KST | PROPERTIES · promo_survey_completed |
|---|---|---|---|
| u_10··abcd | 2026-02-23T03:19Z | 2026-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··efgh | 2026-02-22T18:18Z | 2026-02-23 03:18 | { …,"segment":"B","segment_label":"Participant","[Q] Was the benefit attractive?":"3 points", …} |
| u_99··wxyz | 2026-02-21T07:24Z | 2026-02-21 16:24 | { …,"segment":"E","segment_label":"Low-frequency","[Q] Preferred benefit?":"Guaranteed type", …} |
[Q] human-readable questions and answers are held together in one row.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.)
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.
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.
Related · next in the series Action and reward on one screen — a gamified opt-in IAM A record of implementing the “immediate feel · experience recall” this survey pointed to, as a flow of terms consent → game → instant result.