Skip to main content
Last Updated: July 2, 2026
Audience: Client developers integrating Zixflow SDK (Android)
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 in sdk-examples/android.

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 Zixflow sends can be displayed to the user in one of two ways. You choose which one per campaign in the Zixflow dashboard — “Native” or “Custom” — and that choice decides the payload shape (see FCM Wire Format). Native mode sends the display content in the platform notification block so the OS renders it; Custom mode sends everything in data so your app renders it. The one thing both paths share: tracking is never automatic. Whether the OS drew the banner or your code did, Zixflow only learns about delivery/opens/clicks when your app calls trackMetric() / track() — that call must happen regardless of which rendering path was taken.
  • Path A (Native, app backgrounded/killed): onMessageReceived is not invoked at all when the payload has a notification block and the app is backgrounded — so Delivered cannot be tracked from app code at that moment. It’s the tap afterward (via intent.extras) that reaches your code and lets you track Opened. 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 via a high-priority data message. Track Delivered immediately, then track Opened / Push Notification Action Clicked from your own tap handlers.
Minimal “OS renders it, I only track” implementation — the smallest possible correct integration, with zero custom notification-building code:
Reference implementation: sdk-examples/android ships a runtime “Custom handling” toggle that switches live between path A and path B on the same running app.
A note on FCM client libraries and Path A: once any FCM client library is present, it typically installs its own FirebaseMessagingService. Some libraries’ built-in fallback renderer for notification-block pushes is not a full reproduction of stock Play-Services rendering. Verify your library’s fallback before assuming Path A gives full fidelity for free.

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 notification’s data payload. Your app uses these to associate the tracking event with the correct campaign delivery. These appear inside message.data. The SDK’s trackMetric() method requires both. The rest of the payload depends on the rendering mode chosen for the campaign in the Zixflow dashboard (see FCM Wire Format):
  • Native (OS-rendered): display content (title/body/image/sound/…) is sent in notification / android.notification, and data carries only the tracking + routing keys.
  • Custom (app-rendered): there is no notification block — the display content is delivered inside data so your app builds the notification itself.
Example data payload in Custom mode (app receives the full content):
Example data payload in Native mode (tracking + routing only):
Important: action_buttons is a JSON string (not a nested object). Parse it with JSONArray / JSONObject before use.

How to handle and show push notifications sent by Zixflow

The Delivery Pipeline

Zixflow sends the notification directly to FCM on your behalf — your app never talks to Zixflow’s backend to receive a push. Every key your app sees (Zixflow-Delivery-ID, deeplink_url, action_buttons, and so on) is part of that same payload. What differs is where Zixflow places those keys, and that placement decides whether the OS displays the notification or your app code has to.

FCM (Android) Wire Format

The shape of the payload depends on the rendering mode you select for the campaign in the Zixflow dashboard. Both modes always carry the same Zixflow tracking + routing keys in data (Zixflow-Delivery-ID, Zixflow-Delivery-Token, deeplink_url, action_buttons, notif_id, workspace_id, template_id). What changes is where the display content — title, body, image, sound, icon, etc. — lives. The two modes deliberately do not duplicate that content.

Mode 1 — Native (OS-rendered)

Pick this when you want Android to build and show the notification for you with zero app code. Zixflow puts all the display content in the notification + android.notification blocks; data carries only the tracking + routing keys — no redundant title/body/image_url/sound.

Mode 2 — Custom (app-rendered)

Pick this when your app builds the notification itself from the data payload. Zixflow sends no notification / android.notification blocks at all — every field, including the display content, lives in data, so your app receives the complete payload in every app state (foreground, background, or killed).
This payload is sent as a high-priority FCM data message (android.priority: "high"). High priority is what lets FCM wake onMessageReceived even when the device is locked or the app is backgrounded — which is exactly what you need to track Delivered reliably on this path (see Tracking Delivery When the Device Is Locked).
Practical consequence: in Custom mode, read everything from data.*. In Native mode, the OS renders the banner from notification.* while backgrounded/killed and your code isn’t invoked; if the app is in the foreground when a Native-mode push arrives, read the display content from RemoteMessage.notification (message.notification?.title and so on) since it won’t be in data. Prefer message.notification?.x ?: data["x"].
Sound: data.sound / android.notification.sound is a bare resource name with no extension (notification_toneres/raw/notification_tone.*).

Complete Key Reference

Every key that can appear in the payload, where it lives, and who reads it: Demo-only conventions used by the sample apps (not part of the official Zixflow schema): data.priority, data.analytics_label, data.ttl_seconds. The real FCM fields android.priority, fcm_options.analytics_label, and android.ttl are delivery headers and are not readable as display content.

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


Template-Based Custom Rendering (template_id)

When a customer creates a notification template in the Zixflow dashboard (Push Notifications → Templates), the template is saved with a unique Template ID. Every push sent from a saved template automatically includes that ID as data.template_id, in both Native and Custom payload modes. You do not add this field to the template’s custom fields yourself. Why this matters: without template_id, a custom-handled renderer can only branch on the content of a push (is there an image_url? a sound?). With template_id, your app can recognize which template a push came from and give it a fully bespoke layout — a dedicated notification channel, a different icon/style, extra business logic — built from every field that template is known to define. The pattern: keep a small registry mapping known template_id values to a dedicated render function, and fall back to the generic field-driven renderer (Complete Field Mapping Reference) for any template not in the registry (including when template_id is absent).

Complete Field Mapping Reference (Custom-Handled UI)

One consolidated builder covering every official field from the Complete Key Reference — title, body, image, large icon, sound, badge, sticky, and action buttons. This is the Path-B (“custom-handled”) renderer. Combine with the tracking calls from The Three Tracking Events. Matches sdk-examples/android.
Demo-only keys used above (data.priority, data.analytics_label, data.ttl_seconds) are sample-app conventions, not part of the official Zixflow schema. Every other field traces back to the Complete Key Reference.

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 onMessageReceived 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:
When using the default SDK FCM service, delivery is tracked automatically for recognized pushes. When using a custom service (example dual-path), track after the SDK helper returns false.

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, and how you fix it depends on the rendering mode. Why it happens
  • Native mode: a notification-block push is rendered by the OS directly. Your onMessageReceived handler is not invoked while the app is backgrounded/locked, so there is no client-side moment to call trackMetric(delivered). Your code only runs when the user taps (→ Opened).
  • Custom mode with normal priority: a data-only push sent at normal priority can be deferred by Doze / App Standby while the screen is off, so onMessageReceived may fire late or not until the device wakes.
The fix

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:
Default module: body opens are automatic. Custom display path — put delivery extras on the content PendingIntent and handle in the Activity.

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:
Never automatic. Pass the same notification_id into each action PendingIntent that you use with notify(). Reference: NotificationActionReceiver.kt.

Platform-Specific Integration

Token Registration

Track Delivery (Foreground — via onMessageReceived)

Track Open (Notification Tap)

Track Action Button Click


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. Action buttons never render on the Native (OS-rendered) path — attach them only when you build the notification yourself. Payload value (raw string):
Parsed structure:
Rules:
  • Maximum 2 buttons per notification
  • deeplink may be an empty string — handle gracefully, don’t navigate to a blank URL
  • Button index is 0-based

Parsing action_buttons

Building the Buttons on the Notification (Foreground Path)

NotificationActionReceiver is the BroadcastReceiver that fires the trackMetric(opened) + track("Push Notification Action Clicked") calls shown earlier — it reads back the extras you attached above.

Handling the Tap (End-to-End)

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

New Notification Layouts

Richer Android layouts (style, progress, timer, …) are not Zixflow-defined fields. If you want them, add those keys yourself in Custom (or Native custom data) and render them in your app. The tables below document the sample-app convention. These are Android-only.

style — notification layout

progress — determinate / playback bar

Shown as a horizontal bar (used with style: "media" for playback position, but works on any style).

timer — live countdown chronometer

Shows a self-updating countdown (e.g. Expires in ⏰ 00:00:00). Under the hood this maps to Android’s setUsesChronometer(true) + setChronometerCountDown(true) + setWhen(endTimeMillis) + setShowWhen(true).

Example payloads


Sticky Notifications

data.sticky is an official Custom-mode field. Apply Android flags in your notification builder — Native FCM rendering does not honor sticky (android.notification.sticky has no effect). Sample-app convention: sticky (string in data.*). Accepts three values: Both sticky modes are Android-only — there is no iOS/APNs equivalent.
How the two modes differ at the flag level:
  • until_clicksetOngoing(true) sets both FLAG_ONGOING_EVENT and FLAG_NO_CLEAR. This removes the swipe-to-dismiss affordance entirely and excludes the notification from “Clear all”. The only way the user can get rid of it is to tap it (which, with setAutoCancel(true), dismisses it).
  • until_swipeFLAG_NO_CLEAR only (set via additionalFlags, not setOngoing). The notification can still be swiped away individually, but the bulk “Clear all” button skips it.
Regardless of which mode, two things always hold — and a third piece of manual cleanup for buttons:
  • setAutoCancel(true) must always be on — this is what makes tapping the notification body remove it. Do not tie this to sticky.
  • ongoing/FLAG_NO_CLEAR (driven by the sticky value) is what actually blocks swipe and/or “Clear all” per the table above.
  • Action buttons need an explicit cancel call in your action handler. Android does not auto-dismiss a notification when an action button is pressed. Exception: for until_click (ongoing) notifications, do not auto-cancel on action-button press either — only a body tap should remove an ongoing notification.

Why Two Places Read sticky

Because of how push delivery differs by app state, the flag needs to be honored in two different code paths — and in practice, only one of them actually works: If you need sticky behavior at all, it only works when your app builds the notification itself — i.e. the custom-handled path. See Complete Field Mapping Reference for the full builder.

Android (Kotlin) — Native SDK / Manual Notification Builder

If you add a sticky key in custom data and the standard Zixflow Android SDK displays the notification, it applies ongoing / related flags from that value. Native OS-rendered pushes still ignore sticky.
Every Zixflow push may carry a deeplink_url at the top level of data and per-button deeplinks in action_buttons. Your app is responsible for routing these to the right in-app screen, falling back to an external browser/app when the link isn’t one of your own screens. 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
click_action is a separate, custom-handled-only concept — not part of the deeplink system above. Sample apps additionally read data.click_action on a body tap and, if it matches a known token ("OPEN_SALE", "OPEN_DASHBOARD"), resolve it to an in-app screen taking priority over deeplink_url. On the Native path, FCM click_action needs a matching <intent-filter> — a mismatch causes the tap to do nothing at all. See the Complete Key Reference.
If you’re using the standard Zixflow Android SDK (ZixflowPushNotificationHandler.kt), deep link resolution is handled automatically — you don’t need to write any routing code. This lives in PushMessageProcessorImpl.handleNotificationDeepLink() and runs in this order:
  1. Host-app override — if you registered a notificationCallback in moduleConfig and its onNotificationClicked(context, payload) returns non-null, the SDK stops there and your callback owns navigation entirely.
  2. Try your own app first — builds Intent(ACTION_VIEW, uri).setPackage(context.packageName) and checks resolveActivity().
  3. Fall back externally — if no Activity in your app claims the URI, queryIntentActivities() looks for any other installed app.
  4. Default launcher fallback — if nothing matches at all, your app’s normal launcher Activity opens.
To make your own screens resolvable, add matching intent filters:
Or, to fully own routing yourself, implement NotificationCallback.onNotificationClicked():
If you’re not using the SDK’s built-in display path (e.g. a custom FirebaseMessagingService fallback), you resolve the URI yourself:

Decision Flowchart


Fallback Behaviour (No Zixflow-Delivery-ID)

If the push notification was sent by a non-Zixflow source, 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 (or an internal alias) in the event’s properties before treating it as a delivery report, specifically so a name collision with your own custom event of the same name does not silently swallow it. If you ever do send a custom event named exactly Delivered, Opened, or Clicked for your own purposes but it has no delivery identifier in its properties, it is stored as a normal profile event like any other — it is not treated as a push metric. When a Zixflow-originated push tracking call is correctly matched, its event is not stored as a user profile event — the delivery report itself is sufficient. If you want to track a non-Zixflow push for your own analytics, use a custom event name instead.

Testing

Enable ZixflowLogLevel.DEBUG. In the dashboard → MessagingPush Notifications, check Delivered, Opened, and Clicked.