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

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 30-45 min
- Compatibility: FlutterFlow Free+ (Cloud Functions for aggregation require Firebase Blaze plan)
- Last updated: March 2026

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

## Before you start

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

```
// Sending a tracked notification from Cloud Function
const admin = require('firebase-admin');
const { v4: uuidv4 } = require('uuid');

async function sendTrackedNotification(tokens, campaignId, title, body) {
  const notificationId = uuidv4();
  // Log the send to Firestore
  await admin.firestore().collection('notification_campaigns').doc(campaignId).update({
    sent_count: admin.firestore.FieldValue.increment(tokens.length),
    last_sent_at: admin.firestore.FieldValue.serverTimestamp(),
  });

  // Send FCM with tracking data
  return admin.messaging().sendMulticast({
    tokens,
    notification: { title, body },
    data: {
      campaign_id: campaignId,
      notification_id: notificationId,
      tracking_enabled: 'true',
    },
    apns: { payload: { aps: { sound: 'default', badge: 1 } } },
    android: { priority: 'high' },
  });
}
```

**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
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

Future<void> initNotificationTracking() async {
  // Handle notification tap when app is in background
  FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
    _trackNotificationOpen(message);
  });

  // Handle notification tap when app was terminated
  final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
  if (initialMessage != null) {
    _trackNotificationOpen(initialMessage);
  }
}

Future<void> _trackNotificationOpen(RemoteMessage message) async {
  final data = message.data;
  if (data['tracking_enabled'] != 'true') return;

  final campaignId = data['campaign_id'];
  final notificationId = data['notification_id'];
  if (campaignId == null || notificationId == null) return;

  final uid = FirebaseAuth.instance.currentUser?.uid;
  if (uid == null) return;

  final db = FirebaseFirestore.instance;
  // Compound document ID prevents duplicates
  final openDocId = '${notificationId}_$uid';

  // Use set without merge to create or silently overwrite (deduplication)
  await db.collection('notification_opens').doc(openDocId).set({
    'notification_id': notificationId,
    'campaign_id': campaignId,
    'user_uid': uid,
    'opened_at': FieldValue.serverTimestamp(),
    'source': 'notification',
  });

  // Increment campaign open count (idempotent via Cloud Function)
  await db.collection('notification_campaigns').doc(campaignId).update({
    'open_count': FieldValue.increment(1),
  });
}
```

**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 — Firestore trigger
const functions = require('firebase-functions');
const admin = require('firebase-admin');

exports.computeCampaignCtr = functions.firestore
  .document('notification_opens/{openId}')
  .onCreate(async (snap, context) => {
    const { campaign_id } = snap.data();
    if (!campaign_id) return;
    const db = admin.firestore();
    const campaignRef = db.collection('notification_campaigns').doc(campaign_id);
    return db.runTransaction(async (t) => {
      const campaign = await t.get(campaignRef);
      if (!campaign.exists) return;
      const { sent_count = 0, open_count = 0 } = campaign.data();
      const newOpenCount = open_count + 1;
      const ctr = sent_count > 0
        ? parseFloat(((newOpenCount / sent_count) * 100).toFixed(2))
        : 0;
      t.update(campaignRef, { open_count: newOpenCount, ctr });
    });
  });
```

**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 code example

File: `custom_actions/notification_tracker.dart`

```dart
// notification_tracker.dart — Complete notification CTR tracking
// Sets up open handlers, writes deduplicated open events,
// and handles both background and terminated app states

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

// Call this once in your root widget's initState
Future<void> initNotificationTracking() async {
  final messaging = FirebaseMessaging.instance;

  // Request permissions (iOS requires explicit permission)
  final settings = await messaging.requestPermission(
    alert: true, badge: true, sound: true,
  );
  if (settings.authorizationStatus == AuthorizationStatus.denied) return;

  // Save FCM token for targeting
  final token = await messaging.getToken();
  await _saveFcmToken(token);

  // Refresh token listener
  messaging.onTokenRefresh.listen(_saveFcmToken);

  // App opened from background by notification tap
  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    _trackOpen(message);
  });

  // App launched from terminated state by notification tap
  final initial = await messaging.getInitialMessage();
  if (initial != null) _trackOpen(initial);
}

Future<void> _saveFcmToken(String? token) async {
  if (token == null) return;
  final uid = FirebaseAuth.instance.currentUser?.uid;
  if (uid == null) return;
  await FirebaseFirestore.instance.collection('users').doc(uid).set(
    {'fcm_token': token, 'token_updated_at': FieldValue.serverTimestamp()},
    SetOptions(merge: true),
  );
}

Future<void> _trackOpen(RemoteMessage message) async {
  final data = message.data;
  // Only track notifications that opted in to tracking
  if (data['tracking_enabled'] != 'true') return;

  final campaignId = data['campaign_id'] as String?;
  final notificationId = data['notification_id'] as String?;
  if (campaignId == null || notificationId == null) return;

  final uid = FirebaseAuth.instance.currentUser?.uid;
  if (uid == null) return;

  final db = FirebaseFirestore.instance;

  // Compound ID ensures one open record per user per notification
  // Firestore set() overwrites silently if the doc already exists
  final docId = '${notificationId}_$uid';
  await db.collection('notification_opens').doc(docId).set({
    'notification_id': notificationId,
    'campaign_id': campaignId,
    'user_uid': uid,
    'opened_at': FieldValue.serverTimestamp(),
    'source': 'notification',
    'variant': data['variant'] ?? 'default',
  });
  // CTR computation is handled by the Firestore onCreate Cloud Function
  // to keep client-side logic minimal
}

// Optional: track in-app notification banner taps (foreground)
Future<void> trackForegroundNotificationTap(
    String campaignId, String notificationId) async {
  await _trackOpen(RemoteMessage(
    data: {
      'tracking_enabled': 'true',
      'campaign_id': campaignId,
      'notification_id': notificationId,
      'source': 'foreground_banner',
    },
  ));
}
```

## Common mistakes

- **Counting every onMessageOpenedApp event as a unique open without deduplication** — If a user taps a notification, the app comes to the foreground. If the user backgrounds and foregrounds the app again, onMessageOpenedApp may fire a second time for the same notification, counting one real open as two. This inflates CTR beyond 100% which is impossible. Fix: 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.
- **Tracking opens but not sent_count, making CTR impossible to compute** — CTR = opens / sends. If you only log opens and not the number of devices a notification was sent to, you cannot compute the denominator and the open count is meaningless without context. Fix: 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.
- **Calling initNotificationTracking() on a page deep in the navigation stack instead of the root widget** — If the notification tap navigates the user to a page that is not the root (e.g., a product detail page), the tracking initialization never runs, and the open event is lost. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-track-the-click-through-rate-of-my-push-notifications
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-track-the-click-through-rate-of-my-push-notifications
