Klaviyo Push Notifications in Flutter: 3 Silent Pitfalls
Klaviyo pushes silently dropped, taps swallowed by firebase_messaging, Android channels stuck at wrong importance. Three bugs, code fixes included.
For CTOs, tech leads, and senior developers debugging why Klaviyo push notifications stopped working in their Flutter app — or about to find out why they will.
Klaviyo × Flutter series (part 3 of 4): planning & scope · the analytics layer · push notification pitfalls · profiles & newsletter.
TL;DR: The Klaviyo Flutter SDK’s push setup looks like a 10-minute job — until you add it to an app that already uses firebase_messaging. Three silent bugs surfaced in production:
- Silently dropped pushes: Multiple Android services competing for the
MESSAGING_EVENTintent caused Klaviyo pushes to vanish without a trace. - Broken tap handling: On both platforms, Klaviyo’s tap handler broke
firebase_messaging’sonMessageOpenedAppcallback — deep links stopped working. - Invisible marketing pushes: Android’s write-once channel importance meant marketing notifications displayed silently instead of as heads-up banners.
The 10-minute install and the 3-day debug
The Klaviyo Flutter SDK’s push notification setup documentation reads like a checklist: add the klaviyo_flutter_sdk package, pass the FCM token, register for remote notifications on iOS. If you follow it from scratch in a new project, it works. The problems appear when you add Klaviyo to an app that already uses firebase_messaging — which is every Flutter app that already sends push notifications.
The collision is not a Klaviyo bug. It is a consequence of how Android’s intent system and iOS’s notification delegate chain work when multiple frameworks compete for the same system callbacks. The SDK documentation does not warn you because the SDK works correctly in isolation. The failures are integration-level, not SDK-level, and they produce no error logs, no crashes, no warnings. Pushes just stop arriving, taps stop responding, or banners stop popping up — and you have nothing in the console to search for.
This post covers three pitfalls I hit during a production Klaviyo integration into a Flutter e-commerce app. The overview post maps the full integration scope; this one zooms into the push layer. Each pitfall took longer to diagnose than to fix.
Pitfall 1: Your Klaviyo pushes are being silently dropped
On Android, push notifications arrive through Firebase Cloud Messaging. FCM delivers each incoming message to a service registered with the com.google.firebase.MESSAGING_EVENT intent filter. The critical detail: FCM dispatches to exactly one service. If multiple services declare the same intent filter, Android picks one based on service resolution order — and the others receive nothing.
After adding the Klaviyo SDK, the merged Android manifest contained three competing services:
FlutterFirebaseMessagingService— registered by thefirebase_messagingpluginKlaviyoPushService— registered by the Klaviyo SDK- A custom service — the app’s own messaging service
FCM picked one. The others were silently excluded. No exception, no log entry. Messages dispatched to the wrong service simply vanished. If FlutterFirebaseMessagingService won, Klaviyo pushes were dropped. If KlaviyoPushService won, the app’s own push handling broke.
The fix: a single unified service
The solution is a single custom service that extends FlutterFirebaseMessagingService and delegates Klaviyo messages based on the _k payload marker:
// AppPushService.kt
package com.example.app
import android.util.Log
import com.google.firebase.messaging.RemoteMessage
import com.klaviyo.pushFcm.KlaviyoNotification
import io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService
class AppPushService : FlutterFirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
if (message.data.containsKey("_k")) {
try {
KlaviyoNotification(message).displayNotification(applicationContext)
} catch (t: Throwable) {
Log.e(TAG, "KlaviyoNotification.displayNotification failed", t)
}
}
super.onMessageReceived(message)
}
companion object {
private const val TAG = "AppPushService"
}
} Extending FlutterFirebaseMessagingService ensures that firebase_messaging’s Dart-side callbacks (onMessage, onMessageOpenedApp) keep working for non-Klaviyo pushes via the super.onMessageReceived(message) call. The _k check gates only the Klaviyo rendering. Every message still flows through the Flutter plugin’s handler.
The second half of the fix is removing the competing services from the merged manifest:
<service android:name=".AppPushService" android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove"/>
<service
android:name="com.klaviyo.pushFcm.KlaviyoPushService"
tools:node="remove"/> The tools:node="remove" directive strips both plugin-registered services from the final merged manifest. Only AppPushService remains, and FCM has exactly one target.
Pitfall 2: Push taps stop working on both platforms
After fixing delivery, a second problem appeared: tapping a notification stopped doing anything useful.
Two things broke independently on the two platforms.
On Android, Klaviyo’s SDK builds its own PendingIntent for the notification it displays. That intent does not carry the FCM extras that firebase_messaging needs to populate the RemoteMessage object in onMessageOpenedApp. When the user taps a Klaviyo notification, the app opens, but firebase_messaging’s tap callback receives an empty message — or never fires at all. The Dart-side onMessageOpenedApp handler that normally routes deep links gets nothing.
On iOS, the problem was in the UNUserNotificationCenter delegate chain. The initial implementation forwarded every notification tap to Klaviyo’s handler without calling super. That meant firebase_messaging’s plugin — which also hooks into the same delegate method — never saw non-Klaviyo taps. The app’s own push-based navigation stopped working entirely.
The _k marker and the iOS trap
The fix is gating on Klaviyo’s _k marker on both platforms, and always calling super so firebase_messaging keeps working for non-Klaviyo pushes.
There is a subtle platform difference here that cost debugging time: on Android, _k sits top-level in the FCM data payload. On iOS, it is nested under the body key of the APNs payload. A naive check for userInfo["_k"] on iOS will miss it.
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
let isKlaviyoPush =
(userInfo["body"] as? [AnyHashable: Any])?["_k"] != nil
|| userInfo["_k"] != nil
if isKlaviyoPush {
KlaviyoFlutterSdkPlugin.shared.handleNotificationResponse(response)
}
super.userNotificationCenter(
center,
didReceive: response,
withCompletionHandler: completionHandler
)
} The double check — body._k first, then top-level _k as a fallback — handles both the current APNs payload structure and guards against future changes.
The critical line is super.userNotificationCenter(...). Without it, firebase_messaging never receives the tap event, and onMessageOpenedApp on the Dart side stays silent for non-Klaviyo pushes. Always call super, regardless of whether the push is from Klaviyo.
Dart-side handling: skip Klaviyo taps
On the Dart side, the onMessageOpenedApp handler should skip messages that carry the _k marker. The Klaviyo native SDK already tracks the open event when the user taps a Klaviyo notification — if the Dart handler also processes it, you get double handling: Klaviyo registers the open, and your app also tries to route a deep link that does not exist in the Klaviyo payload.
The gate is straightforward: check for _k in message.data on Android, skip if present. On iOS the Klaviyo tap is already handled natively and typically does not bubble to onMessageOpenedApp at all if the delegate chain is set up correctly.
Pitfall 3: Android channel importance is write-once
This one is the quietest failure mode. Klaviyo pushes arrive. Taps work. But the notifications show up silently in the notification shade instead of popping up as heads-up banners. Marketing pushes that nobody sees are marketing pushes that do not convert — and in a DTC e-commerce app, push notifications are a direct revenue channel. Silent notifications mean lost sales, not just a UX inconvenience.
The cause is an Android platform behavior: notification channel importance is immutable after creation. Once a channel is registered with Importance.DEFAULT, no code can raise it to Importance.HIGH. The channel ID is the key — same ID, same importance, forever, until the user manually changes it in system settings or the app deletes and recreates the channel with a new ID.
If the app’s notification channels were originally created at default importance — which is common, since default importance is the… default — then switching to Klaviyo for marketing pushes does not magically upgrade those channels. The push arrives, lands in the existing channel, and displays silently.
The fix: bump the channel ID
The only reliable fix is to create new channels with new IDs at the desired importance level, and delete the legacy channels on startup.
enum NotificationChannelId {
marketing('marketing_v2'),
content('content_v2'),
loyalty('loyalty_v2');
const NotificationChannelId(this.id);
final String id;
} The _v2 suffix is a convention, not a requirement — any new string works. The point is that it is a different channel ID, so Android creates a fresh channel with the importance level you specify at creation time. On app startup, delete the legacy channel IDs so users do not see duplicate entries in their notification settings.
This generalizes beyond importance: any channel-level property change in Android — sound, vibration pattern, LED color — requires an ID bump. Android’s channel system is designed to give users control after the channel is created. The app gets one shot at the defaults.
What this means for your estimate
If someone quotes you a day for “adding Klaviyo push notifications to the Flutter app,” they are quoting the SDK installation. The SDK installation is real — it takes an hour or two. But if the app already has firebase_messaging, the three pitfalls above are near-certain, and each one takes longer to diagnose than to fix.
A realistic estimate for push notification integration in a Flutter app that already uses firebase_messaging:
- Delivery routing (Pitfall 1): 0.5–1 day. The fix is small; diagnosing silent drops is the time sink.
- Tap collision (Pitfall 2): 0.5–1 day. Two platforms, two different
_klocations, both need testing. - Channel importance migration (Pitfall 3): 0.25–0.5 day. Straightforward once diagnosed.
- iOS Notification Service Extension (if rich push is required): 0.5–1 day. Klaviyo’s rich push (images, GIFs, action buttons) requires a separate Xcode target that the Flutter toolchain does not scaffold. The extension code itself is minimal —
KlaviyoSwiftExtensionhandles media downloads — but the Xcode configuration, provisioning profile, and app group setup are all manual steps.
Total: 2–3.5 days for push alone, not counting QA across both platforms with real devices. End-to-end push delivery through Klaviyo’s infrastructure requires physical devices — the iOS simulator supports basic push simulation (drag-and-drop APNs payloads since Xcode 11.4), but the full Klaviyo routing chain only works on real hardware. Budget for device builds on both platforms.
If you are evaluating a Klaviyo integration as part of a larger app development project, the push layer is just one of four workstreams — the overview post maps the full scope.
Planning a Klaviyo integration for your Flutter app? I’ve shipped this in production and documented the hours — book a 20-minute coffee chat and bring your tech stack. I’ll tell you where the days will go.
The pattern across all three pitfalls
The failure is silent, the diagnosis takes longer than the fix, and the root cause is a platform behavior — not a Klaviyo bug. Here is what that means in practice:
- Silent failures are the worst failures. All three pitfalls produce zero log output. The push arrives at the device, gets dispatched to the wrong handler or the wrong channel, and disappears. Add logging at the native service entry point before you start debugging anything else.
- The
_kmarker is your routing key. Every Klaviyo-specific code path — delivery, taps, Dart-side handling — should gate on this single marker. Memorize where it lives: top-level in Android data payloads, nested underbodyin iOS APNs payloads. tools:node="remove"is essential in multi-SDK Android apps. When two plugins register services for the same intent filter, the merged manifest creates a race condition. Stripping the plugin-registered services and replacing them with a single custom service is the pattern.- Channel importance is a one-shot decision. If you are setting up notification channels for the first time, think about importance carefully. If the channels already exist at default importance, the only path to heads-up banners is a new channel ID.
- Budget platform debugging, not SDK installation. The SDK works. The platform integration is where the days go.
For debugging all three pitfalls, start with native-layer logging at the service entry point. On Android, add a Log.d call at the top of onMessageReceived in your custom service — if you never see the log line, the service is not receiving messages. On iOS, log in the didReceive delegate method. These entry-point logs are the fastest way to determine which layer is swallowing the message.
With push delivery, taps, and channels sorted, the remaining integration work was identity and newsletter — which introduced its own set of silent data-integrity bugs. Part 4 covers those traps.
Related reading
- Klaviyo Shopify Integration in Flutter: What to Know First — the full project scope and why push was the hardest workstream.
- One trackEvent(), Four SDKs: Analytics Layer for Flutter — the dispatcher architecture that the push event tracking flows through.
- Klaviyo Profiles & Newsletter: Where the Docs Won’t Save You — the identity and newsletter traps that came after push was sorted.
- Smart Push Notifications: The JITAI Framework — designing push strategies that users actually open.
Frequently Asked Questions
How do I set up Klaviyo push notifications in Flutter?
Install the klaviyo_flutter_sdk package, pass the FCM token to Klaviyo on app startup, and register for remote notifications on iOS. The SDK documentation covers the basic steps — the setup itself takes an hour or two. The real work starts when you hit the three integration pitfalls described above, which are near-certain if the app already uses firebase_messaging.
Can I test Klaviyo push notifications in the iOS simulator?
Partially. The iOS simulator supports basic push simulation via drag-and-drop APNs payloads (since Xcode 11.4), and Android emulators with Google Play Services can receive FCM messages. However, end-to-end delivery through Klaviyo’s infrastructure — where Klaviyo sends the push via FCM/APNs — requires physical devices on both platforms. The three integration pitfalls described above all manifested during real-device testing.
Does the MESSAGING_EVENT conflict apply to other push SDKs besides Klaviyo?
Yes. Any SDK that registers its own FirebaseMessagingService in the Android manifest will create the same intent filter collision. The pattern — a single custom service that extends FlutterFirebaseMessagingService and delegates based on payload markers — applies to any multi-SDK push setup.
Do I need to fix all three pitfalls, or can I skip some?
Pitfall 1 (delivery routing) is mandatory — without it, either Klaviyo pushes or your existing pushes will be silently dropped. Pitfall 2 (tap collision) depends on whether your app uses onMessageOpenedApp for deep linking. Pitfall 3 (channel importance) only matters if you need heads-up banners for marketing pushes, but marketing pushes that display silently have significantly lower engagement.