A Flutter plugin that wraps the native Klaviyo iOS and Android SDKs, enabling you to integrate Klaviyo's marketing automation features into your Flutter apps.
- Features
- Requirements
- Installation
- Initialization
- Platform Setup
- Profile Management
- Event Tracking
- Push Notifications
- In-App Forms
- Deep Linking
- Geofencing
- Optional Module Configuration
- Profile Reset (Logout)
- API Reference
- Troubleshooting
- Contributing
- Profile Management — Set user profiles, emails, phone numbers, and custom properties
- Event Tracking — Track custom events and user interactions
- Push Notifications — Register for and handle push notifications with automatic token handling
- In-App Forms — Display and manage in-app forms for lead capture
- Deep Linking — Handle custom URL schemes, universal links, and Klaviyo tracking links
- Geofencing — Observe geofences for location-based event tracking
| Platform | Minimum Version |
|---|---|
| Flutter | 3.x |
| Dart | 3.x |
| iOS | 15.0+ |
Android minSdkVersion |
23+ |
Android compileSdkVersion |
34+ |
| Kotlin | 1.8.0+ |
Add klaviyo_flutter_sdk to your pubspec.yaml:
dependencies:
klaviyo_flutter_sdk: ^0.1.0A complete working example is available in the example/ directory.
import 'package:klaviyo_flutter_sdk/klaviyo_flutter_sdk.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await KlaviyoSDK().initialize(
apiKey: 'YOUR_KLAVIYO_PUBLIC_API_KEY',
);
runApp(MyApp());
}The Flutter plugin depends on the native Swift and Android SDKs, which are automatically installed by the plugin. On Android, required permissions are also automatically added via manifest merging.
The sections below cover required native configuration for push notification handling.
1. Install Pods
cd ios && pod install2. Enable Capabilities
Open your project in Xcode (ios/Runner.xcworkspace), select the Runner target, go to Signing & Capabilities, and add:
- Push Notifications (Required for APNs push notifications)
- Background Modes → check Remote notifications (Required for silent push updates)
3. AppDelegate Setup
To display push notifications in the foreground and track "Open" events, update your ios/Runner/AppDelegate.swift:
- Import the plugin module:
import klaviyo_flutter_sdk - Set the notification delegate: Assign
UNUserNotificationCenter.current().delegate = selfinapplication(_:didFinishLaunchingWithOptions:) - Implement (or update) the
userNotificationCentermethods to forward events to the Klaviyo SDK (see example code below)
Note on Push Token Handling: The plugin automatically intercepts
didRegisterForRemoteNotificationsWithDeviceTokento capture the APNs token. If you override this method (e.g., for another push provider), callsuper:override func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) // Your custom token handling here }
Full AppDelegate example:
import UIKit
import Flutter
import klaviyo_flutter_sdk
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
// ... Your existing setup code ...
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// Handle foreground notifications
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
// Show banner, sound, and badge even when the app is open
completionHandler([.banner, .sound, .badge])
// ... Your custom logic (if any) ...
}
// Forward tap events to Klaviyo
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
// Forward the "Open" event to the Klaviyo SDK
KlaviyoFlutterSdkPlugin.shared.handleNotificationResponse(response)
// ... Your custom logic (if any) ...
// Complete the system callback
completionHandler()
}
// Handle silent push notifications (optional)
override func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// This method fires for both pure silent pushes (content-available: 1, no alert)
// and standard pushes that include content-available. Only forward pure silent
// pushes to the Klaviyo plugin — standard pushes are handled by willPresent/didReceive.
// Per the APNs spec, title/body are nested inside alert, so checking alert covers
// all visible-content cases.
let apsPayload = userInfo["aps"] as? [String: Any]
let hasVisibleContent = apsPayload?["alert"] != nil
if !hasVisibleContent {
KlaviyoFlutterSdkPlugin.shared.handleSilentPush(userInfo: userInfo)
}
// ... Your custom logic (if any) ...
// You MUST call the completion handler within ~30 seconds.
// Failing to do so will cause iOS to throttle or stop delivering
// silent push notifications to your app.
completionHandler(.newData)
}
}1. MainActivity Setup
To track push notification opens, handle intents in your MainActivity (at android/app/src/main/kotlin/.../MainActivity.kt). See the example MainActivity.kt for reference.
import android.content.Intent
import android.os.Bundle
import com.klaviyo.analytics.Klaviyo
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Handle notification intent on cold start
intent?.let { Klaviyo.handlePush(it) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle notification intent on warm start
Klaviyo.handlePush(intent)
}
}2. KlaviyoPushService Declaration
Declare KlaviyoPushService in your android/app/src/main/AndroidManifest.xml inside the <application> tag. This ensures Klaviyo processes FCM messages before Flutter's default FirebaseMessagingService, enabling open tracking and rich push features.
<service
android:name="com.klaviyo.pushFcm.KlaviyoPushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>See the example AndroidManifest.xml for a complete implementation.
// Set a complete profile
final profile = KlaviyoProfile(
email: 'user@test.com',
firstName: 'John',
lastName: 'Doe',
phoneNumber: '+2125557890',
properties: {
'plan': 'premium',
'signup_date': DateTime.now().toIso8601String(),
},
);
await KlaviyoSDK().setProfile(profile);// Set individual properties
await KlaviyoSDK().setEmail('user@example.com');
await KlaviyoSDK().setPhoneNumber('+1234567890');
await KlaviyoSDK().setExternalId('user123');
await KlaviyoSDK().setProfileProperties({
'preferences': {'notifications': true},
});// Track a predefined event
final openedAppEvent = KlaviyoEvent(
name: EventMetric.openedApp,
properties: {
'source': 'home_screen',
},
);
await KlaviyoSDK().createEvent(openedAppEvent);// Track a custom event
final customEvent = KlaviyoEvent.custom(
metric: 'User Completed Tutorial',
properties: {
'tutorial_id': 'intro_v2',
'completion_time_seconds': 245,
},
);
await KlaviyoSDK().createEvent(customEvent);// Track a purchase event with value
final purchaseEvent = KlaviyoEvent.custom(
metric: 'Purchase Completed',
properties: {
'currency': 'USD',
'product_id': 'prod_123',
'product_name': 'Premium Subscription',
},
value: 99.99,
);
await KlaviyoSDK().createEvent(purchaseEvent);Platform-specific setup:
- Complete the iOS Setup and Android Setup sections above
- For additional context, see the native push documentation: Android | iOS
Key requirements:
- Firebase project configured (for both platforms)
google-services.json(Android) andGoogleService-Info.plist(iOS) added to your project- Push notifications configured in your Klaviyo account settings
Permission can be managed from Flutter code or platform-specific native code. Either approach informs the Klaviyo SDK of the permission change. For Flutter-side handling, use a third-party package such as firebase_messaging or permission_handler.
The Klaviyo SDK needs to register the device's push token. Choose one of the following approaches:
Best if you already use firebase_messaging, or if you need more control over token handling (such as to send the token to multiple push providers).
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:logging/logging.dart';
final _logger = Logger('MyApp');
// Get token from Firebase and pass to Klaviyo
if (Platform.isIOS) {
String? apnsToken = await FirebaseMessaging.instance.getAPNSToken();
if (apnsToken != null) {
await KlaviyoSDK().setPushToken(apnsToken);
_logger.info('Sent APNs token to Klaviyo');
} else {
_logger.warning('APNs token was null. Waiting for refresh...');
}
} else if (Platform.isAndroid) {
String? fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken != null) {
await KlaviyoSDK().setPushToken(fcmToken);
_logger.info('Sent FCM token to Klaviyo');
}
}
// Listen for Token Refreshes (Important for long-running apps)
// Note: On iOS, this stream returns the FCM token, not APNs.
// Native APNs token changes are rare, but for Android this is crucial.
if (Platform.isAndroid) {
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
KlaviyoSDK().setPushToken(newToken);
});
// On iOS, if the APNs token changes, the OS usually relaunches the app
// or triggers distinct native callbacks.
}The simplest approach — the SDK automatically fetches the push token without requiring firebase_messaging as a direct dependency:
// iOS: Triggers APNs registration and forwards token to Klaviyo
// Android: Fetches the FCM token and registers it with Klaviyo
await KlaviyoSDK().registerForPushNotifications();For visibility into push token events, subscribe to the onPushNotification stream:
// Listen for token events
KlaviyoSDK().onPushNotification.listen((event) {
switch (event['type']) {
case 'push_token_received':
final token = event['data']['token'];
_logger.info('Token received: $token');
break;
case 'push_token_error':
final error = event['data']['error'];
_logger.warning('Token error: $error');
break;
}
});KlaviyoSDK().onPushNotification.listen((event) {
if (event['type'] == 'push_notification_opened') {
_logger.info('Notification opened: ${event['data']}');
}
});Use the isKlaviyoNotification extension to check whether a push notification payload originated from Klaviyo:
void handlePushPayload(Map<String, dynamic> payload) {
if (payload.isKlaviyoNotification) {
// This is a Klaviyo notification
}
}Silent push notifications (content-available: 1) deliver data to your app in the background without showing a banner. They are commonly used to prefetch content, sync state, or trigger background tasks.
iOS requirement: Enable "Remote notifications" under Background Modes in Xcode (covered in iOS Setup).
iOS delivers silent pushes via didReceiveRemoteNotification:fetchCompletionHandler: in your AppDelegate.swift. The setup in iOS Setup already handles this — it differentiates pure silent pushes from standard pushes that carry content-available and forwards only the former to the Klaviyo plugin.
Subscribe to onPushNotification to react to silent push events in Flutter:
KlaviyoSDK().onPushNotification.listen((event) {
if (event['type'] == 'silent_push_received') {
final payload = Map<String, dynamic>.from(event['data'] as Map);
// Perform background work: refresh content, sync state, etc.
}
});Note: This event fires only for pure silent pushes (no visible content). Standard notifications that include
content-availableare handled bywillPresent/didReceiveand will not trigger this event.
Rich Push lets you add images and videos (iOS only) to push notifications.
- Android: No additional setup needed. Refer to the Android SDK documentation for more details.
- iOS: Requires a notification service extension. Follow the CocoaPods setup steps in the iOS SDK installation guide, then see the Rich Push documentation.
Klaviyo supports setting or incrementing the badge count on iOS push notifications. This requires a notification service extension and app group — see the Swift SDK installation instructions and badge count documentation.
Android handles badge counts automatically.
// Register with default session timeout (1 hour)
await KlaviyoSDK().registerForInAppForms();// Register with a custom session timeout
final config = InAppFormConfig(
sessionTimeoutDuration: Duration(minutes: 30),
);
await KlaviyoSDK().registerForInAppForms(configuration: config);// Register with infinite session timeout (no timeout)
final infiniteConfig = InAppFormConfig.infinite();
await KlaviyoSDK().registerForInAppForms(configuration: infiniteConfig);// Unregister from in-app forms
await KlaviyoSDK().unregisterFromInAppForms();// Listen for form events
KlaviyoSDK().onFormEvent.listen((event) {
_logger.info('Form event: ${event['type']}');
});The sessionTimeoutDuration controls how long forms remain eligible to display after app backgrounding. For more details, see the native SDK documentation: Android | iOS
Klaviyo supports Deep Links for tracking link clicks and navigating to specific content within your app. This works with push notifications, in-app messages, and Klaviyo tracking links.
-
Set up deep linking in your Flutter app using a routing package:
-
Configure platform-specific deep linking (see below).
Custom URL Schemes (e.g., myapp://product/123)
Follow steps 1 & 2 ("Register the URL scheme" and "Whitelist your URL scheme") under the Handling URL Schemes section of the Swift SDK README.
FlutterAppDelegatehandlesapplication(_:open:options:)and forwards custom URL scheme deep links to Flutter automatically. No additional native code is required.
Klaviyo Universal Tracking Links (e.g., https://trk.yourdomain.com/u/abc123)
Follow steps 1 & 2 ("Configure Universal Links in your Klaviyo account" and "Add the Associated Domains Entitlement") under the Handling Universal Links section of the Swift SDK README.
FlutterAppDelegateimplementsapplication(_:continue:restorationHandler:)and forwards universal links to Flutter automatically.
Once universal links are arriving in your Flutter app, call handleUniversalTrackingLink from your router to let the Klaviyo SDK resolve tracking URLs. See the go_router integration section for an example.
The example AndroidManifest.xml demonstrates all three types of deep links:
Custom URL Schemes (e.g., myapp://product/123)
Add to your MainActivity in AndroidManifest.xml:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="YOUR_CUSTOM_SCHEME" />
</intent-filter>Additionally, this can be configured natively on Flutter. Just be sure that the deeplink path you use for actions matches your app package schema.
App Links (e.g., https://yourdomain.com/product/123)
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="yourdomain.com" />
</intent-filter>Klaviyo Universal Tracking Links (e.g., https://trk.yourdomain.com/u/abc123)
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="trk.send.yourdomain.com" />
<data android:pathPrefix="/u/" />
</intent-filter>Testing deep links:
# Custom URL scheme
adb shell am start -W -a android.intent.action.VIEW -d "myapp://product/123" com.your.package
# App link
adb shell am start -W -a android.intent.action.VIEW -d "https://yourdomain.com/product/123" com.your.packageFor complete Android App Links setup including domain verification, see the Android SDK Deep Linking Guide.
Use go_router's redirect callback to pass URLs to Klaviyo for tracking:
import 'package:go_router/go_router.dart';
import 'package:klaviyo_flutter_sdk/klaviyo_flutter_sdk.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomeScreen(),
routes: [
GoRoute(
path: 'product/:id',
builder: (context, state) => ProductScreen(
productId: state.pathParameters['id']!,
),
),
],
),
],
redirect: (context, state) {
// Fire-and-forget — Klaviyo tracks the link in the background
KlaviyoSDK().handleUniversalTrackingLink(state.uri.toString());
return null;
},
);
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await KlaviyoSDK().initialize(apiKey: 'YOUR_API_KEY');
runApp(MaterialApp.router(routerConfig: router));
}How It Works
- URL arrives via go_router's routing system
redirectcallback fires with the URL- Call
handleUniversalTrackingLink()- validates and returns immediately - Returns
trueif it's a Klaviyo tracking link (format:https://domain/u/...),falseotherwise - Native SDK tracks the click event in the background (fire-and-forget)
- Native SDK resolves the link and your Flutter routing library handles the final destination
Note: handleUniversalTrackingLink() is synchronous - it validates the URL and returns a bool immediately, while the native tracking happens asynchronously in the background.
The Klaviyo Flutter SDK supports geofencing for location-based event tracking. The full location module is not included by default — see Enabling Geofencing to opt in.
// Start monitoring geofences (requires location permissions)
await KlaviyoSDK().registerGeofencing();// Stop monitoring all geofences
await KlaviyoSDK().unregisterGeofencing();Without the full location module enabled, geofencing methods return error code GEOFENCING_NOT_AVAILABLE with instructions to enable it.
The SDK includes lightweight location interfaces by default but requires the full location module for geofencing to work.
Android — add to android/gradle.properties:
klaviyoIncludeLocation=trueThis includes the full location module with Google Play Services and adds the following permissions to your merged manifest:
android.permission.ACCESS_FINE_LOCATIONandroid.permission.ACCESS_COARSE_LOCATIONandroid.permission.ACCESS_BACKGROUND_LOCATION
iOS — add to ios/Podfile before flutter_install_all_ios_pods:
ENV['KLAVIYO_INCLUDE_LOCATION'] = 'true'This includes the KlaviyoLocation pod. You'll also need location permission descriptions in Info.plist:
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location for geofencing features.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We need background location for geofence monitoring.</string>In-app forms are enabled by default. To opt out (for a smaller SDK footprint):
Android — add to android/gradle.properties:
klaviyoIncludeForms=falseiOS — add to ios/Podfile before flutter_install_all_ios_pods:
ENV['KLAVIYO_INCLUDE_FORMS'] = 'false'Without the forms module, forms methods will log an error and no-op gracefully (error code: FORMS_NOT_AVAILABLE).
// Reset profile when user logs out
await KlaviyoSDK().resetProfile();The main SDK class. All methods are accessed via KlaviyoSDK().
| Method | Description |
|---|---|
initialize({required String apiKey}) |
Initialize the SDK |
| Method | Description |
|---|---|
setProfile(KlaviyoProfile profile) |
Set a complete user profile |
setEmail(String email) |
Set user email |
setPhoneNumber(String phoneNumber) |
Set user phone number |
setExternalId(String externalId) |
Set external user ID |
getEmail() |
Get user email |
getPhoneNumber() |
Get user phone number |
getExternalId() |
Get external user ID |
setProfileProperties(Map<String, dynamic> properties) |
Set custom profile properties |
setProfileAttribute(String propertyKey, dynamic value) |
Set a single profile attribute |
resetProfile() |
Reset user profile (logout) |
| Method | Description |
|---|---|
createEvent(KlaviyoEvent event) |
Track a profile activity event |
| Method | Description |
|---|---|
registerForPushNotifications() |
Register for push (iOS: APNs, Android: FCM) |
setPushToken(String token) |
Set push token manually |
getPushToken() |
Get current push token |
setBadgeCount(int count) |
Set badge count (iOS only) |
| Method | Description |
|---|---|
registerForInAppForms({InAppFormConfig? configuration}) |
Register for in-app forms |
unregisterFromInAppForms() |
Unregister from in-app forms |
| Method | Description |
|---|---|
registerGeofencing() |
Begin monitoring geofences |
unregisterGeofencing() |
Stop monitoring geofences |
| Method | Description |
|---|---|
handleUniversalTrackingLink(String url) |
Handle Klaviyo tracking links, returns bool |
| Method | Description |
|---|---|
setLogLevel(KlaviyoLogLevel logLevel) |
Set logging level (Flutter-side only) |
dispose() |
Clean up resources |
| Property | Type | Description |
|---|---|---|
isInitialized |
bool |
Whether the SDK is initialized |
apiKey |
String? |
Current API key |
onPushNotification |
Stream<Map<String, dynamic>> |
Push notification events |
onFormEvent |
Stream<Map<String, dynamic>> |
In-app form events |
KlaviyoProfile({
String? email,
String? phoneNumber,
String? externalId,
String? firstName,
String? lastName,
String? organization,
String? title,
String? image,
KlaviyoLocation? location,
Map<String, dynamic>? properties,
})KlaviyoEvent({
required EventMetric name,
Map<String, dynamic>? properties,
double? value,
String? uniqueId,
})
// Convenience constructor for custom events
KlaviyoEvent.custom({
required String metric,
Map<String, dynamic>? properties,
double? value,
String? uniqueId,
})KlaviyoLocation({
double? latitude,
double? longitude,
String? address1,
String? address2,
String? city,
String? region,
String? country,
String? zip,
String? timezone,
})// Default configuration (1 hour session timeout)
InAppFormConfig({
Duration? sessionTimeoutDuration,
})
// Infinite session timeout (no timeout)
InAppFormConfig.infinite()none | error | warning | info | debug | verbose
Predefined metrics:
EventMetric.openedAppEventMetric.viewedProductEventMetric.addedToCartEventMetric.startedCheckout
Custom metrics:
EventMetric.custom(String name)
| Property | Description |
|---|---|
isKlaviyoNotification |
bool — whether the map is a Klaviyo push notification payload |
All SDK exceptions extend KlaviyoException:
| Exception | Description |
|---|---|
KlaviyoNotInitializedException |
SDK used before initialize() |
KlaviyoInvalidApiKeyException |
Invalid API key provided |
KlaviyoNetworkException |
Network request failed (includes statusCode, responseBody) |
KlaviyoProfileException |
Profile operation failed |
KlaviyoEventException |
Event tracking failed |
KlaviyoPushException |
Push notification operation failed |
KlaviyoFormException |
In-app forms operation failed |
KlaviyoConfigurationException |
Configuration error |
KlaviyoPermissionException |
Missing required permission |
Push opens not tracked — Verify Klaviyo.handlePush(intent) is called in both onCreate and onNewIntent. Use singleTop or singleTask launch mode. See the example MainActivity.
Push notifications not displaying — Check Firebase setup (google-services.json, plugin applied in build.gradle). On Android 13+, runtime notification permission is required. Test with Firebase Console first to rule out Klaviyo-specific issues.
Push tokens not being set — Ensure Firebase is initialized before calling FirebaseMessaging.instance.getToken(). Initialize the SDK before calling setPushToken().
Deep links not working — Verify intent filters in AndroidManifest.xml. Test with adb shell am start -W -a android.intent.action.VIEW -d "yourscheme://path" com.your.package. For App Links, verify assetlinks.json is accessible.
Build failures — Ensure minSdkVersion 23+. Remove direct references to com.klaviyo:klaviyo-android-sdk from your gradle files (the plugin includes it automatically).
Debug logging — Add to AndroidManifest.xml:
<meta-data android:name="com.klaviyo.core.log_level" android:value="1" />For iOS-specific troubleshooting, refer to the iOS SDK documentation.
Events not in dashboard — Wait 5-10 minutes for processing. Verify your API key and that a profile is set (email, phone, or external ID) before tracking events. Verify that your Klaviyo account is active.
"Not initialized" errors — Call KlaviyoSDK().initialize() in main() before runApp(), and ensure you await the call:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await KlaviyoSDK().initialize(apiKey: 'YOUR_API_KEY');
runApp(MyApp());
}- Native SDK Docs: Android | iOS
- Klaviyo Support: https://help.klaviyo.com/
- Report Bugs: GitHub Issues
When reporting issues, include: SDK version, Flutter version, platform and OS version, steps to reproduce, relevant logs (with debug logging enabled), and code snippets.
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests if applicable
- Submit a pull request
This project is licensed under the MIT License — see the LICENSE file for details.