Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Track the Click-Through Rate of Push Notifications in FlutterFlow

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.

What you'll learn

  • How to attach tracking data to FCM notifications and handle open events in FlutterFlow
  • How to deduplicate notification opens to avoid inflated CTR metrics
  • How to structure Firestore collections for campaign sends, opens, and CTR calculations
  • How to A/B test notification copy by splitting users into variant groups
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read30-45 minFlutterFlow Free+ (Cloud Functions for aggregation require Firebase Blaze plan)March 2026RapidDev Engineering Team
TL;DR

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

1

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.

2

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.

functions/sendTrackedNotification.js
1// Sending a tracked notification from Cloud Function
2const admin = require('firebase-admin');
3const { v4: uuidv4 } = require('uuid');
4
5async function sendTrackedNotification(tokens, campaignId, title, body) {
6 const notificationId = uuidv4();
7 // Log the send to Firestore
8 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 });
12
13 // Send FCM with tracking data
14 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.

3

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.

custom_actions/init_notification_tracking.dart
1// custom_actions/init_notification_tracking.dart
2import 'package:firebase_messaging/firebase_messaging.dart';
3import 'package:cloud_firestore/cloud_firestore.dart';
4import 'package:firebase_auth/firebase_auth.dart';
5
6Future<void> initNotificationTracking() async {
7 // Handle notification tap when app is in background
8 FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
9 _trackNotificationOpen(message);
10 });
11
12 // Handle notification tap when app was terminated
13 final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
14 if (initialMessage != null) {
15 _trackNotificationOpen(initialMessage);
16 }
17}
18
19Future<void> _trackNotificationOpen(RemoteMessage message) async {
20 final data = message.data;
21 if (data['tracking_enabled'] != 'true') return;
22
23 final campaignId = data['campaign_id'];
24 final notificationId = data['notification_id'];
25 if (campaignId == null || notificationId == null) return;
26
27 final uid = FirebaseAuth.instance.currentUser?.uid;
28 if (uid == null) return;
29
30 final db = FirebaseFirestore.instance;
31 // Compound document ID prevents duplicates
32 final openDocId = '${notificationId}_$uid';
33
34 // 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 });
42
43 // 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.

4

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.

functions/computeCtr.js
1// functions/computeCtr.js — Firestore trigger
2const functions = require('firebase-functions');
3const admin = require('firebase-admin');
4
5exports.computeCampaignCtr = functions.firestore
6 .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 > 0
18 ? 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.

5

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

custom_actions/notification_tracker.dart
1// notification_tracker.dart — Complete notification CTR tracking
2// Sets up open handlers, writes deduplicated open events,
3// and handles both background and terminated app states
4
5import 'package:firebase_messaging/firebase_messaging.dart';
6import 'package:cloud_firestore/cloud_firestore.dart';
7import 'package:firebase_auth/firebase_auth.dart';
8
9// Call this once in your root widget's initState
10Future<void> initNotificationTracking() async {
11 final messaging = FirebaseMessaging.instance;
12
13 // 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;
18
19 // Save FCM token for targeting
20 final token = await messaging.getToken();
21 await _saveFcmToken(token);
22
23 // Refresh token listener
24 messaging.onTokenRefresh.listen(_saveFcmToken);
25
26 // App opened from background by notification tap
27 FirebaseMessaging.onMessageOpenedApp.listen((message) {
28 _trackOpen(message);
29 });
30
31 // App launched from terminated state by notification tap
32 final initial = await messaging.getInitialMessage();
33 if (initial != null) _trackOpen(initial);
34}
35
36Future<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}
45
46Future<void> _trackOpen(RemoteMessage message) async {
47 final data = message.data;
48 // Only track notifications that opted in to tracking
49 if (data['tracking_enabled'] != 'true') return;
50
51 final campaignId = data['campaign_id'] as String?;
52 final notificationId = data['notification_id'] as String?;
53 if (campaignId == null || notificationId == null) return;
54
55 final uid = FirebaseAuth.instance.currentUser?.uid;
56 if (uid == null) return;
57
58 final db = FirebaseFirestore.instance;
59
60 // Compound ID ensures one open record per user per notification
61 // Firestore set() overwrites silently if the doc already exists
62 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 Function
72 // to keep client-side logic minimal
73}
74
75// 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.

ChatGPT Prompt

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.

FlutterFlow Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.