Popup frequency and limits
A marketer can decide how often one customer sees a block instance or an in-app campaign: once ever, once per day, up to three times a week, or until the customer taps or closes it. Items marked as a popup (a modal, popup or interstitial) also share workspace-wide popup limits, so customers never see stacked or excessive popups. The server applies these rules before it delivers content. From SDK 2.4.0 the SDK also keeps a local count on the device and has a call that picks the one popup a screen may show. This page follows a rule from the dashboard to the screen.
- Marketers set the rule under Frequency in the block editor or in a campaign's Schedule step, with presets such as Once per day; admins set the workspace Popup limits (by default 1 per session, 3 per day, 30 minutes apart). Popups work on every plan; a rule other than the default and changed popup limits need the Pro or Enterprise plan.
- With every SDK version, the server withholds a capped block from
/syncand a capped campaign from the campaigns call, counting only the events it has received. - From SDK 2.4.0, call
getPopup(screen)(useCFPopupin React) to get at most one popup for a screen, and track its impression and dismissal: that is what counts toward the rule. - Ask
frequencyReason(item)in the app, or Developers → Test mode → Why didn't it show? in the dashboard, to see which rule is holding an item back. - The most common trap: the server only counts events it receives, so without analytics consent, or without an impression tracked against the instance or campaign, nothing is ever capped on the server.
What works with each SDK version
shouldShow, getPopup, useCFPopup, frequencyReason and resetFrequency are new in SDK 2.4.0, in all four SDKs. Server-side caps also apply to SDKs before 2.4.0.| Part | SDKs before 2.4.0 | SDK 2.4.0 and later |
|---|---|---|
| Most views and most taps per hour, day, week, month or ever | Enforced by the server | Server, and the device's local count |
| Wait between two shows | Enforced by the server | Server and device |
| Never show again after a tap, a close or a conversion | Enforced by the server for block events. Campaign dismissals can't be sent before 2.4.0, and the Kotlin and Flutter SDKs send no campaign events at all before 2.4.0. | Server and device, blocks and campaigns |
| Most per app session | Not enforced: these SDKs don't send their session id with reads | Device and server: all four SDKs send their session id in X-CF-Session-Id |
| Workspace limit: most popups per day | Enforced by the server | Server and device |
| Workspace limit: minimum gap between two popups | Enforced by the server | Server and device |
| Workspace limit: most popups per session | Not enforced: these SDKs don't send their session id with reads | Device and server: all four SDKs send their session id in X-CF-Session-Id |
| At most one popup on a screen at a time | Your app's own logic | getPopup(screen) / useCFPopup(screen) |
| Caps that hold offline, from cache, and before the event batch is sent | No | Yes |
The rule a marketer sets
The same rule exists on a block instance (the Frequency section of the block editor, below Schedule) and on a campaign (the Frequency field in the Schedule step). The dashboard explains it as "How often one customer may see this. Enforced by the server and by the app, even offline." Pick a preset, or open Advanced for the individual fields.
| Preset | Rule it saves |
|---|---|
| Every time | No rule (the default for anything that isn't a popup) |
| Once ever | 1 view, lifetime |
| Once per session | 1 per app session |
| Once per day | 1 view per day. A new instance of a block type marked Popup starts here. |
| Up to N per week | N views per week (3 unless you change it) |
| Until the customer taps or closes it | Stops after a tap on its button or a dismissal |
| Custom | Anything set under Advanced that no preset describes |
Under Advanced, and in the API, the rule has these fields. Every count is a whole number from 1 to 1,000.
| Dashboard label | Field | What it does | Enforced by |
|---|---|---|---|
| Most views | maxImpressions | Most impressions in the period | Server and SDK |
| Most taps | maxClicks | Most taps or CTA clicks in the period | Server and SDK |
| Per | period | hour, day (default), week, month or lifetime (shown as Ever) | Server and SDK |
| Most per app session | maxPerSession | Most shows in one app session | SDK, and the server when the app sends X-CF-Session-Id |
| Wait between two shows (minutes) | minIntervalSeconds | Cooldown after an impression, up to 30 days. The dashboard takes minutes; the API takes seconds. | Server and SDK |
| Never show again after | stopOn | Any of cta_click, dismiss, conversion | Server and SDK |
| Count a republished version as new | resetOnNewVersion | Blocks only, on by default: each published change shows the content again to everyone. Campaigns never reset. | Server and SDK |
| This is a popup | interruptive | A modal, popup or interstitial: the workspace popup limits apply, and the app shows at most one at a time. Set on a popup block; a campaign makes a popup with a Popup channel, and only legacy campaigns carry it on their own rule. | Server and SDK |
| Exempt from workspace popup limits | bypassWorkspaceCap | For regulatory or transactional notices. Its own rule still applies. Only a workspace admin can switch it on; anyone else gets 403 FREQUENCY_BYPASS_ADMIN_ONLY. | Server and SDK |
A rule with enabled: false, or with no limit set, means no cap. An out-of-range value is rejected with a 400 (FREQUENCY_RULE_INVALID for a block instance). Windows are counted in the workspace time zone (Asia/Riyadh unless the workspace sets another), so "once per day" resets at local midnight and a week starts on Sunday.
Plan. Popups show on every plan. On the Free plan a block instance or campaign can only have no rule or the default Once per day (marking an item as a popup with no other limit counts as no rule); any other rule, including Exempt from workspace popup limits, needs the Pro or Enterprise plan and is otherwise refused with 403 UPGRADE_REQUIRED (503 PLAN_CHECK_UNAVAILABLE if the plan can't be read). The plan is checked only when a rule is saved: delivery never looks it up, so a workspace that moves to Free keeps the rules it already has running and can re-save them unchanged.
For a campaign, the same rule also limits push, SMS, WhatsApp and email resends; see Campaigns: limits. The rest of this page is about content the app pulls: blocks and in-app campaigns.
Workspace popup limits
Under Settings → Workspace → Popup limits, a workspace admin sets limits that apply across every popup block and in-app campaign (every item whose rule has This is a popup), except items exempted by an admin. 0 turns a limit off.
| Dashboard label | Default | Enforced by |
|---|---|---|
| Most popups per app session | 1 | The SDK and the server, from SDK 2.4.0 (the SDK sends its session id in X-CF-Session-Id) |
| Most popups per day | 3 | The server (a per-person daily counter), and the SDK from 2.4.0 |
| Minimum gap between two popups (minutes) | 30 | The server (the person's last popup), and the SDK from 2.4.0 |
The limits reach apps in every /sync response as config.frequency, together with the workspace time zone. A change can take up to a minute to be served. Only admins can change them; other roles see the values read-only. Changing a limit away from its default needs the Pro or Enterprise plan (403 UPGRADE_REQUIRED on Free); setting it back to the default is always allowed.
"config": {
"features": { "live_updates": true, "campaigns": true, "location": true, "frequency_caps": true },
"frequency": {
"timezone": "Asia/Riyadh",
"fatigue": { "maxPerSession": 1, "maxPerDay": 3, "minGapSeconds": 1800 }
}
}
Show a popup on a screen
From SDK 2.4.0, a screen asks for its one popup, shows it, and reports what the customer did. (Your app draws a block popup; the SDK can draw an in-app campaign popup and do the tracking for you: see Popups in your app.) The device counts at once; the server counts when the event batch arrives.
- The SDK asks for this device's blocks, as usual.
- ContentFlow checks every instance that has a rule, against the counts for the signed-in user and the device (the larger wins).
If this step is skipped: An instance whose rule is reached for this user or device is left out of the response.
- Each delivered block with a rule carries it as
frequency;config.frequencycarries the workspace time zone and popup limits. - Inside
getPopup('home'), the SDK asks for the in-app campaigns for thehomeplacement. ContentFlow checks the counts for campaigns that have a rule; each one with a rule carries it asfrequency.If this step is skipped: A campaign whose rule is reached is left out, the same way. With the campaigns switch off, the SDK sends nothing and gets no campaigns.
- Candidates are the screen's popup blocks and popup campaigns, highest priority first (a block without a priority counts as 5; ties keep the delivered order, blocks first). The first one its own rule and the workspace limits allow is returned.
If this step is skipped: Returns null when every candidate is held back by its own rule or by the workspace popup limits.
- The impression is counted in the device's ledger at once, even offline and without analytics consent.
- The impression, and later the dismissal, travel with the next event batch.
If this step is skipped: Without analytics consent nothing is sent, so the server never counts this person: only the device's own count applies.
- ContentFlow counts the block and campaign events for items that have a rule. An event the SDK resends with the same timestamp is counted once.
How the server counts and enforces
- What is counted. Impressions, taps and CTA clicks, dismissals and conversions that reach ContentFlow in an event batch with the block's
instanceIdor the campaign'scampaignId. Only items with an enabled rule are counted. Test traffic (an app on the_testkey) is capped the same way but on its own counters: a user signed in on a test and a live install never shares a count, and the dashboard's QA reset clears test counts only. - Whose count. Counts are kept for the signed-in user, else the device. When the server checks, it reads both and uses the larger, so a "once ever" popup seen before sign-in stays seen after it.
- Sessions. Each event batch carries the app's session id (
context.sessionId), so the server counts popups per session. It enforces the session limits only on reads that send the same id in theX-CF-Session-Idheader; without it the session limits are the device's alone. - Where. Windowed counts (hour, day, week, month), the last time shown, per-session counts and the workspace popup counts (per day, per session, and the time of the last popup) are kept for the length of their window. Lifetime counts and the stop events (tapped, closed, converted) are kept durably, so a "once ever" popup or one the customer closed never comes back. Durable rows expire 90 days after the item's schedule ends (400 days after the last event for an item with no end).
- When it's applied. On every
/syncand single-block read, before the response and its ETag are built, so a newly capped popup isn't answered with a stale304. In-app campaigns are filtered the same way when the app asks for a placement. - Erasure.
deleteIdentity()also erases the device's and its linked user's frequency counts and durable state, live and test.
In the app
The SDK keeps a small local ledger with the same rule and the same windows as the server, so a cap also holds before the event batch is sent, offline, from cache and within a session. The track calls you already make feed it: block impressions, taps, CTA clicks, dismissals and conversions, and campaign impressions, taps, dismissals and conversions. It counts without analytics consent (nothing leaves the device). It holds counters and timestamps only, never content, keeps what was counted before sign-in, and is cleared by logout() and deleteIdentity(). A new session starts on each app launch, after 30 minutes or more in the background, and on logout(), deleteIdentity() and resetFrequency(). The SDK sends the session id in X-CF-Session-Id on /sync, single-block reads and the campaigns call, and as context.sessionId in event batches.
Every request also declares the frequency-v1 capability (X-CF-SDK-Capabilities: erasure-partial-v1,frequency-v1). It is informational: the server caps every SDK the same way.
Show at most one popup on a screen
getPopup(screen) considers the screen's blocks and the in-app campaigns for the placement with the same name, but only items whose rule has This is a popup. Track the impression when you show it and the dismissal when the customer closes it: those events are what count. In React, useCFPopup tracks the impression for you and its dismiss() tracks the dismissal.
// 1. Ask for the one popup this screen may show (null when nothing may).
const popup = await client.getPopup('home');
if (popup?.kind === 'block') {
// 2. Show it and record the impression: this counts toward its rule.
showModal(popup.block);
client.trackImpression(popup.block);
// 3. When the customer closes it, record the dismissal.
onModalClose(() => client.trackDismiss(popup.block));
} else if (popup?.kind === 'campaign') {
showModal(popup.campaign);
client.trackCampaignImpression(popup.campaign);
onModalClose(() => client.trackCampaignDismiss(popup.campaign));
}import { useCFPopup } from '@contentflow/sdk/react';
function HomePopup() {
// 1. At most one popup for this screen, re-evaluated when content or the user changes.
// 2. The impression is tracked once it is shown.
const { popup, dismiss } = useCFPopup('home');
if (!popup) return null;
// 3. dismiss() records the dismissal and hides it.
return <PopupModal popup={popup} onClose={dismiss} />;
}import { useCFPopup } from '@contentflow/sdk/react-native';
function HomePopup() {
// 1. At most one popup for this screen, re-evaluated when content or the user changes.
// 2. The impression is tracked once it is shown.
const { popup, dismiss } = useCFPopup('home');
if (!popup) return null;
// 3. dismiss() records the dismissal and hides it.
return <PopupModal popup={popup} onClose={dismiss} />;
}// 1. Ask for the one popup this screen may show (nil when nothing may).
if let popup = await client.getPopup(screen: "home") {
switch popup {
case .block(let block):
// 2. Show it and record the impression: this counts toward its rule.
present(block)
_ = try await client.trackEngagement(.impression(block))
// 3. When the customer closes it:
// _ = try await client.trackEngagement(.dismiss(block))
case .campaign(let campaign):
present(campaign)
_ = try await client.trackCampaign(campaign, type: .impression)
// 3. When the customer closes it:
// _ = try await client.trackCampaign(campaign, type: .dismiss)
}
}// 1. Ask for the one popup this screen may show (null when nothing may), from a coroutine.
when (val popup = client.getPopup("home")) {
is Popup.Block -> {
// 2. Show it and record the impression: this counts toward its rule.
showModal(popup.block)
client.trackEngagement(CFEngagementEvent.IMPRESSION, popup.block.key, popup.block.instanceId)
// 3. When the customer closes it:
// client.trackEngagement(CFEngagementEvent.DISMISS, popup.block.key, popup.block.instanceId)
}
is Popup.Campaign -> {
showModal(popup.campaign)
client.trackCampaign(popup.campaign, EngagementType.Impression)
// 3. When the customer closes it:
// client.trackCampaign(popup.campaign, EngagementType.Dismiss)
}
null -> Unit
}final cf = ContentFlow.instance;
// 1. Ask for the one popup this screen may show (null when nothing may).
final popup = await cf.getPopup('home');
switch (popup) {
case CFBlockPopup(:final block):
// 2. Show it and record the impression: this counts toward its rule.
await cf.trackImpression(blockKey: block.key, instanceId: block.id, screen: 'home');
// 3. When the customer closes it, record the dismissal.
showMyBlockDialog(block, onClose: () => cf.trackDismiss(blockKey: block.key, instanceId: block.id));
case CFCampaignPopup(:final campaign):
await cf.trackCampaignImpression(campaign);
showMyCampaignDialog(campaign, onClose: () => cf.trackCampaignDismiss(campaign));
case null:
break;
}For a block or campaign you render yourself (not a popup), shouldShow(item) answers the same question for one item: true when its rule, and for a popup the workspace limits, allow it now. In JavaScript, getPopup also takes { placement, locale } when the campaign placement isn't named after the screen.
Ask why an item was held back
frequencyReason(item) answers null when the item may show, or one of the reasons below. It reads the device's ledger, so it also explains holds the server doesn't know about yet (a session limit, or an impression still waiting in the batch).
// 1. The block you want to show.
const offer = client.getBlock('offer_modal');
// 2. Why it may not show right now: null means it may.
const reason = offer ? client.frequencyReason(offer) : null;
// 3. For example 'capped_impressions', 'cooldown' or 'workspace_guard'.
console.debug('offer_modal frequency:', reason ?? 'can show');import { useCF, useCFBlock } from '@contentflow/sdk/react';
function OfferDebug() {
const cf = useCF();
// 1. The block you want to show (don't record an impression just for checking).
const offer = useCFBlock('offer_modal', { trackImpression: false });
// 2. Why it may not show right now: null means it may.
const reason = offer ? cf.frequencyReason(offer) : null;
// 3. For example 'capped_impressions', 'cooldown' or 'workspace_guard'.
return <code>{reason ?? 'can show'}</code>;
}import { Text } from 'react-native';
import { useCF, useCFBlock } from '@contentflow/sdk/react-native';
function OfferDebug() {
const cf = useCF();
// 1. The block you want to show (don't record an impression just for checking).
const offer = useCFBlock('offer_modal', { trackImpression: false });
// 2. Why it may not show right now: null means it may.
const reason = offer ? cf.frequencyReason(offer) : null;
// 3. For example 'capped_impressions', 'cooldown' or 'workspace_guard'.
return <Text>{reason ?? 'can show'}</Text>;
}// 1. The block you want to show.
if let offer = try await client.fetchBlock(key: "offer_modal") {
// 2. Why it may not show right now: nil means it may.
let reason = await client.frequencyReason(offer)
// 3. The wire name, for example "capped_impressions", "cooldown" or "workspace_guard".
print("offer_modal frequency:", reason?.rawValue ?? "can show")
}// 1. The block you want to show, from a coroutine.
val offer = client.fetchBlock("offer_modal")
// 2. Why it may not show right now: null means it may.
val reason = offer?.let { client.frequencyReason(it) }
// 3. The wire name, for example "capped_impressions", "cooldown" or "workspace_guard".
Log.d("ContentFlow", "offer_modal frequency: ${reason?.wireName ?: "can show"}")final cf = ContentFlow.instance;
// 1. The block you want to show.
final offer = cf.getBlock('offer_modal');
// 2. Why it may not show right now: null means it may.
final reason = offer == null ? null : cf.frequencyReason(offer);
// 3. The wire name, for example 'capped_impressions', 'cooldown' or 'workspace_guard'.
debugPrint('offer_modal frequency: ${reason?.value ?? 'can show'}');| Reason (wire name) | Dashboard status | Meaning |
|---|---|---|
capped_impressions | View limit reached | Most views reached in the current period |
capped_clicks | Tap limit reached | Most taps reached in the current period |
capped_session | (device, or server when X-CF-Session-Id is sent) | Most per app session reached |
cooldown | Waiting between shows | Shown less than the wait time ago |
stopped_on_cta_click, stopped_on_dismiss, stopped_on_conversion | Stopped: the customer tapped it / closed it / converted | A stop event in the rule happened |
workspace_guard | Workspace popup limit reached today | A popup held back by the workspace popup limits (on the device: per session, per day or minimum gap) |
Swift, Kotlin and Flutter return an enum (CFSuppressionReason.cappedImpressions, SuppressionReason.CappedImpressions, CFSuppressionReason.cappedImpressions) whose rawValue, wireName or value is the wire name.
Reset counts in a debug build
resetFrequency() forgets every local count on this device, so a "once ever" popup can be tested again. It doesn't touch the server's counts: clear those with Reset popup frequency (see Why didn't it show?). Never call it in a release build.
// 1. Only in development builds.
if (process.env.NODE_ENV !== 'production') {
// 2. Forget this device's local frequency counts.
client.resetFrequency();
}
// 3. Then reset the server's counts in Developers → Test mode.import { useCF } from '@contentflow/sdk/react';
function DebugMenu() {
const cf = useCF();
// 1. Only in development builds.
if (process.env.NODE_ENV === 'production') return null;
// 2. Forget this device's local frequency counts.
// 3. Then reset the server's counts in Developers → Test mode.
return <button onClick={() => cf.resetFrequency()}>Reset popup frequency</button>;
}import { Button } from 'react-native';
import { useCF } from '@contentflow/sdk/react-native';
function DebugMenu() {
const cf = useCF();
// 1. Only in development builds.
if (!__DEV__) return null;
// 2. Forget this device's local frequency counts.
// 3. Then reset the server's counts in Developers → Test mode.
return <Button title="Reset popup frequency" onPress={() => cf.resetFrequency()} />;
}// 1. Only in debug builds.
#if DEBUG
// 2. Forget this device's local frequency counts.
await client.resetFrequency()
#endif
// 3. Then reset the server's counts in Developers → Test mode.// 1. Only in debug builds.
if (BuildConfig.DEBUG) {
// 2. Forget this device's local frequency counts.
client.resetFrequency()
}
// 3. Then reset the server's counts in Developers → Test mode.import 'package:flutter/foundation.dart';
// 1. Only in debug builds.
if (kDebugMode) {
// 2. Forget this device's local frequency counts.
ContentFlow.instance.resetFrequency();
}
// 3. Then reset the server's counts in Developers → Test mode.Why didn't it show?
In the dashboard, Developers → Test mode → Why didn't it show? takes a User ID or a Device ID and lists every block instance that has a frequency rule, with its status for that person right now: Can show, or the reason it is held back. It reads the server's counts, so it can't see device-only holds (a session limit, or an impression the app hasn't sent yet). Test and live traffic are counted apart: a test device (or a user signed in only on test installs) is judged on its test counts, anyone else on live counts. Reset popup frequency clears that person's server-side test counts and stop state, for blocks and campaigns, so a "once ever" popup can be tested again. It works only for test users and devices (apps using the test SDK key); anyone else is refused and nothing is reset.
Each block instance with a rule also shows Suppressed by frequency next to its impressions, and a campaign shows the same count next to its funnel: deliveries withheld because the person had reached the limit, over the last 90 days. It counts withheld deliveries, not people.
The same data is available from the dashboard API (portal sign-in, not an SDK key):
| Route | What it does |
|---|---|
GET /api/v1/blocks/frequency/explain?userId=…&deviceId=…&blockKey=… | Every block instance with a rule and its status for that user or device. One of userId or deviceId is required (400 FREQUENCY_SUBJECT_REQUIRED). env=test|live picks the counters; without it the env is inferred from the device or user, and data.env says which was judged. |
GET /api/v1/blocks/frequency/suppressions?type=blk|cmp&ids=… | Suppressed-by-frequency counts per block instance (blk) or campaign (cmp), by reason and in total, up to 500 ids. |
POST /api/v1/blocks/frequency/reset { "userId": "…", "deviceId": "…" } | The QA reset of test counts and state (never live ones). Admins and editors only, audited. 403 FREQUENCY_RESET_TEST_ONLY for a user or device that isn't a test one; 207 when the campaign part could not be reset. |
The frequency_caps switch
frequency_caps is one of the four workspace feature switches a ContentFlow platform operator can turn off for a workspace. It is on by default, and on with no rules behaves exactly as if frequency control didn't exist. Off, the server applies no frequency rule (capped items are served again) and counts nothing; from SDK 2.4.0, the SDK also ignores its ledger: shouldShow is always true, frequencyReason is always null, and getPopup offers the top-ranked popup item. While it's off, Why didn't it show? says so and shows what would apply.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
| A "once per day" popup keeps showing | The server counts only events it receives. Check that analytics consent is granted, that the app tracks the impression with the block's instanceId or the campaign, and that the batch was sent. With SDK 2.4.0 and later, the device's own count closes this gap. |
| A popup that should show doesn't | Check Why didn't it show? for the user and device, or frequencyReason in the app. workspace_guard means the workspace popup limits are holding it; an admin can raise them, or exempt a regulatory notice. |
| A "once ever" popup can't be tested again | Reset the server's counts with Reset popup frequency (test users and devices only) and the device's counts with resetFrequency(). |
Saving a rule fails with 403 FREQUENCY_BYPASS_ADMIN_ONLY | Only a workspace admin can switch on Exempt from workspace popup limits. |
| A cap stopped working for a while | An operator may have turned frequency_caps off. Once-ever and stop rules still hold, and with SDK 2.4.0 and later the device's own count still applies. |
| "Once per day" resets at the wrong hour | Windows use the workspace time zone (Asia/Riyadh unless set). In Flutter, a workspace zone other than UTC and the fixed-offset Gulf zones is counted in the device's local time. |
Next
- Blocks & placements: the other delivery rules a block passes before a frequency rule is checked.
- Testing: test mode: point a test build at the test key so you can reset and re-test popups.