In-Product Targeting: How We Built a 10KB Survey SDK for Formaly
Why email surveys are dying, how in-product targeting works, and the full technical story behind the Formaly SDK: triggers, audience conditions, display modes, session management, and all.
Arindam Majumder
Founder, Formaly
Email surveys have a response rate problem. The industry average is 10–30%. When you factor in deliverability, unsubscribes, and the increasing cost of sending to a disengaged list, you are often getting useful signal from fewer than one in ten people you actually want to hear from.
The core issue is context. An email survey arrives minutes, hours, or days after the experience you are asking about. The memory has faded. The frustration or delight that was visceral in the moment has been replaced by whatever happened next. You are asking people to reconstruct feelings they have already half-forgotten.
In-product surveys fix this. You ask the question at the exact moment the user has just done the thing you care about. They just upgraded; ask them why. They just churned; ask them what went wrong. They just completed onboarding; ask how it went. The context is live. The response is honest. And because the survey lives inside your product, not in an inbox competing with everything else, the response rate is dramatically higher.
Tools like Formbricks, Pendo, and Intercom have built entire product lines around this idea. We wanted to bring it to Formaly, and we wanted to do it in a way that was genuinely simple to set up. Here is what we built and how it works.
What “in-product targeting” actually means
The phrase sounds more complicated than it is. In-product targeting is three things:
1. A trigger
A condition that determines when the survey appears. Not "when the user opens the page" but "when the user has been on the pricing page for 30 seconds" or "when the user clicks the upgrade button."
2. An audience filter
A set of conditions on user attributes that determine who sees the survey. Not "every visitor" but "users on the Pro plan with more than 14 days of activity who have not already responded."
3. A display format
How the survey appears: a centered popup that demands attention, a slide-in panel that sits in the corner, or a persistent widget bubble that the user can open when they want.
The combination of all three is what makes in-product surveys effective. A trigger without an audience filter shows your survey to everyone, including people who have already answered it or people who have been using your product for two years and do not need an onboarding survey. An audience filter without a smart trigger shows the survey at the wrong moment. Display format affects whether the survey feels like an interruption or a natural part of the product.
The Formaly SDK
The SDK is a single JavaScript file you add to your page. It handles everything: fetching your surveys, evaluating triggers, checking audience conditions, rendering the survey in the right format, and tracking whether a given user has already seen or completed it.
Install
<!-- Add to your <head> or before </body> -->
<script src="https://formaly.io/formaly-sdk.js" defer></script>
<script>
Formaly.init({ apiKey: "fml_YOUR_KEY_HERE" });
</script>That is it for the basic setup. The SDK fetches all your active surveys that have targeting configured, registers their trigger listeners, and starts evaluating. No build step, no npm package, no framework dependency.
Identify your user
// Call this after the user logs in
Formaly.identify({
userId: "u_4a9f2b",
plan: "pro",
daysActive: 21,
role: "admin",
company: "Acme Corp",
});Formaly.identify() sets user attributes in memory. These attributes are then evaluated against the audience conditions you configure per survey. You can pass any key-value pairs you have: plan tier, days since signup, feature usage counts, role, anything that helps you target the right people.
Triggers: when the survey appears
Triggers are the conditions that fire the survey display. You configure them per survey in the Formaly editor, and you can combine multiple triggers; the survey shows when any of them fires first.
Delay
{ type: "delay", value: 5000 }Show the survey after the user has been on the page for N milliseconds. The most common trigger; gives users time to form an opinion before asking for it.
Scroll Depth
{ type: "scroll", value: 60 }Fire when the user has scrolled past N% of the page. Ideal for blog posts and documentation; you know they read it before you ask about it.
Exit Intent
{ type: "exit-intent" }Detect when the cursor moves toward the browser chrome (a sign the user is about to leave) and show the survey as a last-chance prompt.
Element Click
{ type: "element-click", selector: "#feedback-btn" }Attach the survey to a specific CSS selector. When that element is clicked, the survey appears. Perfect for a persistent 'Give Feedback' button.
Page View
{ type: "page-view" }Show immediately on page load; no delay, no scroll, no click required. Use sparingly; this is best for single-page flows like a post-checkout thank-you page.
Manual
Formaly.show("survey_id")Call the SDK programmatically at any point in your application logic. Useful for triggering a survey after a specific user action: completing a task, upgrading, or reaching a milestone.
Each trigger also accepts an optional urlPattern: a regex string matched against the current URL. This lets you scope a trigger to specific pages. A delay trigger with urlPattern: "/pricing" only fires on the pricing page, not everywhere.
Audience conditions: who sees the survey
Triggers control when. Conditions control who. You set conditions per survey, and you choose whether all conditions must match (AND) or any single one is enough (OR).
| Operator | Example |
|---|---|
equals | plan equals "pro" |
notEquals | plan notEquals "free" |
contains | email contains "@company.com" |
greaterThan | daysActive greaterThan 14 |
lessThan | sessionCount lessThan 3 |
exists | userId exists |
notExists | companyId notExists |
A realistic targeting rule might look like: show this NPS survey to users where plan equals “pro” AND daysActive is greater than 14. This excludes free users and users who just signed up, both groups likely to give you NPS data that does not represent your actual product experience.
Display modes
The same survey can feel like a jarring interruption or a natural product element depending on how it is displayed. We built three modes:
Popup
Centered modal overlay with a backdrop. Takes full attention; best for short NPS or CSAT surveys where you need a clear, unambiguous response. The backdrop prevents interaction with the page until dismissed.
Best for
Post-purchase confirmation, feature launch announcement, high-priority one-question surveys.
Slide-in
A panel that animates in from a corner of the screen. Non-blocking; the user can continue using the page while the survey is visible. Sits in the corner without demanding attention.
Best for
Ongoing feedback, CSAT after a support interaction, any survey where you want to reduce friction and allow dismissal.
Widget
A floating bubble in the corner that expands into a full panel on click. Permanently available without interrupting anything. The user decides when to engage; zero forced interaction.
Best for
Persistent feedback buttons, 'How are we doing?' prompts, product portals where you want feedback always available.
Slide-in and widget modes also let you set the corner position: bottom-right, bottom-left, top-right, or top-left. This matters for products where the bottom-right corner is already occupied by a chat widget or help bubble.
Session controls: the cooldown system
One of the most important details in in-product surveys is what happens after someone sees one. Without controls, you can end up showing the same survey to the same person every time they visit a page, which is annoying enough to make them disable your product's JavaScript.
The SDK tracks two things per survey per browser in localStorage:
fml_shown_{id}Timestamp of the last time this survey was displayed. Compared against cooldownDays to decide whether to show again.
fml_completed_{id}Set permanently when the user finishes the survey (detected via postMessage from the embed iframe). A completed survey never re-shows, ever.
Setting cooldownDays: 0 disables the cooldown; the survey will show every time the trigger fires (assuming it has not been completed). This is useful for testing, or for genuinely ephemeral prompts like a post-payment confirmation survey that should always show after a purchase.
How we built it
Here is the technical story: the decisions we made, the constraints we worked within, and the details that were less obvious than they seemed.
Starting with the embed
Formaly already had an embed system: a /embed/[id] iframe endpoint and a public/embed.js script for inserting the iframe into third-party pages. That gave us the survey rendering layer for free. The SDK just needed to be the orchestration layer on top: fetch targeting rules, evaluate them, and decide when and how to inject the iframe.
The targeting data model
We extended the SurveyScript JSONB type in the database with a targeting field. Rather than a separate table, the targeting config lives inside the survey's existing script column; no migration needed, no schema change. Each survey's targeting is fetched in a single row select. The schema covers triggers (array of trigger objects), conditions (audience rules), matchAll (AND vs OR logic), cooldownDays, displayMode, and position.
A dedicated SDK endpoint
We created GET /api/sdk/surveys, a lightweight read endpoint authenticated by API key. It returns only the active surveys that have targeting configured, and only the fields the SDK needs: id, title, defaultSurveyMode, and the targeting object. No question content, no responses, no personal data. The endpoint is the only network call the SDK makes on page load.
Writing the SDK in TypeScript
The SDK is a single TypeScript file at lib/sdk/formaly-sdk.ts; no framework, no external dependencies, no imports from the project. It compiles to a standalone IIFE via esbuild. The entire minified output is under 10KB. It registers window.Formaly on the global scope, exposes the public API, and manages all state internally through class properties and localStorage.
Trigger listeners
Each trigger type maps to a different DOM strategy. Delay uses setTimeout. Scroll uses a passive scroll event listener that compares window.scrollY + innerHeight against document.body.scrollHeight. Exit intent listens for mouseleave on the document with a Y threshold to distinguish tab switching from genuine exit intent. Element click uses event delegation on document.body rather than querying the selector repeatedly. Page view fires synchronously in init().
Condition evaluation
The condition evaluator is a pure function, evalCondition(condition, attributes), that handles all nine operators: equals, notEquals, contains, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual, exists, and notExists. Attribute values from Formaly.identify() are stored in memory and compared against the stored conditions. matchAll determines whether all conditions must pass (AND) or any single one (OR).
Session management
The cooldown system uses localStorage keys prefixed with fml_shown_{id} (stores the timestamp of last display) and fml_completed_{id} (set when the postMessage completion event fires). On each trigger evaluation, the SDK checks both keys before deciding to show. A cooldownDays of 0 means always show. The completion key is permanent; a completed survey never re-shows unless the key is cleared.
Cross-origin completion detection
The survey runs inside an iframe from a different origin (formaly.io). When the respondent finishes, the iframe sends a postMessage with type 'formaly-complete' and the survey ID. The SDK listens for this on the parent window, fires registered 'complete' callbacks, marks the completion in localStorage, and hides the widget. This is how the SDK knows a survey was finished without any server round-trip.
What the final output looks like
The compiled SDK is a single formaly-sdk.js file, minified by esbuild, targeting ES2018 (supported in all modern browsers without polyfills). The final size is under 10KB. It has zero external runtime dependencies, injects its own CSS animations, and works with any JavaScript framework, or no framework at all.
Here is the complete public API:
Public API
// Initialize with your API key
Formaly.init({ apiKey: "fml_...", debug: false });
// Set user attributes for audience targeting
Formaly.identify({ userId: "u_123", plan: "pro", daysActive: 14 });
// Show a specific survey manually
Formaly.show("survey_id");
// Hide a specific survey (or all if no ID given)
Formaly.hide("survey_id");
// Listen for events
Formaly.on("complete", ({ surveyId }) => {
console.log("Survey completed:", surveyId);
});
Formaly.on("shown", ({ surveyId }) => {});
Formaly.on("dismissed", ({ surveyId }) => {});
// Remove event listener
Formaly.off("complete", handler);
// Clean up all listeners and DOM nodes
Formaly.destroy();Setting it up in Formaly
Targeting is configured per survey directly in the form editor; no code needed for most use cases. Here is the flow:
Go to any survey → Edit → scroll to In-Product Targeting at the bottom.
Toggle "Enable In-Product Targeting" on. A default delay trigger is added automatically.
Choose your display mode (popup, slide-in, or widget) and set the position.
Add or edit triggers. Set delay in milliseconds, scroll depth as a percentage, or paste a CSS selector for element-click.
Add audience conditions. Type an attribute name, pick an operator, and set the value. Toggle AND/OR matching.
Set cooldown days. 7 is the default; the same user won't see the survey again for a week.
Save the form. The targeting config is live immediately; the SDK picks it up on the next page load.
Your API key is available on the API Keys page in the dashboard. The same page has the full SDK install instructions with copyable code snippets.
Why this matters for how you collect feedback
The highest quality feedback you can collect is from users who are currently using your product, asking about the specific thing they just did, in a format that does not require them to leave their workflow.
A 5-question NPS survey sent via email two weeks after a user upgraded will get you some data. An NPS widget that appears 10 seconds after they hit the upgrade confirmation page (triggered only for users on the new plan, with a 30-day cooldown so they never see it twice) will get you better data from users whose experience is still immediate and whose motivation to respond is high.
The difference is not just response rate. It is response quality. Context-aware feedback is more specific, more actionable, and more honest. Users are not reconstructing an experience; they are reporting one they just had.
In-product targeting is how you close the gap between what users tell you and what they actually experienced. We built it because we needed it ourselves, and we shipped it because every team collecting product feedback deserves it.
Try in-product targeting
Set up the Formaly SDK in under five minutes. Create a survey, configure targeting, add the script tag, and start collecting in-context feedback from the users who matter most.