A Few Good CodersStart a project
Flutter Package

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.

Dart
flutter --version
flutter pub add firebase_messaging_handler:^2.0.0 firebase_core
dart pub global activate flutterfire_cli
flutterfire configure
flutter pub get

Platform 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.

Dart
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:

Delivery pathWho displays it?What to account for
FCM notification payloadFCM/APNs and the operating system handle background and terminated display.Client-side quiet hours cannot stop an alert the OS already displayed. Enforce those on your server.
Data-only FCM payloadThe package evaluates delivery policy and creates a local notification.Allows client quiet hours and caps, but background execution is best-effort under OS battery rules.
Local or in-app notificationYour app requests package presentation or scheduling.Permissions, quiet hours, and frequency caps are applied cleanly.

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.

Dart
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.

Dart
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:

Dart
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:

Dart
// 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:

Dart
// 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.

Dart
// 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.

Dart
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.

Dart
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.

Dart
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:

{
  "message": {
    "token": "DEVICE_FCM_TOKEN",
    "notification": {
      "title": "Order shipped",
      "body": "Order #7821 is on the way."
    },
    "data": {
      "schemaVersion": "2",
      "id": "order-7821-shipped",
      "idempotencyKey": "order-7821-shipped-v1",
      "command": "display",
      "remotePresentation": "system",
      "category": "orders",
      "route": "/orders/7821",
      "storeInInbox": "true",
      "data": "{\"orderId\":\"7821\"}"
    }
  }
}

A complete tested TypeScript reference server is available in server/ with token stores, idempotency, batching, and exponential retry.

Dart
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:

Dart
// 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(). Call refreshLocalTimezone() after device travel.
  • Web notifications fail in background: Verify firebase-messaging-sw.js is registered at the root domain scope and served over HTTPS.

Release checklist: test states, not just a happy-path push

TestEvidence to capture
Fresh install before consentNo surprise prompt; usable in-app UI
Permission granted / deniedHonest UI state and explicit token result
Foreground message and tapOne presentation, correct allowlisted route
Background notification and tapOS delivery plus correct resume routing
Terminated launch from notificationOne cold-start navigation after auth/router readiness
Data-only messageBridge configuration, policy outcome, and OS background limitations
Duplicate / expired envelopeDedupe/expiry outcome, no repeated business action
Local schedule and timezone changeFuture time, intended scheduling mode, cancellation, capability checks
Reinstall / token rotation / account switchBackend association stays correct; no previous-user inbox exposure
Web worker updateActive worker version, scope, consent flow, click forwarding

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.