Track push notification CTR in FlutterFlow by attaching a tracking payload to every FCM notification (campaign_id, notification_id), handling the FirebaseMessaging.onMessageOpenedApp stream in a Custom Action, and writing an open event to Firestore. Deduplicate opens using a compound document ID (notification_id + user_id) to prevent double-counting. Compute CTR by dividing unique opens by sends in a Cloud Function.
FCM Notification Analytics with Firestore and Deduplication
Click-through rate (CTR) for push notifications measures what percentage of delivered notifications users actually tap. Without proper deduplication, CTR calculations become unreliable — the same user opening the app twice after receiving one notification gets counted as two opens. This tutorial builds a complete tracking pipeline: structured campaign documents in Firestore that log sends, a client-side open handler that writes deduplicated open events, and a Cloud Function that computes CTR percentages for your analytics dashboard.
Prerequisites
- A FlutterFlow project with Firebase and FCM configured
- Push notification sending already working (either via FlutterFlow's built-in notification action or Cloud Functions)
- Basic familiarity with Firestore collections and FlutterFlow Custom Actions
Step-by-step guide
Structure Firestore collections for notification campaigns and opens
Structure Firestore collections for notification campaigns and opens
Create a 'notification_campaigns' Firestore collection. Each document represents one notification blast with fields: id, title (the notification headline), body, target_audience (String: all/segment/individual), sent_count (Integer), open_count (Integer), ctr (Float — computed field), created_at (Timestamp), and variant (String: A/B for A/B testing). Create a second collection called 'notification_opens'. Each document uses a compound ID format of '{notification_id}_{user_uid}' as the document ID — this automatically prevents duplicate open records for the same user-notification pair. Fields: notification_id, campaign_id, user_uid, opened_at (Timestamp), source (String: notification/deeplink). In FlutterFlow, import both collections via the Firestore panel.
Expected result: Both collections appear in FlutterFlow's Firestore panel. The notification_opens collection enforces uniqueness per user per notification via its composite document ID.
Add tracking data to FCM notification payloads
Add tracking data to FCM notification payloads
When you send a notification from a Cloud Function, add a 'data' map alongside the notification body. Include three fields: campaign_id (the Firestore campaign document ID), notification_id (a UUID generated per send batch), and tracking_enabled: 'true'. The data map is delivered to the app even when the app is in the background or terminated — unlike the notification map which iOS may process before the app wakes. In FlutterFlow's built-in notification action, add Custom Data key-value pairs for these tracking fields. This data is what your client-side handler will read when the user taps the notification.
1// Sending a tracked notification from Cloud Function2const admin = require('firebase-admin');3const { v4: uuidv4 } = require('uuid');45async function sendTrackedNotification(tokens, campaignId, title, body) {6 const notificationId = uuidv4();7 // Log the send to Firestore8 await admin.firestore().collection('notification_campaigns').doc(campaignId).update({9 sent_count: admin.firestore.FieldValue.increment(tokens.length),10 last_sent_at: admin.firestore.FieldValue.serverTimestamp(),11 });1213 // Send FCM with tracking data14 return admin.messaging().sendMulticast({15 tokens,16 notification: { title, body },17 data: {18 campaign_id: campaignId,19 notification_id: notificationId,20 tracking_enabled: 'true',21 },22 apns: { payload: { aps: { sound: 'default', badge: 1 } } },23 android: { priority: 'high' },24 });25}Expected result: Notifications sent via this function include campaign_id and notification_id in the data payload. Verify by logging the notification payload in a test device's console.
Create a Custom Action to handle notification opens
Create a Custom Action to handle notification opens
Create a Custom Action called 'initNotificationTracking'. This action sets up two FCM message handlers: FirebaseMessaging.onMessageOpenedApp (fires when the user taps a notification while the app is backgrounded) and FirebaseMessaging.instance.getInitialMessage() (fires when the user taps a notification that launched the app from terminated state). Both handlers call the same tracking function: read campaign_id and notification_id from the message's data map, then write a deduplication-safe open event to Firestore using SetOptions(merge: false) with the compound document ID. Call this Custom Action from your app's initState.
1// custom_actions/init_notification_tracking.dart2import 'package:firebase_messaging/firebase_messaging.dart';3import 'package:cloud_firestore/cloud_firestore.dart';4import 'package:firebase_auth/firebase_auth.dart';56Future<void> initNotificationTracking() async {7 // Handle notification tap when app is in background8 FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {9 _trackNotificationOpen(message);10 });1112 // Handle notification tap when app was terminated13 final initialMessage = await FirebaseMessaging.instance.getInitialMessage();14 if (initialMessage != null) {15 _trackNotificationOpen(initialMessage);16 }17}1819Future<void> _trackNotificationOpen(RemoteMessage message) async {20 final data = message.data;21 if (data['tracking_enabled'] != 'true') return;2223 final campaignId = data['campaign_id'];24 final notificationId = data['notification_id'];25 if (campaignId == null || notificationId == null) return;2627 final uid = FirebaseAuth.instance.currentUser?.uid;28 if (uid == null) return;2930 final db = FirebaseFirestore.instance;31 // Compound document ID prevents duplicates32 final openDocId = '${notificationId}_$uid';3334 // Use set without merge to create or silently overwrite (deduplication)35 await db.collection('notification_opens').doc(openDocId).set({36 'notification_id': notificationId,37 'campaign_id': campaignId,38 'user_uid': uid,39 'opened_at': FieldValue.serverTimestamp(),40 'source': 'notification',41 });4243 // Increment campaign open count (idempotent via Cloud Function)44 await db.collection('notification_campaigns').doc(campaignId).update({45 'open_count': FieldValue.increment(1),46 });47}Expected result: After tapping a tracked notification, a document appears in notification_opens with the compound ID. The campaign's open_count increments in Firestore.
Build a CTR calculation Cloud Function and dashboard query
Build a CTR calculation Cloud Function and dashboard query
Create a Firestore trigger Cloud Function on the notification_opens collection that fires on onCreate. The function reads the campaign_id from the new open document, then queries the notification_campaigns document to get sent_count and open_count. It computes CTR as (open_count / sent_count) * 100 and writes the updated ctr Float back to the campaign document. This keeps the CTR field current without any scheduled jobs. In FlutterFlow, add an analytics dashboard page with a Backend Query on notification_campaigns ordered by created_at descending, showing title, sent_count, open_count, and a formatted CTR percentage for each campaign.
1// functions/computeCtr.js — Firestore trigger2const functions = require('firebase-functions');3const admin = require('firebase-admin');45exports.computeCampaignCtr = functions.firestore6 .document('notification_opens/{openId}')7 .onCreate(async (snap, context) => {8 const { campaign_id } = snap.data();9 if (!campaign_id) return;10 const db = admin.firestore();11 const campaignRef = db.collection('notification_campaigns').doc(campaign_id);12 return db.runTransaction(async (t) => {13 const campaign = await t.get(campaignRef);14 if (!campaign.exists) return;15 const { sent_count = 0, open_count = 0 } = campaign.data();16 const newOpenCount = open_count + 1;17 const ctr = sent_count > 018 ? parseFloat(((newOpenCount / sent_count) * 100).toFixed(2))19 : 0;20 t.update(campaignRef, { open_count: newOpenCount, ctr });21 });22 });Expected result: After an open event is recorded, the campaign document's ctr field updates within 2-3 seconds. An average push notification CTR is 3-10%; test notifications to yourself should show 100% initially.
Implement A/B testing with user variant assignment
Implement A/B testing with user variant assignment
A/B testing notification copy requires splitting users into groups before sending. Add a 'notification_variant' field to each user's Firestore document when they register — assign 'A' or 'B' alternately using modulo on a counter, or randomly. When creating a campaign, create two campaign documents (one for variant A, one for B) with different notification titles. Your Cloud Function reads each user's notification_variant and uses the corresponding campaign_id and message. Track CTR separately per variant. In FlutterFlow's analytics dashboard, show variant A vs variant B CTR side by side so you can identify the better-performing copy and use it for all future sends.
Expected result: Two separate campaign documents exist in Firestore — one per variant. After sending to your user base, the analytics dashboard shows different CTR values for each variant, letting you identify the winner.
Complete working example
1// notification_tracker.dart — Complete notification CTR tracking2// Sets up open handlers, writes deduplicated open events,3// and handles both background and terminated app states45import 'package:firebase_messaging/firebase_messaging.dart';6import 'package:cloud_firestore/cloud_firestore.dart';7import 'package:firebase_auth/firebase_auth.dart';89// Call this once in your root widget's initState10Future<void> initNotificationTracking() async {11 final messaging = FirebaseMessaging.instance;1213 // Request permissions (iOS requires explicit permission)14 final settings = await messaging.requestPermission(15 alert: true, badge: true, sound: true,16 );17 if (settings.authorizationStatus == AuthorizationStatus.denied) return;1819 // Save FCM token for targeting20 final token = await messaging.getToken();21 await _saveFcmToken(token);2223 // Refresh token listener24 messaging.onTokenRefresh.listen(_saveFcmToken);2526 // App opened from background by notification tap27 FirebaseMessaging.onMessageOpenedApp.listen((message) {28 _trackOpen(message);29 });3031 // App launched from terminated state by notification tap32 final initial = await messaging.getInitialMessage();33 if (initial != null) _trackOpen(initial);34}3536Future<void> _saveFcmToken(String? token) async {37 if (token == null) return;38 final uid = FirebaseAuth.instance.currentUser?.uid;39 if (uid == null) return;40 await FirebaseFirestore.instance.collection('users').doc(uid).set(41 {'fcm_token': token, 'token_updated_at': FieldValue.serverTimestamp()},42 SetOptions(merge: true),43 );44}4546Future<void> _trackOpen(RemoteMessage message) async {47 final data = message.data;48 // Only track notifications that opted in to tracking49 if (data['tracking_enabled'] != 'true') return;5051 final campaignId = data['campaign_id'] as String?;52 final notificationId = data['notification_id'] as String?;53 if (campaignId == null || notificationId == null) return;5455 final uid = FirebaseAuth.instance.currentUser?.uid;56 if (uid == null) return;5758 final db = FirebaseFirestore.instance;5960 // Compound ID ensures one open record per user per notification61 // Firestore set() overwrites silently if the doc already exists62 final docId = '${notificationId}_$uid';63 await db.collection('notification_opens').doc(docId).set({64 'notification_id': notificationId,65 'campaign_id': campaignId,66 'user_uid': uid,67 'opened_at': FieldValue.serverTimestamp(),68 'source': 'notification',69 'variant': data['variant'] ?? 'default',70 });71 // CTR computation is handled by the Firestore onCreate Cloud Function72 // to keep client-side logic minimal73}7475// Optional: track in-app notification banner taps (foreground)76Future<void> trackForegroundNotificationTap(77 String campaignId, String notificationId) async {78 await _trackOpen(RemoteMessage(79 data: {80 'tracking_enabled': 'true',81 'campaign_id': campaignId,82 'notification_id': notificationId,83 'source': 'foreground_banner',84 },85 ));86}Common mistakes when tracking the Click-Through Rate of Push Notifications in FlutterFlow
Why it's a problem: Counting every onMessageOpenedApp event as a unique open without deduplication
How to avoid: Use a compound document ID in the notification_opens collection — '{notification_id}_{user_uid}'. Firestore's set() without merge silently overwrites the same record, so duplicate events produce exactly one open document.
Why it's a problem: Tracking opens but not sent_count, making CTR impossible to compute
How to avoid: Increment sent_count in the notification_campaigns document when you send the notification (server-side, where you know exactly how many tokens you targeted), before any opens occur.
Why it's a problem: Calling initNotificationTracking() on a page deep in the navigation stack instead of the root widget
How to avoid: Call initNotificationTracking() in the initState of your root app widget (the first widget built, before any navigation), or in a splash screen that always runs on app launch.
Best practices
- Always use compound document IDs (notification_id + user_id) for open records to prevent duplication without any extra query overhead.
- Track sent_count server-side at send time — never trust the client to report how many notifications were sent.
- Store the FCM token refresh and save it to Firestore on every launch, not just on first install. Tokens change after app updates and OS changes.
- Separate campaign metadata (title, body, audience) from analytics (sent_count, open_count, ctr) into different collections if campaigns are updated frequently to avoid write contention.
- Include the notification variant (A/B test group) in every open event so you can filter CTR by variant in your analytics dashboard.
- Set up a Firestore TTL policy to automatically delete notification_opens records older than 90 days to control storage costs.
- A good push notification CTR benchmark is 3-10% for consumer apps. Under 1% signals your copy or targeting needs improvement; over 20% may indicate bot traffic.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm tracking push notification CTR in a Firebase app. Write a Node.js Cloud Function triggered by onCreate on a 'notification_opens' Firestore collection that: reads the campaign_id from the new document, fetches the corresponding 'notification_campaigns' document, increments open_count, and recomputes CTR as (open_count / sent_count) * 100 using a Firestore transaction to prevent race conditions. Also write the sendTrackedNotification function that attaches campaign_id and a UUID notification_id to the FCM data payload and increments sent_count.
In my FlutterFlow project I want to track when users tap push notifications. I have a Custom Action called 'initNotificationTracking' that sets up FirebaseMessaging.onMessageOpenedApp and getInitialMessage() handlers. Where should I call this action in FlutterFlow's Action Flow editor? Should it be on my splash page's initState, my home page's initState, or somewhere else? I want to make sure it runs every time the app launches regardless of which page the notification deep-links to.
Frequently asked questions
What is a good push notification CTR benchmark?
Industry averages are 3-10% for consumer apps and 1-5% for e-commerce. News apps with breaking alerts can reach 15-20%. Under 1% usually means your audience targeting, notification timing, or copy needs improvement.
Can I track notification opens on iOS without the user granting notification permission?
No. Without notification permission, FCM tokens are not issued, notifications are not delivered, and there is nothing to track. If the user denies permission during onboarding, show a settings prompt later when they try to enable a feature that requires alerts.
How do I track notification opens if the user has the app open (foreground)?
FCM delivers foreground notifications silently by default on iOS (no banner shown). Use FirebaseMessaging.onMessage to display an in-app banner, and track a tap on that banner separately as a 'foreground_banner' open event using the trackForegroundNotificationTap function.
What is the difference between 'delivered' and 'sent' in notification tracking?
Sent count is the number of FCM requests you made — how many tokens you sent to. Delivered count is harder to measure: FCM provides delivery receipts only at the platform level (available in Firebase Console's FCM reporting, not through the SDK). For CTR, using sent count as the denominator is the standard practice.
How do I track which specific link or action the user took after opening the notification?
Add an 'action' field to the notification data payload (e.g., action: 'view_product', action_id: 'prod_123'). In your open handler, write this action data to the notification_opens document. This lets you measure not just whether users opened the notification but whether they completed the intended action.
Will this tracking work for notifications sent from FlutterFlow's built-in notification action?
Yes, but you need to add custom data key-value pairs in FlutterFlow's notification action: set campaign_id, notification_id (you can use a fixed ID per campaign), and tracking_enabled: 'true'. The client-side open handler reads these from message.data and works identically regardless of whether the notification was sent from FlutterFlow's UI or a Cloud Function.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation