Home / Guides / Popup frequency and limits
Product guide

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.

In short
  • 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 /sync and a capped campaign from the campaigns call, counting only the events it has received.
  • From SDK 2.4.0, call getPopup(screen) (useCFPopup in 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.
Availability

What works with each SDK version

SDK 2.4.0 and latershouldShow, 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.
PartSDKs before 2.4.0SDK 2.4.0 and later
Most views and most taps per hour, day, week, month or everEnforced by the serverServer, and the device's local count
Wait between two showsEnforced by the serverServer and device
Never show again after a tap, a close or a conversionEnforced 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 sessionNot enforced: these SDKs don't send their session id with readsDevice and server: all four SDKs send their session id in X-CF-Session-Id
Workspace limit: most popups per dayEnforced by the serverServer and device
Workspace limit: minimum gap between two popupsEnforced by the serverServer and device
Workspace limit: most popups per sessionNot enforced: these SDKs don't send their session id with readsDevice and server: all four SDKs send their session id in X-CF-Session-Id
At most one popup on a screen at a timeYour app's own logicgetPopup(screen) / useCFPopup(screen)
Caps that hold offline, from cache, and before the event batch is sentNoYes
In the dashboard

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.

PresetRule it saves
Every timeNo rule (the default for anything that isn't a popup)
Once ever1 view, lifetime
Once per session1 per app session
Once per day1 view per day. A new instance of a block type marked Popup starts here.
Up to N per weekN views per week (3 unless you change it)
Until the customer taps or closes itStops after a tap on its button or a dismissal
CustomAnything 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 labelFieldWhat it doesEnforced by
Most viewsmaxImpressionsMost impressions in the periodServer and SDK
Most tapsmaxClicksMost taps or CTA clicks in the periodServer and SDK
Perperiodhour, day (default), week, month or lifetime (shown as Ever)Server and SDK
Most per app sessionmaxPerSessionMost shows in one app sessionSDK, and the server when the app sends X-CF-Session-Id
Wait between two shows (minutes)minIntervalSecondsCooldown after an impression, up to 30 days. The dashboard takes minutes; the API takes seconds.Server and SDK
Never show again afterstopOnAny of cta_click, dismiss, conversionServer and SDK
Count a republished version as newresetOnNewVersionBlocks only, on by default: each published change shows the content again to everyone. Campaigns never reset.Server and SDK
This is a popupinterruptiveA 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 limitsbypassWorkspaceCapFor 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.

In the dashboard

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 labelDefaultEnforced by
Most popups per app session1The SDK and the server, from SDK 2.4.0 (the SDK sends its session id in X-CF-Session-Id)
Most popups per day3The server (a per-person daily counter), and the SDK from 2.4.0
Minimum gap between two popups (minutes)30The 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 }
  }
}
End to end

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.

Sync → pick one popup → show → count
  1. The SDK asks for this device's blocks, as usual.
  2. 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.

  3. Each delivered block with a rule carries it as frequency; config.frequency carries the workspace time zone and popup limits.
  4. Inside getPopup('home'), the SDK asks for the in-app campaigns for the home placement. ContentFlow checks the counts for campaigns that have a rule; each one with a rule carries it as frequency.

    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.

  5. 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.

  6. The impression is counted in the device's ledger at once, even offline and without analytics consent.
  7. 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.

  8. 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.
Server side

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 instanceId or the campaign's campaignId. Only items with an enabled rule are counted. Test traffic (an app on the _test key) 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 the X-CF-Session-Id header; 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 /sync and single-block read, before the response and its ETag are built, so a newly capped popup isn't answered with a stale 304. 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

In the app

SDK 2.4.0 and laterEverything in this section is new in SDK 2.4.0. With SDKs before 2.4.0, rely on the server-side caps above and decide in your own code which popup a screen shows.

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 statusMeaning
capped_impressionsView limit reachedMost views reached in the current period
capped_clicksTap limit reachedMost taps reached in the current period
capped_session(device, or server when X-CF-Session-Id is sent)Most per app session reached
cooldownWaiting between showsShown less than the wait time ago
stopped_on_cta_click, stopped_on_dismiss, stopped_on_conversionStopped: the customer tapped it / closed it / convertedA stop event in the rule happened
workspace_guardWorkspace popup limit reached todayA 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.
Diagnostics

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):

RouteWhat 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.
Operations

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.

Troubleshooting

When it doesn't work

What you seeWhy, and the fix
A "once per day" popup keeps showingThe 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'tCheck 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 againReset 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_ONLYOnly a workspace admin can switch on Exempt from workspace popup limits.
A cap stopped working for a whileAn 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 hourWindows 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.