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

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 in one of two ways. You choose Native or Custom in the dashboard — and that choice decides the payload shape (see APNs Wire Format). Native mode puts display content in aps.alert; Custom mode also sends top-level display keys and mutable-content: 1 so your Notification Service Extension can enrich the banner. The one thing both paths share: tracking is never automatic. Zixflow only learns about delivery/opens/clicks when your app calls trackMetric() / track().
  • Path A (Native, app backgrounded/killed): willPresent does not run. Track Opened from didReceive on tap. For Delivered on the lock screen, add a Notification Service Extension.
  • Path B (Custom): Zixflow sends mutable-content: 1. Track Delivered from the NSE the moment the push arrives, then track Opened / Push Notification Action Clicked from your tap handlers.
Minimal “OS renders it, I only track” implementation:
Reference implementation: sdk-examples/ios ships a runtime “Custom handling” toggle that switches live between path A and path B on the same running app.
iOS keeps aps.alert in both modes on purpose. Unlike Android, iOS will not show anything on the lock screen from your custom keys alone — a visible push requires an aps.alert. So in Custom mode Zixflow still sends a minimal aps.alert (so the banner appears when the app is backgrounded/locked) plus mutable-content: 1, and your Notification Service Extension reads the top-level keys to enrich the banner and to track delivery.

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. They appear as top-level keys next to aps (in userInfo). The SDK’s trackMetric() method requires both. The rest of the payload depends on the rendering mode chosen in the dashboard (see APNs Wire Format):
  • Native (OS-rendered): display content lives in aps.alert; top-level keys are tracking + routing only.
  • Custom (app-enriched): top-level keys also carry title/body/image/buttons, and aps sets mutable-content: 1 so your NSE can enrich the banner.
Example Custom-mode payload:
Example Native-mode payload (tracking + routing only at top level):
Important: action_buttons is a JSON string (not a nested object). Parse it with JSONSerialization before use.

How to handle and show push notifications sent by Zixflow

APNs (iOS) Wire Format

iOS has no notification vs data split — everything lives in one flat JSON object. Apple’s own fields live under the reserved aps key; every Zixflow-specific field is a top-level sibling key to aps (not nested inside it, and not inside aps.alert). The dashboard rendering mode decides where the display content lives.

Mode 1 — Native (OS-rendered)

aps.alert carries the title/body and aps carries the badge/sound/category; the Zixflow-specific top-level keys carry only tracking + routing — no redundant title/body. See the Native example above.

Mode 2 — Custom (app-enriched)

The full display content lives in the top-level custom keys, and aps sets mutable-content: 1 so your Notification Service Extension can enrich/replace the banner (attach the image, adjust text) before it is shown. See the Custom example above.
iOS keeps aps.alert in both modes on purpose. Unlike Android, iOS will not show anything on the lock screen from your custom keys alone — a visible push requires an aps.alert, and a pure silent (content-available-only) push is throttled when the device is locked. So in Custom mode Zixflow still sends a minimal aps.alert plus mutable-content: 1, and your NSE reads the top-level keys to enrich the banner and to track delivery. This is why iOS Custom mode isn’t fully “data-only” the way Android’s is.
This whole object — aps plus every custom key next to it — is exactly what your app receives as userInfo in willPresent, didReceive response:, or the NSE’s didReceive(_:withContentHandler:).
Sound: aps.sound does include the extension (notification_tone.caf — must be bundled; .caf, .aiff, or .wav).

Complete Key Reference

Field Support Summary: Native vs. Custom-Handled


Template-Based Custom Rendering (template_id)

Every push sent from a dashboard template includes template_id (both modes). On iOS, template-based customization happens in the Notification Service Extension — the only place the app can mutate the notification content before it’s shown, including while the device is locked. Most template fields (large_icon_url, sticky, action_buttons) have no client-side equivalent on iOS, so the example below applies the one that does — badge:
willPresent can also read userInfo["template_id"] for diagnostic logging while the app is in the foreground — but the NSE is what actually runs the customization on the lock screen.

Complete Field Mapping Reference (Custom-Handled UI)

The NSE example below attaches the official image_url field. sticky and large_icon_url have no iOS equivalent.

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: .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 willPresent handler.
SDK method: Zixflow.instance.trackMetric(deliveryID:, deviceToken:, event: .delivered)
What happens: Zixflow updates the campaign delivery record to delivered. No profile event is stored.
Parameters:
Delivered is reliable when you add a Notification Service Extension + App Groups. See Push Notifications.

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. willPresent (foreground) and didReceive (tap) are the only default delegate hooks — neither runs when a banner simply arrives on the lock screen. The fix: add a Notification Service Extension and have Zixflow send mutable-content: 1 (already set in Custom mode). The NSE’s didReceive(_:withContentHandler:) runs the instant the push arrives — even while the device is locked or the app is killed — which is the only client-side hook available on the lock screen. Track Delivered from there. The NSE is a separate target and typically doesn’t link the Zixflow SDK, so track by calling the Zixflow tracking endpoint directly, inside the extension’s ~30s budget:
Note on credentials: the NSE calls the tracking HTTP API with a write-only key, never a service-account or admin credential. Scope the key to event ingestion only.

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: .opened)
What happens: Zixflow updates the campaign delivery record to opened. No profile event is stored.
With autoTrackPushEvents(true), body taps are tracked automatically. You do not need a wrapper for that path. 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

Token Registration

Track Delivery (Foreground — UNUserNotificationCenterDelegate)

Track Open (Background / Terminated Tap)


Action Buttons Format

The action_buttons field is a JSON-encoded string of {name, deeplink} objects. You must parse it before use. On iOS, buttons still require a pre-registered UNNotificationCategory (ZX_2BTN with ACTION_0 / ACTION_1) — the payload cannot register categories by itself. Payload value (raw string):
Parsed structure:
Rules:
  • Maximum 2 buttons per notification (iOS system limit)
  • deeplink may be an empty string — handle gracefully, don’t navigate to a blank URL
  • Button index is 0-based
  • On iOS, button labels are pre-registered at app init due to OS constraints. Use generic labels (“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

Unlike Android/Flutter/RN, iOS does not let you attach dynamic buttons to an individual notification. Instead, the two generic actions (ACTION_0 / ACTION_1) are registered once at app launch, and every notification that should show buttons just needs its categoryIdentifier set to match:
The actual button labels shown to the user come from the title: passed to UNNotificationAction(identifier:title:options:) at registration time — generic (“Action 1”, “Action 2”) because iOS has no per-push dynamic label API. The real, campaign-specific label (action_name) and action_deeplink only exist in the payload’s action_buttons and are resolved at tap time in your didReceive response: handler.

Handling the Tap (End-to-End)


Sticky Notifications

Sticky is an optional custom-data key some Android apps define. There is no iOS/APNs equivalent. Ignore it on iOS.

Every Zixflow push may carry a deeplink_url at the top level 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 click_action key in custom data is optional — the sample app maps "OPEN_SALE" / "OPEN_DASHBOARD" ahead of deeplink_url. iOS has no native click_action.

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 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, use a custom event name instead.

Testing

Enable .logLevel(.debug). Dashboard → MessagingPush Notifications: Delivered, Opened, Clicked. Use a physical device.