Skip to main content
Audience: Client developers integrating Zixflow SDK (Flutter, iOS, Android, Web)
Purpose: Definitive guide to tracking push notification lifecycle events — delivery, open, and action clicks — so Zixflow can measure campaign performance accurately.
For step-by-step SDK setup and code samples, see the platform guides in the SDK tab:

Why Push Tracking Matters

When Zixflow sends a push notification to a user’s device, it records that the notification was sent. But it cannot know on its own:
  • Did the notification actually arrive on the device?
  • Did the user open it (tap the banner)?
  • Did the user tap an action button (“Shop Now”, “Remind Me”)?
Your app reports these three moments back to Zixflow using the SDK. This powers the delivery analytics you see in campaign dashboards — open rates, click rates, and conversion funnels after a push.

The Delivery Lifecycle at a Glance

Zixflow Campaign ──sends──▶ Zixflow (via FCM/APNs) ──delivers──▶ Device OS

                                                     ┌──────────┴──────────┐
                                                     │                     │
                                               App foreground        App background
                                               onMessage()           onMessageOpenedApp()
                                                     │                     │
                                            Your app calls:          Your app calls:
                                         trackMetric(delivered)   trackMetric(opened)

                                          User taps action button

                                             Your app calls:
                                        track("Push Notification
                                           Action Clicked")
Each of these SDK calls results in a delivery report event flowing to the Zixflow backend, updating the campaign’s live metrics.

The Push Payload

Zixflow injects two special fields into every push notification’s data payload. Your app uses these to associate the tracking event with the correct campaign delivery.
FieldExample valuePurpose
Zixflow-Delivery-ID"626533406292836846"Unique ID for this delivery (matches campaign record)
Zixflow-Delivery-Token"dcFRlDhiRbehM1vg-Lx_yn:..."The FCM/APNs token the notification was sent to
These appear inside message.data (Flutter/Android) or userInfo (iOS native). The SDK’s trackMetric() method requires both to route the event correctly. Example raw data payload received by the app:
{
  "Zixflow-Delivery-ID": "626533406292836846",
  "Zixflow-Delivery-Token": "dcFRlDhiRbehM1vg...",
  "title": "Flash Sale! 70% OFF 🔥",
  "body": "Limited time only — grab your deal now!",
  "deeplink_url": "https://yourapp.com/sale",
  "image_url": "https://cdn.yourapp.com/banner.png",
  "large_icon_url": "https://cdn.yourapp.com/icon.png",
  "badge": "5",
  "action_buttons": "[{\"name\":\"Shop Now\",\"deeplink\":\"https://yourapp.com/sale\"},{\"name\":\"Remind Me\",\"deeplink\":\"\"}]",
  "notif_id": "626533406292836846"
}
Important: action_buttons is a JSON string (not a nested object). Parse it with json.decode() / JSONSerialization before use.

The Three Tracking Events

Each interaction maps to a specific SDK call. The event name, parameters, and platform code are listed for each below.

1. Delivery Confirmed

Event name (sent to Zixflow): Push Notification Delivered
When to fire: The moment the push data payload arrives on the device — inside your onMessage / onMessageReceived / willPresent handler.
SDK method: Zixflow.instance.trackMetric(deliveryID:, deviceToken:, event: MetricEvent.delivered)
What happens: Zixflow updates the campaign delivery record to delivered. No profile event is stored.
Parameters:
ParameterRequiredDescription
deliveryIDValue of Zixflow-Delivery-ID from the push payload
deviceTokenValue of Zixflow-Delivery-Token from the push payload (or your cached FCM/APNs token)
eventMetricEvent.delivered

2. Notification Opened

Event name (sent to Zixflow): Push Notification Opened
When to fire: When the user taps the notification banner. Fire for both body taps and action button taps.
SDK method: Zixflow.instance.trackMetric(deliveryID:, deviceToken:, event: MetricEvent.opened)
What happens: Zixflow updates the campaign delivery record to opened. No profile event is stored.
Parameters:
ParameterRequiredDescription
deliveryIDValue of Zixflow-Delivery-ID from the push payload
deviceTokenValue of Zixflow-Delivery-Token from the push payload
eventMetricEvent.opened

3. Action Button Clicked

Event name (sent to Zixflow): Push Notification Action Clicked
When to fire: When the user taps a named action button (“Shop Now”, “Try It Free”, etc.). Always fire trackMetric(opened) first, then fire this event.
SDK method: Zixflow.instance.track(name: "Push Notification Action Clicked", properties: {...})
What happens: Zixflow records a clicked delivery report and captures which button was tapped for campaign analytics.
This is a named track() call — not trackMetric(). There is no MetricEvent enum for clicks.
Properties:
PropertyTypeRequiredDescription
Zixflow-Delivery-IDstringLinks this event to the campaign delivery record
action_indexinteger0-based index of the button tapped (0 = first button)
action_namestringHuman-readable button label (e.g. "Shop Now")
Zixflow-Delivery-TokenstringRecommendedToken push was sent to
action_deeplinkstringRecommendedURL the button navigates to
titlestringOptionalNotification title
action_idstringOptionalOS-level action identifier (e.g. "ACTION_0")
sourcestringOptional"local_notification" or "web_push"

Platform-Specific Integration

Flutter (Android + iOS)

Flutter is the reference implementation. The pattern is the same for both platforms once FCM / APNs tokens are obtained.

Step 1 — Register Device Token

import 'package:zixflow/zixflow.dart';
import 'package:firebase_messaging/firebase_messaging.dart';

// On app start, get the FCM token and register it
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken != null) {
  Zixflow.instance.registerDeviceToken(deviceToken: fcmToken);
}

// Keep token fresh — re-register whenever FCM rotates it
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
  Zixflow.instance.registerDeviceToken(deviceToken: newToken);
});

Step 2 — Track Delivery (Foreground)

When the app is in the foreground, FCM delivers the message to onMessage. Track delivery immediately.
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  final deliveryId    = message.data['Zixflow-Delivery-ID'] ?? '';
  final deliveryToken = message.data['Zixflow-Delivery-Token'] ?? fcmToken ?? '';

  if (deliveryId.isNotEmpty && deliveryToken.isNotEmpty) {
    Zixflow.instance.trackMetric(
      deliveryID:  deliveryId,
      deviceToken: deliveryToken,
      event:       MetricEvent.delivered,
    );
  } else {
    // Fallback: no Zixflow IDs present (non-Zixflow push source)
    Zixflow.instance.track(
      name: 'Push Notification Delivered',
      properties: {
        'notification_id': message.messageId ?? '',
        'title': message.notification?.title ?? message.data['title'] ?? '',
        'body':  message.notification?.body  ?? message.data['body']  ?? '',
      },
    );
  }

  // Show a local notification (since app is in foreground)
  _showLocalNotification(message);
});

Step 3 — Track Open (Background / Terminated)

// Background: app was in background, user tapped banner
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
  _trackOpened(message.data, fcmToken);
  _handleDeeplink(message.data['deeplink_url']);
});

// Terminated: app was closed, notification tap launched the app
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
  _trackOpened(initial.data, fcmToken);
  _handleDeeplink(initial.data['deeplink_url']);
}

void _trackOpened(Map<String, String> data, String? fallbackToken) {
  final deliveryId    = data['Zixflow-Delivery-ID'] ?? '';
  final deliveryToken = data['Zixflow-Delivery-Token'] ?? fallbackToken ?? '';

  if (deliveryId.isNotEmpty && deliveryToken.isNotEmpty) {
    Zixflow.instance.trackMetric(
      deliveryID:  deliveryId,
      deviceToken: deliveryToken,
      event:       MetricEvent.opened,
    );
  } else {
    Zixflow.instance.track(
      name: 'Push Notification Opened',
      properties: {
        'notification_id': data['notif_id'] ?? '',
        'deeplink':        data['deeplink_url'] ?? '',
      },
    );
  }
}

Step 4 — Track Action Button Click (Local Notification Response)

For foreground-received notifications displayed as local notifications, handle taps via flutter_local_notifications:
// In FlutterLocalNotificationsPlugin.initialize()
onDidReceiveNotificationResponse: (NotificationResponse response) {
  final payload = jsonDecode(response.payload ?? '{}') as Map<String, dynamic>;

  if (response.actionId == null) {
    // Body tap → opened
    _trackOpened(payload, null);
  } else {
    // Action button tap → opened + clicked
    _trackOpened(payload, null);

    final actionIndex = int.tryParse(
        response.actionId!.replaceAll(RegExp(r'\D'), '')) ?? -1;
    final buttons = _parseButtons(payload['action_buttons']);
    final buttonName = (actionIndex >= 0 && actionIndex < buttons.length)
        ? buttons[actionIndex]['name'] ?? 'Action ${actionIndex + 1}'
        : 'Action ${actionIndex + 1}';

    Zixflow.instance.track(
      name: 'Push Notification Action Clicked',
      properties: {
        'Zixflow-Delivery-ID':    payload['Zixflow-Delivery-ID'] ?? '',
        'Zixflow-Delivery-Token': payload['Zixflow-Delivery-Token'] ?? '',
        'notification_id':        payload['Zixflow-Delivery-ID'] ?? '',
        'title':                  payload['title'] ?? '',
        'action_id':              response.actionId!,
        'action_index':           actionIndex,
        'action_name':            buttonName,
        'action_deeplink':        _resolveButtonDeeplink(buttons, actionIndex),
        'source':                 'local_notification',
      },
    );
  }
},

List<Map<String, dynamic>> _parseButtons(dynamic raw) {
  if (raw == null) return [];
  try {
    final decoded = raw is String ? jsonDecode(raw) : raw;
    return List<Map<String, dynamic>>.from(decoded as List);
  } catch (_) {
    return [];
  }
}

Android (Kotlin / Java native)

If you are building with native Android (not Flutter), the integration points are the same — only the API surface changes.

Token Registration

import com.zixflow.Zixflow

// In your FirebaseMessagingService.onNewToken()
override fun onNewToken(token: String) {
    super.onNewToken(token)
    Zixflow.instance.registerDeviceToken(token)
}

// On app start (in case token already exists)
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
    Zixflow.instance.registerDeviceToken(token)
}

Track Delivery (Foreground — via onMessageReceived)

override fun onMessageReceived(message: RemoteMessage) {
    val data           = message.data
    val deliveryId     = data["Zixflow-Delivery-ID"] ?: ""
    val deliveryToken  = data["Zixflow-Delivery-Token"] ?: currentToken ?: ""

    if (deliveryId.isNotEmpty() && deliveryToken.isNotEmpty()) {
        Zixflow.instance.trackMetric(
            deliveryID  = deliveryId,
            deviceToken = deliveryToken,
            event       = MetricEvent.DELIVERED
        )
    } else {
        Zixflow.instance.track(
            name       = "Push Notification Delivered",
            properties = mapOf(
                "notification_id" to (data["notif_id"] ?: ""),
                "title"           to (data["title"] ?: ""),
                "body"            to (data["body"] ?: "")
            )
        )
    }
    showLocalNotification(message)
}

Track Open (Notification Tap)

// In your launcher Activity.onCreate() or wherever you handle the intent
intent.extras?.let { extras ->
    val deliveryId    = extras.getString("Zixflow-Delivery-ID", "")
    val deliveryToken = extras.getString("Zixflow-Delivery-Token", "") ?: currentToken ?: ""
    val deeplink      = extras.getString("deeplink_url", "")

    if (deliveryId.isNotEmpty() && deliveryToken.isNotEmpty()) {
        Zixflow.instance.trackMetric(
            deliveryID  = deliveryId,
            deviceToken = deliveryToken,
            event       = MetricEvent.OPENED
        )
    }

    if (deeplink.isNotEmpty()) navigateTo(deeplink)
}

Track Action Button Click

// In your NotificationActionReceiver
val deliveryId   = intent.getStringExtra("Zixflow-Delivery-ID") ?: ""
val actionIndex  = intent.getIntExtra("action_index", -1)
val actionName   = intent.getStringExtra("action_name") ?: "Action ${actionIndex + 1}"
val actionDeep   = intent.getStringExtra("action_deeplink") ?: ""

Zixflow.instance.trackMetric(                    // also fire opened
    deliveryID = deliveryId, deviceToken = token, event = MetricEvent.OPENED
)
Zixflow.instance.track(
    name       = "Push Notification Action Clicked",
    properties = mapOf(
        "Zixflow-Delivery-ID"    to deliveryId,
        "Zixflow-Delivery-Token" to token,
        "notification_id"        to deliveryId,
        "action_index"           to actionIndex,
        "action_name"            to actionName,
        "action_deeplink"        to actionDeep,
        "source"                 to "local_notification"
    )
)

iOS (Swift native)

Token Registration

import ZixflowAnalytics

// In AppDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
func application(_ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
    Zixflow.instance.registerDeviceToken(deviceToken: tokenString)
}

Track Delivery (Foreground — UNUserNotificationCenterDelegate)

// userNotificationCenter(_:willPresent:withCompletionHandler:)
func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    willPresent notification: UNNotification,
    withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
    let userInfo    = notification.request.content.userInfo
    let deliveryId  = userInfo["Zixflow-Delivery-ID"] as? String ?? ""
    let deliveryTok = userInfo["Zixflow-Delivery-Token"] as? String ?? apnsToken ?? ""

    if !deliveryId.isEmpty && !deliveryTok.isEmpty {
        Zixflow.instance.trackMetric(
            deliveryID:  deliveryId,
            deviceToken: deliveryTok,
            event:       .delivered
        )
    }

    // Show the notification even while in foreground
    completionHandler([.banner, .sound, .badge])
}

Track Open (Background / Terminated Tap)

// userNotificationCenter(_:didReceive:withCompletionHandler:)
func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
) {
    let userInfo    = response.notification.request.content.userInfo
    let deliveryId  = userInfo["Zixflow-Delivery-ID"] as? String ?? ""
    let deliveryTok = userInfo["Zixflow-Delivery-Token"] as? String ?? apnsToken ?? ""

    if !deliveryId.isEmpty && !deliveryTok.isEmpty {
        Zixflow.instance.trackMetric(
            deliveryID:  deliveryId,
            deviceToken: deliveryTok,
            event:       .opened
        )
    }

    // Handle action buttons
    if response.actionIdentifier != UNNotificationDefaultActionIdentifier &&
       response.actionIdentifier != UNNotificationDismissActionIdentifier {
        let actionIndex = Int(response.actionIdentifier
            .replacingOccurrences(of: "ACTION_", with: "")) ?? -1
        let buttons = parseActionButtons(userInfo["action_buttons"])
        let actionName = (actionIndex >= 0 && actionIndex < buttons.count)
            ? buttons[actionIndex]["name"] as? String ?? "Action \(actionIndex + 1)"
            : "Action \(actionIndex + 1)"
        let deeplink = (actionIndex >= 0 && actionIndex < buttons.count)
            ? buttons[actionIndex]["deeplink"] as? String ?? ""
            : ""

        Zixflow.instance.track(
            name: "Push Notification Action Clicked",
            properties: [
                "Zixflow-Delivery-ID":    deliveryId,
                "Zixflow-Delivery-Token": deliveryTok,
                "notification_id":        deliveryId,
                "action_index":           actionIndex,
                "action_name":            actionName,
                "action_deeplink":        deeplink,
                "source":                 "local_notification"
            ]
        )
    }

    // Handle deeplink navigation
    let deeplink = userInfo["deeplink_url"] as? String ?? ""
    if !deeplink.isEmpty { navigateTo(deeplink) }

    completionHandler()
}

Web (JavaScript / TypeScript)

Web push uses the Service Worker to receive notifications while the browser is in the background. The tracking pattern is the same.

Token Registration

import { AnalyticsBrowser } from '@zixflow/analytics-browser';

// After requesting notification permission
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});

// Register the endpoint with Zixflow
Zixflow.registerPushToken({
  endpoint:  subscription.endpoint,
  keys:      subscription.toJSON().keys,
  platform:  'web',
});

Track Delivery (Service Worker — push event)

// service-worker.js
self.addEventListener('push', (event) => {
  const data       = event.data?.json() ?? {};
  const deliveryId = data['Zixflow-Delivery-ID'] ?? '';
  const token      = data['Zixflow-Delivery-Token'] ?? '';

  if (deliveryId && token) {
    // Best-effort — fire and forget from service worker
    fetch('https://events.zixflow.in/v1/track', {
      method:  'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${WRITE_KEY}` },
      body: JSON.stringify({
        event:      'Push Notification Delivered',
        userId:     self.__ZIXFLOW_USER_ID__ ?? undefined,
        properties: {
          'Zixflow-Delivery-ID':    deliveryId,
          'Zixflow-Delivery-Token': token,
        },
      }),
    }).catch(() => {});
  }

  event.waitUntil(
    self.registration.showNotification(data.title ?? 'Notification', {
      body:    data.body ?? '',
      icon:    data.image_url,
      badge:   data.badge_url,
      data:    data,
      actions: parseButtons(data.action_buttons),
    })
  );
});

Track Open (Service Worker — notificationclick event)

// service-worker.js
self.addEventListener('notificationclick', (event) => {
  const data        = event.notification.data ?? {};
  const deliveryId  = data['Zixflow-Delivery-ID'] ?? '';
  const token       = data['Zixflow-Delivery-Token'] ?? '';
  const actionIndex = event.action ? parseInt(event.action.replace(/\D/g, '')) : -1;

  event.notification.close();

  // Always track opened
  trackEvent('Push Notification Opened', {
    'Zixflow-Delivery-ID':    deliveryId,
    'Zixflow-Delivery-Token': token,
  });

  // If action button tapped
  if (event.action) {
    const buttons    = parseButtons(data.action_buttons);
    const actionName = actionIndex >= 0 ? buttons[actionIndex]?.name ?? '' : '';
    const deeplink   = actionIndex >= 0 ? buttons[actionIndex]?.deeplink ?? '' : '';

    trackEvent('Push Notification Action Clicked', {
      'Zixflow-Delivery-ID':    deliveryId,
      'Zixflow-Delivery-Token': token,
      'notification_id':        deliveryId,
      'action_index':           actionIndex,
      'action_name':            actionName,
      'action_deeplink':        deeplink,
      'source':                 'web_push',
    });

    if (deeplink) { event.waitUntil(clients.openWindow(deeplink)); return; }
  }

  // Default: open deeplink or focus existing tab
  const deeplink = data['deeplink_url'] ?? '';
  event.waitUntil(deeplink ? clients.openWindow(deeplink) : clients.openWindow('/'));
});

Action Buttons Format

The action_buttons field in the push payload is a JSON-encoded string containing an array of button objects. You must parse it before use. Payload value (raw string):
"[{\"name\":\"Shop Now\",\"deeplink\":\"https://yourapp.com/sale\"},{\"name\":\"Remind Me\",\"deeplink\":\"\"}]"
Parsed structure:
[
  { "name": "Shop Now",  "deeplink": "https://yourapp.com/sale" },
  { "name": "Remind Me", "deeplink": "" }
]
Rules:
  • Maximum 2 buttons per notification (iOS system limit; Android supports more but keep it consistent)
  • deeplink may be an empty string — handle gracefully, don’t navigate to a blank URL
  • Button index is 0-based — button at index 0 is leftmost/first
  • On iOS, button labels are pre-registered at app init due to OS constraints. Only the deeplinks are dynamic from the payload. Use generic labels in the pre-registration (“Action 1”, “Action 2”) and rely on the action_name in the tracking event for analytics.
iOS pre-registration example (required):
// Must be done at app launch in AppDelegate / UNUserNotificationCenter setup
let actions = [
    UNNotificationAction(identifier: "ACTION_0", title: "Action 1", options: .foreground),
    UNNotificationAction(identifier: "ACTION_1", title: "Action 2", options: .foreground),
]
let category = UNNotificationCategory(identifier: "ZX_2BTN", actions: actions,
                                       intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])

Every Zixflow push notification may carry a deeplink_url at the top level and per-button deeplinks in action_buttons. Your app is responsible for routing these. Priority order for navigation on tap:
  1. If an action button was tapped → use action_buttons[index].deeplink
  2. If body was tapped → use deeplink_url
  3. If neither is set → open app to default screen
Flutter example:
void handleDeeplink(String? url) {
  if (url == null || url.isEmpty) return;
  final uri = Uri.tryParse(url);
  if (uri == null) return;

  switch (uri.host) {
    case 'yourapp.com':
      final path = uri.path;
      if (path.startsWith('/sale'))       Navigator.pushNamed(context, '/sale');
      else if (path.startsWith('/dashboard')) Navigator.pushNamed(context, '/dashboard');
      else Navigator.pushNamed(context, '/home');
      break;
    default:
      // External URL — launch in browser
      launchUrl(uri);
  }
}

Decision Flowchart

Use this when deciding which SDK call to make for any notification interaction:
Notification received?
├── YES → trackMetric(delivered)        [always, as soon as data payload arrives]

User interacted?
├── Tapped notification body
│   └── trackMetric(opened)             [SDK call with deliveryID + deviceToken]
│       └── Navigate to deeplink_url

└── Tapped action button
    ├── trackMetric(opened)             [still track opened]
    └── track("Push Notification Action Clicked")  [with action_index + action_name]
        └── Navigate to button's deeplink

Fallback Behaviour (No Zixflow-Delivery-ID)

If the push notification was sent by a non-Zixflow source (e.g., a direct FCM API call, a test tool, or a third-party system), Zixflow-Delivery-ID and Zixflow-Delivery-Token will be absent from the payload. In this case, skip trackMetric(). Push notification event names (Push Notification Delivered, Push Notification Opened, Push Notification Action Clicked) are reserved for Zixflow’s delivery pipeline — they are not stored as user profile events. If you want to track a non-Zixflow push for your own analytics (e.g., to build a custom funnel), use a custom event name instead:
// Use a custom event name — push system events are not stored as profile events
Zixflow.instance.track(
  name: 'External Push Received',   // choose a name that makes sense for your use case
  properties: {
    'notification_id': message.messageId ?? '',
    'title':           message.notification?.title ?? '',
    'body':            message.notification?.body ?? '',
    'source':          'external',
  },
);