Messages
/messages lets you publish in-app tooltips, modals, and banners that fire on specific events. Once a campaign is set to Active and the product’s auto-render toggle is on, the SDK on your customers’ sites will render the campaign automatically — no per-customer integration required.
The list
Each message has a status badge: Draft, Active, or Paused.
- New message — give the message a name, then jump to the editor.
- Edit — opens the editor.
- Preview — opens
/messages/:id/previewto rehearse the flow without firing real events. - Delete — removes the message with an undo toast (you have a few seconds to Undo before it’s gone).
The editor
The editor (/messages/:id) has four sections plus an A/B Test badge if the message is part of an experiment (see A/B-test variants below).
Name
A human-readable label.
Trigger
When the message should fire:
- Event name — the event that triggers the message (e.g.
page_viewed,$pageview,signup_started). Auto-captured events ($pageview,$click,$form_submit) work as triggers. - URL regex (optional) — additionally limits firing to URLs whose
location.pathnamematches the regex. Examples:^/pricing$for the exact pricing page,^/checkoutfor any URL under/checkout. - Audience (optional) — gates rendering by user properties. See Audience targeting below.
Steps
One or more steps that play in order. Each step has:
- Type — Modal, Banner, or Tooltip. Steps within one campaign can mix types.
- CSS selector (Tooltip only) — the element the tooltip anchors to (e.g.
#upgrade-btn). - Content — the message body.
- CTA label / CTA URL (optional) — adds a button.
When a campaign has multiple steps, the renderer adds a “Next →” button on non-final steps and a “Done” button on the final step. The user can also click × on any step to dismiss the campaign. See Step lifecycle.
Frequency cap
How often the same user can see this campaign:
- Once per session — within one browser session only. New session ⇒ user sees it again. (The SDK handles this in memory; the server doesn’t filter.)
- Once per day — once every 24 hours, measured from the user’s last terminal interaction.
- Once ever (default) — the safe default — once the user dismisses, clicks CTA, or completes the flow, they never see it again.
The cap counts only terminal interactions — dismiss, cta_click, or complete. A user who sees step 1 of a 3-step campaign and walks away (no dismiss, no CTA) is not considered “consumed” and may see the campaign again later.
Saving
In the top-right of the editor, change the status (Draft / Active / Paused) and click Save.
Auto-rendering on your customers’ sites
Auto-render is off by default at the product level. Active campaigns won’t appear on customer sites until you flip the toggle.
Flip the product toggle
Go to Settings → Products → [your product] → In-app messages and turn on Auto-render in-app messages.
Mark the campaign Active
In the message editor, set the status to Active and save.
Wait for the SDK to refetch
The customer’s SDK fetches the active list on init and on tracker.identify(), plus on window focus after >30 min idle. There’s a 5-minute sessionStorage cache, so changes can take up to 5 min to propagate to in-progress sessions.
The user does the trigger event
The next time the user fires the trigger event on a URL that matches the URL regex, the campaign renders.
Step lifecycle
| User action | What fires | What happens |
|---|---|---|
| Renderer mounts step 0 | message_impression | One per campaign — not per step |
| Click “Next →” on a non-final step | (no event) | Current step dismisses, next step renders |
| Click “Done” on the final step | message_complete | Flow ends successfully — counts toward cap |
Click × on any step | message_dismiss | Flow ends — counts toward cap |
| Click CTA on any step | message_cta_click | Flow ends, optional URL opens — counts toward cap |
See Event taxonomy for the full payload shape of each event.
Audience targeting
Currently supported: score-based audiences that target users by predicted churn risk.
In the trigger config, set the audience to:
{ "type": "score", "churnProbGt": 0.5 }Either or both of:
churnProbGt— only users whoseuser_scores.churnProbis strictly greater than this value qualify.topN— only users whose churn-risk rank within the tenant is belowtopNqualify.
When both are set, the user must clear both conditions.
Other audience types (userFilterTree, custom property filters) are on the roadmap but not yet shipped. Campaigns with no audience field render for everyone.
Preview
The Preview button on the editor opens /messages/:id/preview — a sandbox that renders the campaign exactly as it would on a customer’s site, but with a no-op track callback so no events fire to the backend. Use it to rehearse multi-step flows and check copy/positioning before going Active.
A/B-test variants
You can A/B test two (or more) versions of a campaign — different copy, different CTA, different timing — and let TrackCrumb pick a winner via the same Bayesian engine that powers Experiments.
Create a variant
- Open an existing message and click Create variant.
- The dashboard clones the message (steps, trigger, frequency cap copied), creates a new Experiment with
control(the original) andtreatment(the clone) arms, creates a flag at 50% rollout linking them, and routes you to the clone’s editor. - Edit the clone — change the copy, CTA text, step structure, anything you want to test.
- Set both messages to Active and ensure the product’s auto-render toggle is on. Done.
How rendering picks a variant
When the SDK fetches active campaigns, the response groups variant messages under one logical campaign with a variants map:
{
"id": "msg-original",
"experimentId": "exp-abc",
"variants": {
"control": { "messageId": "msg-original", "steps": [...] },
"treatment": { "messageId": "msg-clone", "steps": [...] }
}
}The SDK calls flagsManager.getVariant("<flag_key>") to deterministically pick an arm by distinct_id, then renders the matching variant’s steps[]. The same user always sees the same arm.
Frequency cap aggregates across variants
When a campaign has variants, the cap aggregates: a user who consumes (dismisses, clicks CTA, or completes) the control variant will not see the treatment variant in the same session — and vice-versa. Cap-checking sums terminal events across all variant message ids of the same experimentId.
Reading results
Open the linked experiment from the A/B Test badge on the message editor. The Experiments results panel shows winner + per-arm breakdown + SRM check + apply-winner — see Experiments for details.
When you click Apply winner, the linked flag flips to 100% and that variant becomes the only one rendered. The losing variant message remains in the dashboard for your records but stops appearing on customer sites.
Test one thing at a time. If your control and treatment differ in copy AND timing AND CTA, you won’t know which change moved the metric. Two variants × one change = clear answer.
Frequency cap mechanics
When the SDK fetches /api/v1/messages/active?distinct_id=<user>, the server consults the message_impressions table:
ever— excludes if any terminal event exists for(message_id, distinct_id).day— excludes if a terminal event exists withcreated_atwithin the last 24 h.session— server returns the campaign; the SDK’s in-memorySet<messageId>blocks within-session re-renders.
Terminal events are dual-written by the SDK: once via the standard /e ingest path (so they show up in funnels and trends like any other event) and once via POST /api/v1/messages/:id/track (which populates message_impressions for the cap to read).
Troubleshooting
- Campaign isn’t rendering. Check (1) status is Active, (2) product toggle is on, (3) trigger event name matches what the SDK actually fires (open the Network tab and look for
/erequests), (4) URL regex matcheslocation.pathname, (5) the cap hasn’t been consumed (try a differentdistinct_id). - Campaign re-renders unexpectedly across sessions. Check the frequency cap.
sessionis per-browser-session; for cross-session capping usedayorever. - Changes don’t propagate. The 5-minute SDK cache delays in-flight sessions. Reload the customer’s site to force a refresh.