Skip to Content

Push primer (soft-ask)

iOS gives you exactly one chance to call the system notification permission prompt. Once a user taps Don’t Allow, you cannot ask again from inside the app — they have to go to Settings. The push primer is the soft-ask pattern that protects that one shot: show a friendly in-app card first, and only fire the real UNUserNotificationCenter.requestAuthorization prompt for users who already said yes to the card.

Users who tap “Not right now” never see the harsh system dialog, so the one-shot opportunity stays available for later. The SDK records a cooldown so the primer doesn’t nag.

InAppPushPrimer lives in the ActiveReachInApp companion product (Swift class InAppPushPrimer).

Why soft-ask

A cold system prompt forces an irreversible decision out of context — the user has no idea why the app wants to notify them. A soft-ask card explains the value first (order updates, back-in-stock alerts, reward expiry) in your own copy, with your own dismiss option that carries no permanent cost. Only the users who opt in on your card ever reach the OS dialog, so a “no” on the card is fully recoverable later.

How operators author it

The card content is configured in the dashboard, not hardcoded in your app. Operators create an in-app campaign with render type push_primer. The campaign supplies the title, body, allowButtonLabel, and laterButtonLabel (plus optional imageUrl, icon, and cooldownDays). Your app receives that config and maps it into InAppPushPrimer.Config.

The flow

Skip if already authorized

Before showing anything, check whether the user has already granted permission. If so, there is nothing to ask for.

Skip if in cooldown

If the user previously tapped “Not right now” (or dismissed the card), respect the cooldown window and don’t re-show.

Show the card

Present your soft-ask UI and call displayed(config:) so the in_app.displayed event is emitted.

Allow → system prompt

When the user taps Allow, call allowTapped(config:). This emits push_primer.allow_tapped, then triggers the real UNUserNotificationCenter.requestAuthorization. The outcome emits push_primer.permission_granted or push_primer.permission_denied, and on grant the SDK calls registerForRemoteNotifications().

Later → cooldown

When the user taps “Not right now”, call laterTapped(config:). This emits push_primer.later_tapped and records the cooldown timestamp, so the primer won’t re-show until the cooldown elapses.

Constructing the primer

InAppPushPrimer takes an event emitter and (optionally) a UserDefaults store. Wire the emitter to your analytics — typically Aegis.shared.track — so the six canonical events flow through the same ingestion path as the rest of your telemetry.

import ActiveReachInApp let primer = InAppPushPrimer( emit: { eventName, properties in Aegis.shared.track(eventName, properties: properties) } ) // Map the operator-authored in-app campaign config into Config. let config = InAppPushPrimer.Config( campaignId: "campaign_123", title: "Stay in the loop", body: "Get order updates, back-in-stock alerts, and reward reminders.", allowButtonLabel: "Turn on notifications", laterButtonLabel: "Not right now", imageUrl: nil, icon: nil, cooldownDays: 7 )

The EventEmitter signature is:

public typealias EventEmitter = (_ eventName: String, _ properties: [String: Any]) -> Void

cooldownDays defaults to 7 when the campaign config doesn’t specify one.

Wiring the lifecycle

// 1. Don't bother if permission is already granted. primer.isAlreadyAuthorized { authorized in guard !authorized else { return } // 2. Respect the cooldown from a previous "Not right now". guard !primer.isInCooldown(cooldownDays: config.cooldownDays) else { return } // 3. Present your soft-ask UI, then record the display. presentPrimerCard(config) // your own presentation primer.displayed(config: config) }

Hook your card’s buttons to the action methods:

// User tapped Allow → fires the system prompt, emits the outcome event. func onAllow() { primer.allowTapped(config: config) } // User tapped Not right now → records cooldown, no system prompt. func onLater() { primer.laterTapped(config: config) } // Card swiped away without a decision → treated as a soft "later". func onDismiss() { primer.dismissed(config: config) }

dismissed(config:) records the cooldown too — a swipe-to-dismiss is treated as a soft “not right now” so the primer doesn’t immediately re-appear.

Canonical events

The primer emits six canonical events through your emitter:

EventWhen
in_app.displayedThe primer card was shown
in_app.dismissedThe card was dismissed without a permission outcome
push_primer.allow_tappedUser tapped Allow — the system prompt fires next
push_primer.later_tappedUser tapped “Not right now” — cooldown recorded
push_primer.permission_grantedThe system prompt outcome was granted
push_primer.permission_deniedThe system prompt outcome was denied

Because these flow through your analytics emitter, you can build conversion funnels (displayed → allow_tapped → permission_granted) and measure soft-ask effectiveness per campaign.

Cooldown management

The SDK persists the cooldown timestamp in UserDefaults. Use the helpers to inspect and reset it:

// Should we skip showing the primer right now? let skip = primer.isInCooldown(cooldownDays: config.cooldownDays) // Clear the cooldown — e.g. when the operator resets opt-in state. primer.clearCooldown()

You can pass a custom UserDefaults (for example an App Group suite) when constructing the primer if you need the cooldown to be shared across targets:

let primer = InAppPushPrimer( emit: { Aegis.shared.track($0, properties: $1) }, defaults: UserDefaults(suiteName: "group.com.example.app") ?? .standard )

What’s next