Skip to main content
Last Updated: July 2, 2026
Audience: Client developers integrating Zixflow SDK (Flutter)
Purpose: Track push notification lifecycle events — delivery, open, and action clicks — so Zixflow can measure campaign performance accurately.
See also: Push Notifications and the reference implementation push_handlers.dart.

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.

Two Ways to Handle a Zixflow Push — and Why Tracking Is Required in Both

Every push can be Native or Custom in the dashboard — that choice decides the payload shape (see The Push Payload).
  • Native: display content lives in FCM notification / APNs aps.alert; data has tracking + routing keys (Zixflow-Delivery-ID, Zixflow-Delivery-Token, deeplink_url, action_buttons, template_id).
  • Custom: no notification block — the full content is in data.* so your app renders with flutter_local_notifications.
The one thing both paths share: tracking is never automatic.
  • Path A (Native, app backgrounded/killed): your onMessage/background handler is not invoked at all when the payload has a notification block and the app is backgrounded. Track Opened from onMessageOpenedApp / getInitialMessage. For Delivered on this path, rely on Zixflow’s server-side delivery receipt, or see Tracking Delivery When the Device Is Locked.
  • Path B (Custom): your code runs the moment the push arrives. Track Delivered immediately, then track Opened / Push Notification Action Clicked from your local-notification tap handlers.
Minimal “OS renders it, I only track” implementation:
Reference implementation: sdk-examples/flutter ships a runtime “Custom handling” toggle that switches live between path A and path B on the same running app.
A note on FlutterFire and Path A: FlutterFire’s fallback (fcm_fallback_notification_channel) renders title+body only and silently drops notification.image, even though genuine stock FCM rendering supports it. If you need images on Path A, build a minimal renderer yourself that reads notification.title/body/android.imageUrl rather than relying on the library’s fallback.

The Delivery Lifecycle at a Glance

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 data payload. trackMetric() requires both. The rest of the payload depends on the rendering mode chosen in the dashboard:
  • Native (OS-rendered): display content lives in notification / aps.alert; data carries tracking + routing keys only.
  • Custom (app-rendered): there is no notification block — the full content is in data (Android) / top-level keys (iOS).
Example data payload in Custom mode:
Example data payload in Native mode (tracking + routing only):
Important: action_buttons is a JSON string (not a nested object). Parse it with json.decode() before use.
On Android, Native-mode display content lives in notification.title / body / image. On iOS it lives in aps.alert. Prefer message.notification?.x ?? data['x'] so a single renderer covers both modes while the app is in the foreground.

Complete Key Reference

Demo-only sample-app conventions (not official schema): data.priority, data.analytics_label, data.ttl_seconds.

Field Support Summary: Native (OS-Rendered) vs. Custom-Handled


Template-Based Custom Rendering (template_id)

Every push sent from a dashboard template includes data.template_id (both modes). Map known IDs to a dedicated renderer and fall back to the generic field-driven builder. The Flutter sample also checks template_type == "custom" before looking up template_id — that extra gate is a sample-app routing convention. The official payload field is template_id.

Complete Field Mapping Reference (Custom-Handled UI)

One consolidated builder covering every official field from the Complete Key Reference. Combine with the tracking calls from The Three Tracking Events. Matches sdk-examples/flutter.

The Three Tracking Events

Each interaction maps to a specific SDK call. The event name, parameters, and platform code are listed for each below.
Event naming — read this first: trackMetric() used to always send a single generic internal event name, Report Delivery Event, for every metric type (delivered/opened/clicked/converted), with the actual status only distinguishable via an internal metric property. The SDK now sends the metric name itself as the event nameDelivered, Opened, Clicked — so each lifecycle stage is directly filterable/reportable by name in analytics and Journeys, with no code change required on your side (you still call trackMetric(event: MetricEvent.delivered) exactly as before; only the resulting event name on the backend changed). If you have older dashboards/segments filtering on the literal string "Report Delivery Event", update them to filter on "Delivered" / "Opened" / "Clicked" instead. Both old and new names are still recognized by the backend, so nothing breaks during the transition — but new events will use the short-form names going forward.

1. Delivery Confirmed

Event name (sent to Zixflow): 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:

Tracking Delivery When the Device Is Locked

A common gap: Delivered isn’t recorded when the push arrives while the phone is locked or the app is backgrounded. This is expected default OS behaviour.

2. Notification Opened

Event name (sent to Zixflow): 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:

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:

Platform-Specific Integration

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

Step 1 — Register Device Token

Step 2 — Track Delivery (Foreground)

When the app is in the foreground, FCM delivers the message to onMessage. Track delivery immediately.

Step 3 — Track Open (Background / Terminated)

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

For foreground-received notifications displayed as local notifications, handle taps via flutter_local_notifications:

Action Buttons Format

The action_buttons field is a JSON-encoded string of {name, deeplink} objects. Parse it before use. Buttons never render on the Native path — attach them when you build the local notification. Payload value (raw string):
Parsed structure:
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):

Parsing action_buttons

Building the Buttons on the Notification (Foreground Path)

onDidReceiveNotificationResponse’s response.actionId (e.g. "ACTION_0") is what you parse back into an index for tracking.

Handling the Tap (End-to-End)

Important on Android (Flutter): pressing an action button does not automatically dismiss the notification the way tapping the notification body does — see Sticky Notifications below.

New Notification Layouts

Not Zixflow-defined. If you want Android layouts, add keys such as style / progress / timer yourself and render them. Sample-app convention below. Android-only.

style — notification layout

progress — determinate / playback bar

timer — live countdown chronometer

Maps to Android’s setUsesChronometer(true) + setChronometerCountDown(true) + setWhen(endTimeMillis).

Example payloads


Sticky Notifications

data.sticky is an official Custom-mode field. Apply ongoing / FLAG_NO_CLEAR in your builder — Native OS rendering does not honor sticky. Values: sticky accepts three values: This is Android-only — there is no iOS/APNs equivalent. Sticky only works on the Custom (app-rendered) path.
Regardless of which mode:
  • autoCancel: true must always be on
  • ongoing / FLAG_NO_CLEAR is driven by the sticky value (see Complete Field Mapping Reference)
  • Action buttons need an explicit cancel call except for until_click, where only a body tap should dismiss
Unlike native Android, Flutter’s Zixflow SDK does not intercept messages with its own background service — your app’s FirebaseMessaging.onMessage / onBackgroundMessage handler is what builds the local notification, so this flag must be read and applied in your own app code.
Every Zixflow push may carry a deeplink_url and per-button deeplinks in action_buttons. Your app is responsible for routing these. Sample-app priority on tap:
  1. Action button → that button’s deeplink
  2. Body tap → deeplink_url
  3. Neither set → default screen
click_action: FCM android.notification.click_action needs a matching <intent-filter>. A separate data.click_action key is optional — the sample app maps "OPEN_SALE" / "OPEN_DASHBOARD" ahead of deeplink_url.
Resolution order your routing code should follow, regardless of platform:
  1. Try to resolve the link to one of your app’s own screens (e.g. a custom yourapp:// scheme, or https://yourapp.com/... paths you own).
  2. If it doesn’t match anything you own, fall back to opening it externally (browser, another installed app, mailto:, etc.).
  3. If the URL is empty/missing, do nothing (or navigate to a default/home screen).

Decision Flowchart

Use this when deciding which SDK call to make for any notification interaction:

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 (Delivered, Opened, Push Notification Action Clicked) are reserved for Zixflow’s delivery pipeline only when their properties actually carry a Zixflow delivery identifier — the backend checks for Zixflow-Delivery-ID before treating it as a delivery report. If you send a custom event named exactly Delivered, Opened, or Clicked with no delivery identifier, it is stored as a normal profile event. When a Zixflow-originated push tracking call is correctly matched, its event is not stored as a user profile event. 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:

Background Isolate Taps

When the app was terminated and the user taps a local notification, the response may run in a background isolate. Re-initialize Zixflow before tracking:
Wire this as onDidReceiveBackgroundNotificationResponse when initializing FlutterLocalNotificationsPlugin.

Testing Push Tracking

In the dashboard: Messaging → Push Notifications → check Delivered, Opened, and Clicked (action) for your campaign.