> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zixflow.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Push Notification Tracking

> Track push delivery, opens, and action clicks with the Zixflow SDK.

**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](/documentation/sdk/flutter/introduction) tab:

* [Flutter — Push Notification Tracking](/documentation/sdk/flutter/push-notification-tracking)
* [React Native — Push Notification Tracking](/documentation/sdk/react-native/push-notification-tracking)
* [iOS — Push Notification Tracking](/documentation/sdk/ios/push-notification-tracking)
* [Android — Push Notification Tracking](/documentation/sdk/android/push-notification-tracking)
* [JavaScript — Push Notification Tracking](/documentation/sdk/javascript/push-notification-tracking)

***

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

| Field                    | Example value                  | Purpose                                               |
| ------------------------ | ------------------------------ | ----------------------------------------------------- |
| `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:**

```json theme={null}
{
  "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:**

| Parameter     | Required | Description                                                                             |
| ------------- | -------- | --------------------------------------------------------------------------------------- |
| `deliveryID`  | ✅        | Value of `Zixflow-Delivery-ID` from the push payload                                    |
| `deviceToken` | ✅        | Value of `Zixflow-Delivery-Token` from the push payload (or your cached FCM/APNs token) |
| `event`       | ✅        | `MetricEvent.delivered`                                                                 |

<details>
  <summary><strong>Flutter (Dart)</strong></summary>

  ```dart theme={null}
  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,
      );
    }
  });
  ```
</details>

<details>
  <summary><strong>Android (Kotlin)</strong></summary>

  ```kotlin theme={null}
  // In FirebaseMessagingService.onMessageReceived()
  override fun onMessageReceived(message: RemoteMessage) {
      val deliveryId    = message.data["Zixflow-Delivery-ID"] ?: ""
      val deliveryToken = message.data["Zixflow-Delivery-Token"] ?: cachedToken ?: ""

      if (deliveryId.isNotEmpty() && deliveryToken.isNotEmpty()) {
          Zixflow.instance.trackMetric(
              deliveryID  = deliveryId,
              deviceToken = deliveryToken,
              event       = MetricEvent.DELIVERED
          )
      }
  }
  ```
</details>

<details>
  <summary><strong>iOS (Swift)</strong></summary>

  ```swift theme={null}
  // 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 deliveryToken = userInfo["Zixflow-Delivery-Token"] as? String ?? cachedApnsToken ?? ""

      if !deliveryId.isEmpty && !deliveryToken.isEmpty {
          Zixflow.instance.trackMetric(
              deliveryID:  deliveryId,
              deviceToken: deliveryToken,
              event:       .delivered
          )
      }
      completionHandler([.banner, .sound, .badge])
  }
  ```
</details>

<details>
  <summary><strong>React Native (TypeScript)</strong></summary>

  ```typescript theme={null}
  import messaging from '@react-native-firebase/messaging';
  import { Zixflow, MetricEvent } from 'zixflow-reactnative';

  messaging().onMessage(async remoteMessage => {
    const deliveryId    = remoteMessage.data?.['Zixflow-Delivery-ID'] ?? '';
    const deliveryToken = remoteMessage.data?.['Zixflow-Delivery-Token'] ?? cachedToken ?? '';

    if (deliveryId && deliveryToken) {
      await Zixflow.trackMetric({
        deliveryID:  deliveryId,
        deviceToken: deliveryToken,
        event:       MetricEvent.delivered,
      });
    }
  });
  ```
</details>

<details>
  <summary><strong>Web (Service Worker)</strong></summary>

  `trackMetric` is not available in a service worker context — call the Zixflow HTTP API directly.

  ```javascript theme={null}
  // 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) {
      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__,
          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, data,
        actions: parseButtons(data.action_buttons),
      })
    );
  });
  ```
</details>

***

### 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:**

| Parameter     | Required | Description                                             |
| ------------- | -------- | ------------------------------------------------------- |
| `deliveryID`  | ✅        | Value of `Zixflow-Delivery-ID` from the push payload    |
| `deviceToken` | ✅        | Value of `Zixflow-Delivery-Token` from the push payload |
| `event`       | ✅        | `MetricEvent.opened`                                    |

<details>
  <summary><strong>Flutter (Dart)</strong></summary>

  ```dart theme={null}
  // Background tap — app was backgrounded
  FirebaseMessaging.onMessageOpenedApp.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.opened,
      );
    }
  });

  // Terminated tap — app was closed when user tapped
  bool _initialHandled = false;
  final initial = await FirebaseMessaging.instance.getInitialMessage();
  if (initial != null && !_initialHandled) {
    _initialHandled = true;
    final deliveryId    = initial.data['Zixflow-Delivery-ID'] ?? '';
    final deliveryToken = initial.data['Zixflow-Delivery-Token'] ?? fcmToken ?? '';

    if (deliveryId.isNotEmpty && deliveryToken.isNotEmpty) {
      Zixflow.instance.trackMetric(
        deliveryID:  deliveryId,
        deviceToken: deliveryToken,
        event:       MetricEvent.opened,
      );
    }
  }
  ```
</details>

<details>
  <summary><strong>Android (Kotlin)</strong></summary>

  ```kotlin theme={null}
  // In your launcher Activity.onCreate() — handle the tap intent
  intent.extras?.let { extras ->
      val deliveryId    = extras.getString("Zixflow-Delivery-ID", "")
      val deliveryToken = extras.getString("Zixflow-Delivery-Token") ?: cachedToken ?: ""

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

<details>
  <summary><strong>iOS (Swift)</strong></summary>

  ```swift theme={null}
  // UNUserNotificationCenterDelegate.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 deliveryToken = userInfo["Zixflow-Delivery-Token"] as? String ?? cachedApnsToken ?? ""

      if !deliveryId.isEmpty && !deliveryToken.isEmpty {
          Zixflow.instance.trackMetric(
              deliveryID:  deliveryId,
              deviceToken: deliveryToken,
              event:       .opened
          )
      }
      completionHandler()
  }
  ```
</details>

<details>
  <summary><strong>React Native (TypeScript)</strong></summary>

  ```typescript theme={null}
  import messaging from '@react-native-firebase/messaging';
  import { Zixflow, MetricEvent } from 'zixflow-reactnative';

  // Background / quit tap
  messaging().onNotificationOpenedApp(async remoteMessage => {
    const deliveryId    = remoteMessage.data?.['Zixflow-Delivery-ID'] ?? '';
    const deliveryToken = remoteMessage.data?.['Zixflow-Delivery-Token'] ?? cachedToken ?? '';

    if (deliveryId && deliveryToken) {
      await Zixflow.trackMetric({ deliveryID: deliveryId, deviceToken: deliveryToken, event: MetricEvent.opened });
    }
  });

  // Terminated tap
  const initial = await messaging().getInitialNotification();
  if (initial) {
    const deliveryId    = initial.data?.['Zixflow-Delivery-ID'] ?? '';
    const deliveryToken = initial.data?.['Zixflow-Delivery-Token'] ?? cachedToken ?? '';

    if (deliveryId && deliveryToken) {
      await Zixflow.trackMetric({ deliveryID: deliveryId, deviceToken: deliveryToken, event: MetricEvent.opened });
    }
  }
  ```
</details>

<details>
  <summary><strong>Web (Service Worker)</strong></summary>

  ```javascript theme={null}
  // service-worker.js — inside notificationclick
  self.addEventListener('notificationclick', (event) => {
    const data        = event.notification.data ?? {};
    const deliveryId  = data['Zixflow-Delivery-ID'] ?? '';
    const token       = data['Zixflow-Delivery-Token'] ?? '';

    event.notification.close();

    if (deliveryId && token) {
      fetch('https://events.zixflow.in/v1/track', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${WRITE_KEY}` },
        body: JSON.stringify({
          event:      'Push Notification Opened',
          userId:     self.__ZIXFLOW_USER_ID__,
          properties: { 'Zixflow-Delivery-ID': deliveryId, 'Zixflow-Delivery-Token': token },
        }),
      }).catch(() => {});
    }

    const deeplink = data['deeplink_url'] ?? '';
    event.waitUntil(deeplink ? clients.openWindow(deeplink) : clients.openWindow('/'));
  });
  ```
</details>

***

### 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:**

| Property                 | Type    | Required    | Description                                             |
| ------------------------ | ------- | ----------- | ------------------------------------------------------- |
| `Zixflow-Delivery-ID`    | string  | ✅           | Links this event to the campaign delivery record        |
| `action_index`           | integer | ✅           | 0-based index of the button tapped (`0` = first button) |
| `action_name`            | string  | ✅           | Human-readable button label (e.g. `"Shop Now"`)         |
| `Zixflow-Delivery-Token` | string  | Recommended | Token push was sent to                                  |
| `action_deeplink`        | string  | Recommended | URL the button navigates to                             |
| `title`                  | string  | Optional    | Notification title                                      |
| `action_id`              | string  | Optional    | OS-level action identifier (e.g. `"ACTION_0"`)          |
| `source`                 | string  | Optional    | `"local_notification"` or `"web_push"`                  |

<details>
  <summary><strong>Flutter (Dart)</strong></summary>

  ```dart theme={null}
  // In FlutterLocalNotificationsPlugin.initialize()
  onDidReceiveNotificationResponse: (NotificationResponse response) {
    final payload = jsonDecode(response.payload ?? '{}') as Map<String, dynamic>;

    // Always fire opened first
    final deliveryId    = payload['Zixflow-Delivery-ID'] ?? '';
    final deliveryToken = payload['Zixflow-Delivery-Token'] ?? '';
    if (deliveryId.isNotEmpty && deliveryToken.isNotEmpty) {
      Zixflow.instance.trackMetric(
        deliveryID: deliveryId, deviceToken: deliveryToken, event: MetricEvent.opened,
      );
    }

    // Then fire action clicked (only if a button was tapped, not body tap)
    if (response.actionId != null) {
      final actionIndex = int.tryParse(response.actionId!.replaceAll(RegExp(r'\D'), '')) ?? -1;
      final buttons     = _parseButtons(payload['action_buttons']);
      final actionName  = (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':    deliveryId,
          'Zixflow-Delivery-Token': deliveryToken,
          'action_index':           actionIndex,
          'action_name':            actionName,
          'action_deeplink':        actionIndex >= 0 ? (buttons[actionIndex]['deeplink'] ?? '') : '',
        },
      );
    }
  },
  ```
</details>

<details>
  <summary><strong>Android (Kotlin)</strong></summary>

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

  // Always fire opened first
  Zixflow.instance.trackMetric(deliveryID = deliveryId, deviceToken = deliveryToken, event = MetricEvent.OPENED)

  // Then fire action clicked
  Zixflow.instance.track(
      name       = "Push Notification Action Clicked",
      properties = mapOf(
          "Zixflow-Delivery-ID"    to deliveryId,
          "Zixflow-Delivery-Token" to deliveryToken,
          "action_index"           to actionIndex,
          "action_name"            to actionName,
          "action_deeplink"        to actionDeep
      )
  )
  ```
</details>

<details>
  <summary><strong>iOS (Swift)</strong></summary>

  ```swift theme={null}
  // In userNotificationCenter(_:didReceive:withCompletionHandler:), after trackMetric(opened)
  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 actionDeep  = (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,
              "action_index":           actionIndex,
              "action_name":            actionName,
              "action_deeplink":        actionDeep
          ]
      )
  }
  ```
</details>

<details>
  <summary><strong>React Native (TypeScript)</strong></summary>

  ```typescript theme={null}
  import { Zixflow, MetricEvent } from 'zixflow-reactnative';

  async function handleActionPress(data: Record<string, string>, actionId: string) {
    const deliveryId  = data['Zixflow-Delivery-ID'] ?? '';
    const token       = data['Zixflow-Delivery-Token'] ?? '';
    const actionIndex = parseInt(actionId.replace(/\D/g, ''), 10);
    const buttons     = parseButtons(data['action_buttons']);
    const actionName  = buttons[actionIndex]?.name ?? `Action ${actionIndex + 1}`;
    const actionDeep  = buttons[actionIndex]?.deeplink ?? '';

    // Always fire opened first
    await Zixflow.trackMetric({ deliveryID: deliveryId, deviceToken: token, event: MetricEvent.opened });

    // Then fire action clicked
    await Zixflow.track({
      name: 'Push Notification Action Clicked',
      properties: {
        'Zixflow-Delivery-ID':    deliveryId,
        'Zixflow-Delivery-Token': token,
        'action_index':           actionIndex,
        'action_name':            actionName,
        'action_deeplink':        actionDeep,
      },
    });
  }
  ```
</details>

<details>
  <summary><strong>Web (Service Worker)</strong></summary>

  ```javascript theme={null}
  // service-worker.js — inside notificationclick, after tracking opened
  if (event.action) {
    const actionIndex = parseInt(event.action.replace(/\D/g, ''), 10);
    const buttons     = parseButtons(data['action_buttons']);
    const actionName  = buttons[actionIndex]?.name ?? '';
    const actionDeep  = buttons[actionIndex]?.deeplink ?? '';

    fetch('https://events.zixflow.in/v1/track', {
      method:  'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${WRITE_KEY}` },
      body: JSON.stringify({
        event:      'Push Notification Action Clicked',
        userId:     self.__ZIXFLOW_USER_ID__,
        properties: {
          'Zixflow-Delivery-ID':    deliveryId,
          'Zixflow-Delivery-Token': token,
          'action_index':           actionIndex,
          'action_name':            actionName,
          'action_deeplink':        actionDeep,
        },
      }),
    }).catch(() => {});

    if (actionDeep) { event.waitUntil(clients.openWindow(actionDeep)); return; }
  }
  ```
</details>

***

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

```dart theme={null}
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.

```dart theme={null}
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)

```dart theme={null}
// 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`:

```dart theme={null}
// 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

```kotlin theme={null}
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)

```kotlin theme={null}
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)

```kotlin theme={null}
// 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

```kotlin theme={null}
// 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

```swift theme={null}
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)

```swift theme={null}
// 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)

```swift theme={null}
// 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

```typescript theme={null}
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)

```javascript theme={null}
// 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)

```javascript theme={null}
// 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:**

```json theme={null}
[
  { "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):**

```swift theme={null}
// 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])
```

***

## Deep Link Handling

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:**

```dart theme={null}
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:

```dart theme={null}
// 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',
  },
);
```
