firebase_messaging_handler
Push notifications are only half the job. Complete Flutter FCM handling, local notifications, actions, scheduling, in-app messages, inbox, user preferences, delivery policy, and diagnostics.
Updated , by A Few Good Coders
Published 22 sectionsFlutter Package
- pub points
- 160/160
- likes
- 28
- downloads, 30 days
- 1,458
- latest version
- 2.0.0
pub.dev, as of Verify on pub.dev (opens in a new tab)BSD-3-Clause licence (opens in a new tab)
Overview
This guide covers firebase_messaging_handler 2.0.0, reviewed against the published package and repository on 13 October 2026. It connects Firebase Cloud Messaging, local notifications, click routing, a local inbox, in-app presentation, and user preferences. Your application still owns Firebase configuration, authenticated token registration, navigation, and server-side sending.
Start small: configure one platform, initialize the handler, ask permission from a user action, synchronize a token, then send one test message. Add actions, scheduling, inbox, and policy only after that path works.
An FCM send accepted by the server is not proof of device display, and a display event is not a user open. OS permissions, channels, APNs, background restrictions, and browser workers remain part of delivery.
- Unified notification click stream across foreground, background, and cold-start terminated states
- Typed notification envelope with v2 JSON Schema and TypeScript reference server
- Built-in Preference Center widget with user quiet hours, category switches, and interval caps
- Local notifications with custom actions, inline replies, BigPicture styles, and native detail escape hatches
- Inexact and exact local scheduling with timezone management and reboot persistence
- Persistent inbox storage with out-of-the-box paged NotificationInboxView widget
- In-app message presenter supporting dialog, bottom sheet, full screen, banner, and carousel templates
- Notification Doctor diagnostics and runtime capability matrix querying
Installation & Requirements
Use Dart 3.10+, Flutter 3.38.1+, Android minSdk 24 and compileSdk 36+. Check the resolved native dependencies' build requirements too. Run from your Flutter app directory and perform a full native rebuild after installation.
Add firebase_core directly because your bootstrap imports it. If your own code imports firebase_messaging or flutter_local_notifications types, declare those as direct dependencies as well instead of relying on transitive imports.
The 2.0.0 manifest permits firebase_core >=3.0.0 <5.0.0 and firebase_messaging >=16.0.4 <17.0.0. When adding firebase_update, resolve the shared compatible Firebase range rather than overriding it. Keep service-account JSON and FCM server credentials off the client.
flutter --version
flutter pub add firebase_messaging_handler:^2.0.0 firebase_core
dart pub global activate flutterfire_cli
flutterfire configure
flutter pub getPlatform Configuration
Configure Firebase for the application IDs and platforms you actually ship. flutterfire configure creates the client options used by the bootstrap; it does not complete APNs provisioning or browser worker registration.
Android
Verify the Google Services plugin/configuration, minSdk 24 and compileSdk 36+. Provide android/app/src/main/res/drawable/ic_notification.png as a white-on-transparent small icon. Declare Internet, wake lock, vibration, and notification permission as required by the app and merged manifests. Android 13+ also needs runtime notification permission.
Scheduling uses flutter_local_notifications; follow its resolved version's desugaring and receiver configuration. The repository example enables core-library desugaring with desugar_jdk_libs:2.1.4. Inexact delivery is the default. Add exact-alarm access only for a justified exact-timing feature, and boot restoration only when needed. Review the Android setup (opens in a new tab) and native notification dependency setup rather than pasting unrelated permissions.
iOS and macOS
Add the correct Firebase configuration to the signed target, enable the required push/background capabilities, and configure APNs credentials in Firebase. Keep normal Firebase delegate forwarding unless you implement the complete manual alternative. Test real remote pushes on physical hardware. Rich remote images require an app-owned Notification Service Extension; a Dart image field alone does not create one.
Web
Use the repository's v2 web/firebase-messaging-sw.template.js as the basis for firebase-messaging-sw.js. Fill in the public client Firebase config, serve/register it at the scope controlling the app, and pass the public webVapidKey. Start permission/token operations from a user gesture. HTTPS and correct worker scope matter; verify the active worker in browser developer tools. Coordinate with any existing worker rather than registering conflicting scopes blindly.
Windows and Linux
These targets use desktop local mode, not FlutterFire FCM token/topic delivery. Query capabilities before offering controls. Windows needs WindowsNotificationOptions and a correctly configured app identity for local toasts. Do not show a remote push onboarding flow that cannot work on that platform.
Quick Start & Initialization
The following complete mobile smoke-test app assumes Firebase options have been generated and @drawable/ic_notification exists on Android. It shows the last allowed notification route on screen instead of navigating; connect a validated route to your own authenticated router only after both routing and sign-in are ready.
Permission and token synchronization belong to the button flow, not automatic startup. This intentionally does not pretend a backend exists: add updateTokenCallback to FCMConfiguration when you implement authenticated registration, and return true only after the server actually accepts it.
The example owns its click subscription and cancels it on disposal. Keep one application-wide handler; do not initialize separate copies on each screen.
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging_handler/firebase_messaging_handler.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart';
final notifications = FirebaseMessagingHandler.instance;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await notifications.configureBackgroundMessageHandler(
firebaseMessagingHandlerBackgroundDispatcher,
);
final clicks = await notifications.initialize(FCMConfiguration(
androidNotificationIconPath: '@drawable/ic_notification',
defaultChannelId: 'general',
androidChannels: [NotificationChannelData(
id: 'general', name: 'General', description: 'General notifications',
)],
));
runApp(NotificationDemo(clicks: clicks));
}
class NotificationDemo extends StatefulWidget {
const NotificationDemo({super.key, required this.clicks});
final Stream<NotificationData?>? clicks;
@override
State<NotificationDemo> createState() => _NotificationDemoState();
}
class _NotificationDemoState extends State<NotificationDemo> {
StreamSubscription<NotificationData?>? subscription;
String status = 'Ready to test';
@override
void initState() {
super.initState();
subscription = widget.clicks?.listen((data) {
final route = data?.payload['route'];
if (mounted && route == '/inbox') {
setState(() => status = 'Open requested: /inbox');
}
});
}
Future<void> enableNotifications() async {
try {
await notifications.requestNotificationPermission();
final synced = await notifications.synchronizeToken();
if (mounted) setState(() => status = 'Token operation: $synced');
} catch (error) {
if (mounted) setState(() => status = 'Setup needs attention: $error');
}
}
@override
void dispose() {
subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp(
home: Scaffold(body: Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [Text(status), ElevatedButton(
onPressed: enableNotifications,
child: const Text('Enable notifications'),
)],
))),
);
}Consent, token registration, and account changes
initialize defaults to neither requesting permission nor synchronizing a token. Show a useful explanation, request permission from a user action, and then synchronize. A denied permission is not initialization failure: local inbox/in-app features can still be useful. On web, the user gesture is especially important.
Implement updateTokenCallback as a request to your authenticated backend and return true only for acknowledged registration. Associate a token with an installation and the correct signed-in user, support token rotation, and remove/reassign that association on logout or account switch. Do not print tokens into shared logs.
Topic subscriptions are useful for broad categories, not private authorization. Authorize sensitive sends server-side. In v2, unsubscribeFromAllTopics() does not delete the token, so it is not by itself a complete sign-out cleanup strategy.
Choose Who Displays Remote Notifications
This choice determines what features and guarantees are possible:
Use system presentation for time-sensitive transactional alerts (chat, security, orders). Use data-only or local presentation for policy-governed updates and contextual in-app messages.
Unified Handler & Cold Starts
There are two distinct flows: receipt/business processing and a user opening a notification. The stream returned by initialize is the open/click flow. includeInitialNotificationInStream defaults to true. If you instead use FirebaseMessagingHandler.checkInitial(), turn that flag off and route the launch once; otherwise your app can navigate twice.
Queue permitted destinations until the router and authentication are ready. Never treat an arbitrary payload route or object ID as authorization. Check the signed-in user's access at the destination.
A unified handler returning true tells the package it handled the message; returning false allows the normal package pipeline. It cannot prevent an alert the operating system already displayed. Android background work runs in a separate isolate: foreground closures and dependency injection are not automatically available there. For custom background work, use a top-level entrypoint and the documented handleBackgroundMessage(bootstrap: ...) path. Do not navigate or access widget context from that isolate.
await FirebaseMessagingHandler.instance.setUnifiedMessageHandler(
(message, lifecycle) async {
// Perform only work valid for this lifecycle and initialized dependencies.
// Return true only after handling the message successfully.
return false;
},
);Local Notifications & Custom Styles
After initialization and consent, use a local notification to isolate native presentation from FCM transport. If this fails too, investigate permissions, Android channels/icon, or native host configuration before debugging the server.
Check NotificationOperationResult.isSuccess; do not report a notification as delivered merely because the call completed. The simple request below needs only the handler import. If you add AndroidNotificationDetails or DarwinNotificationDetails, import and directly depend on flutter_local_notifications; native detail overrides are not portable web features.
final result = await FirebaseMessagingHandler.instance.showLocalNotification(
const LocalNotificationRequest(
id: 42,
title: 'Local setup works',
body: 'Tap to test the inbox route.',
channelId: 'general',
payload: {'route': '/inbox'},
),
);
if (!result.isSuccess) {
debugPrint('Local notification: ${result.errorCode} ${result.message}');
}Actions & Inline Reply
Attach action buttons and inline text replies directly to notifications. Action selections are delivered to the click and delivery event streams:
final shown = await FirebaseMessagingHandler.instance.showNotificationWithActions(
title: 'New message from Priya',
body: 'Are we still meeting at 3?',
channelId: 'general',
actionCategoryId: 'message_actions',
payload: const {'route': '/messages/123'},
actions: const [
NotificationAction(
id: 'reply',
title: 'Reply',
textInput: true,
inputLabel: 'Write a reply',
inputButtonTitle: 'Send',
inputChoices: ['Yes', 'No', 'In 10 minutes'],
),
NotificationAction(
id: 'mark_read',
title: 'Mark as read',
foreground: false,
),
],
);One-Time & Recurring Scheduling
Schedule local reminders with inexact or exact timing, daily/weekly recurring patterns, and automatic timezone refresh:
// One-time reminder
await FirebaseMessagingHandler.instance.scheduleNotification(
id: 1001,
title: 'Stand-up in 10 minutes',
body: 'Open the agenda before the call.',
scheduledDate: DateTime.now().add(const Duration(minutes: 10)),
channelId: 'general',
payload: const {'route': '/meetings/daily'},
);
// Daily recurring notification
await FirebaseMessagingHandler.instance.scheduleRecurringNotification(
id: 2001,
title: 'Daily check-in',
body: 'How are you feeling today?',
repeatInterval: 'daily',
hour: 9,
minute: 0,
);
// Cancel pending items
await FirebaseMessagingHandler.instance.cancelScheduledNotification(1001);Groups, Threads, Badges & Custom Sounds
Group notifications cleanly on Android and Apple platforms, set badge counts, and configure custom sound channels:
// Android notification grouping with summary
await FirebaseMessagingHandler.instance.showGroupedNotification(
title: 'Build finished',
body: 'Android release build completed.',
groupKey: 'builds',
groupTitle: 'Build activity',
channelId: 'general',
);
// Apple conversation threads
await FirebaseMessagingHandler.instance.showThreadedNotification(
title: 'Mina',
body: 'I added the final screenshots.',
threadIdentifier: 'conversation-123',
payload: const {'route': '/messages/123'},
);
// iOS App icon badge mutation
await FirebaseMessagingHandler.instance.setIOSBadgeCount(5);
await FirebaseMessagingHandler.instance.clearBadgeCount();
// Custom sound channel
await FirebaseMessagingHandler.instance.createCustomSoundChannel(
channelId: 'urgent',
channelName: 'Urgent alerts',
soundFileName: 'urgent_alert',
importance: NotificationImportanceEnum.max,
priority: NotificationPriorityEnum.max,
);Preferences & Delivery Policy
Define user-facing categories in the initialization configuration and pass deliveryPolicy there. Merely creating a policy variable does not install it. Use the same category IDs in client requests and server envelopes.
The preference center edits the handler's preference controller after initialization. Quiet hours use local device time; zero-width windows are not all-day quiet hours. Caps and intervals affect package-managed delivery. For background system-presented FCM alerts, enforce the equivalent preference on the server before sending.
The snippets below are alternatives to add to your existing configuration and settings screen—not a second initialization flow.
// Add to the FCMConfiguration passed to initialize:
// deliveryPolicy: const NotificationDeliveryPolicy(
// quietHours: NotificationQuietHours(startHour: 22, endHour: 7),
// globalInterval: Duration(seconds: 30),
// perCategoryInterval: Duration(minutes: 5),
// globalDailyCap: 30,
// perCategoryDailyCap: 8,
// ),
// In a settings screen after initialization:
NotificationPreferenceCenter(
controller: FirebaseMessagingHandler.instance.notificationPreferences,
);Persistent Notification Inbox
Retain notifications in a local typed inbox backed by SharedPreferences or a custom storage adapter. Includes the ready-made NotificationInboxView with paging, mark-read, and swipe-to-delete:
This is device-local history, not a synchronized server inbox or encrypted secret store. Use a consistent storage configuration, decide retention and deletion behavior, and explicitly handle account switches. Validate routes before navigation; do not directly trust the incoming string. The following fragment demonstrates the widget API; adapt storage ownership to your application.
class InboxScreen extends StatelessWidget {
final InboxStorageService storage = InboxStorageService(maxItems: 100);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Inbox')),
body: NotificationInboxView(
storage: storage,
pageSize: 20,
onItemTap: (item) {
final route = item.data['route'];
if (route == '/inbox') Navigator.of(context).pushNamed('/inbox');
},
onActionTap: (actionId, item) {
debugPrint('Inbox action $actionId for ${item.id}');
},
),
);
}
}In-App Messages & Templates
In-app templates are Flutter UI, not OS push notifications. Configure a shared navigator with setInAppNavigatorKey(navigatorKey) and give that same key to your MaterialApp/router. Register templates once; present only when a mounted foreground UI is available.
The example uses an app-owned TrialEndingSheet widget that you must implement. This is an integration fragment, not a complete runnable application. Validate remote content and destinations; do not turn an arbitrary payload into executable behavior.
Deferred campaigns can be queued for later presentation. Define expiry, dismissal, and sign-out behavior so an old or another user's campaign is not shown at the wrong time.
FirebaseMessagingHandler.instance.registerInAppNotificationTemplates({
'trial_ending': InAppNotificationTemplate(
id: 'trial_ending',
description: 'Shown before a trial expires',
barrierDismissible: true,
onDisplay: (data) async {
await InAppTemplatePresenter.instance.showBottomSheet<void>(
builder: (context) => TrialEndingSheet(data: data),
);
},
),
});- Built-in generic layouts: bottom_sheet, dialog, full_screen, banner, snackbar, and carousel
- Trigger modes: immediate, next_foreground, app_launch, and custom
- Durable queuing: deferred in-app messages are saved until the user foregrounds the app
- Queue management: flush or clear pending campaigns programmatically
Delivery Events & Privacy Analytics
Listen to deliveryEvents for operational visibility and cancel the subscription at its owner's teardown. Keep receipt, suppression, display, and open metrics separate. Client telemetry is not proof of server-side business completion.
Analytics defaults to metadata-only. Apply analyticsOptions and analyticsCallback in the original initialization configuration, not by reinitializing from a screen. Never log raw FCM tokens, full message contents, or personal payloads merely to diagnose delivery.
final subscription = FirebaseMessagingHandler.instance.deliveryEvents.listen((event) {
debugPrint('${event.type.name} / ${event.surface.name}: ${event.reason}');
});
// In teardown:
await subscription.cancel();Payloads & Server Envelope Contract
FCM HTTP v1 requires all values in message.data to be strings. NotificationEnvelope enforces this rule and serializes typed payloads safely:
A complete tested TypeScript reference server is available in server/ with token stores, idempotency, batching, and exponential retry.
final envelope = NotificationEnvelope(
id: 'order-7821-shipped',
idempotencyKey: 'order-7821-shipped-v1',
title: 'Order shipped',
body: 'Order #7821 is on the way.',
category: 'orders',
route: '/orders/7821',
channelId: 'general',
timeToLive: const Duration(days: 2),
sentAt: DateTime.now().toUtc(),
data: const {'orderId': '7821'},
);
final Map<String, String> fcmData = envelope.toFcmData();Send and verify the first remote message
After authenticated token registration, send a small test through your trusted server's FCM HTTP v1/Admin SDK integration. Start with system presentation and a single allowlisted route. Never ship a service-account key or server OAuth access token in the Flutter app.
Then test the data-only path separately: enable enableDefaultDataOnlyBridge: true, configure a valid channel, and send an envelope with remotePresentation: client. This feature is false by default; an arbitrary data payload is not automatically a local notification. All FCM message.data values must be strings, including booleans, numbers, arrays, and nested objects after encoding.
Use the envelope's stable ID and idempotency key for logical events; use expiry fields to avoid stale alerts. Validate against the repository JSON Schema and inspect typed suppression reasons rather than repeatedly resending the same event. The reference server is a starting implementation, not a hosted service that automatically stores your users' tokens.
Notification Doctor & Capabilities
Run diagnostics to troubleshoot configuration, inspect permissions, and query runtime capability flags across platforms:
// Run built-in notification doctor
final result = await FirebaseMessagingHandler.instance.runDiagnostics();
debugPrint('Platform: ${result.platform}');
debugPrint('Permission: ${result.authorizationStatus}');
debugPrint('FCM Token available: ${result.fcmTokenAvailable}');
for (final rec in result.recommendations) {
debugPrint('Recommendation: $rec');
}
// Query capability matrix
final capabilities = await FirebaseMessagingHandler.instance.getCapabilities();
if (capabilities.supports(NotificationCapability.exactScheduling)) {
await FirebaseMessagingHandler.instance.requestExactAlarmPermission();
}Troubleshooting Guide
Outside-in checklist for common notification challenges:
- Notification never arrives: Run
runDiagnostics(). Confirm the token matches the current Firebase project. Check Android channel importance and iOS APNs key upload (.p8). If data-only, check device battery saver / background restrictions. - Android notifications silent or low priority: Android channel settings are immutable once created on the device. Changing importance in Dart does not update an existing channel — increment the channel ID or edit in system settings.
- Scheduling does not trigger: Ensure scheduled date is in the future. For exact timing on Android 13+, call
requestExactAlarmPermission(). CallrefreshLocalTimezone()after device travel. - Web notifications fail in background: Verify
firebase-messaging-sw.jsis registered at the root domain scope and served over HTTPS.
Release checklist: test states, not just a happy-path push
Use physical devices and production-like signed builds. Include Android notification-channel user settings and Apple Focus/permission settings in test notes. Do not promise data-only delivery after a user force-stops an app or under every power restriction.
Migrating to v2 and managing lifecycle
Audit any older init(...) calls: they remain a compatibility path and opt into permission/token work, whereas new initialize(FCMConfiguration) separates those operations. Adopt the explicit consent flow rather than assuming unchanged behavior.
Scheduling defaults to inexact; request exact modes and permission deliberately. Action/group/thread helpers return Future<bool>; await and inspect them. Pending schedule inspection is typed. Unsupported operations may return false, an empty result, or a typed failure—do not fabricate success in UI.
dispose() removes listeners but does not cancel already visible notifications. Cancel schedules and clear application-owned state explicitly when the product requires it. A screen should normally dispose only its subscriptions; the shared handler belongs to the application lifecycle.
Source trail and deeper reference
Release: pub.dev 2.0.0 (opens in a new tab). Repository snapshot: 6e7d051 (opens in a new tab).
Reviewed the public handler, FCMConfiguration, typed models, native example configuration, setup guides (opens in a new tab), v2 migration (opens in a new tab), and envelope contract (opens in a new tab). Consult the versioned API (opens in a new tab) for exact extension signatures.