Push notifications are the single highest-leverage retention tool a mobile app has. Done well, they pull a lapsed user back into a 30-day cohort. Done badly, they get your app uninstalled in under a week. In 2026 the FCM stack has shifted under everyone's feet: the legacy server key API is gone, Android 13 forces a runtime permission, and iOS 18 introduced new interruption levels. This guide is the end-to-end Flutter setup I ship in every production app.
Short version: install firebase_messaging and flutter_local_notifications, register a top-level background handler, request the right permission per platform, define one high-importance Android channel per notification type, and route notification taps through your existing go_router deep link handler. Send from a server using the FCM HTTP v1 API with an OAuth access token. Test on real devices.
How FCM works in 2026
Firebase Cloud Messaging is still the default cross-platform delivery layer. The 2024 deprecation of the legacy HTTP and XMPP APIs is now fully enforced: as of June 2024 Google stopped accepting requests that use a static server key. Every backend now signs requests with a short-lived OAuth 2.0 access token derived from a service account JSON and POSTs to the HTTP v1 endpointhttps://fcm.googleapis.com/v1/projects/PROJECT_ID/messages:send.
On the device, FCM brokers between your app and the platform transport: APNs on iOS, the FCM socket on Google Play Android, and HMS or direct sockets on Android forks. Your Flutter code talks to one API. The token FirebaseMessaging.instance.getToken() returns is a per-install identifier you send to your server and store next to the user record.
A delivered notification has two parts: a notification payload (title and body that the OS will render automatically when the app is backgrounded) and a data payload (key-value pairs your code reads). The combination matters because it changes which handler fires in which app state.
Setup: firebase_messaging, flutter_local_notifications, permissions
You need three packages. Pin to the latest majors in 2026:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.6.0
firebase_messaging: ^15.1.0
flutter_local_notifications: ^17.2.0
permission_handler: ^11.3.0Why both firebase_messaging and flutter_local_notifications? Because the OS will not show a notification banner while the app is in the foreground on either platform unless you ask it to. flutter_local_notifications is what you use to actually render the banner in the foreground case, and it is also where you define Android channels and iOS presentation options.
// lib/notifications/notifications_bootstrap.dart
final _local = FlutterLocalNotificationsPlugin();
Future<void> initNotifications() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
const androidInit = AndroidInitializationSettings('@drawable/ic_notification');
const iosInit = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
await _local.initialize(
const InitializationSettings(android: androidInit, iOS: iosInit),
onDidReceiveNotificationResponse: _onLocalNotificationTap,
);
await _createAndroidChannels();
await _requestPermission();
await _wireMessageHandlers();
}Note requestAlertPermission: false. We trigger the iOS permission prompt ourselves at a moment that makes sense to the user, not at app launch. More on that below.
iOS-specific setup: APNs key, capabilities, provisional auth
FCM cannot deliver to iOS without an APNs auth key uploaded to your Firebase project. Create one in the Apple Developer portal under Certificates, Identifiers and Profiles, then upload the .p8 file plus the Key ID and Team ID under Project Settings, Cloud Messaging in the Firebase console. One key works for every app and every environment.
In Xcode, open Runner, Signing and Capabilities, and add:
- Push Notifications capability.
- Background Modes with Remote notifications and Background fetch checked.
- If you plan to use Critical Alerts, the explicit Critical Alerts entitlement from Apple (requires special permission).
On iOS, request authorization with the granularity you need. The 2026 default for most apps is alert plus badge plus sound, with provisional: true for low-friction quiet delivery on first launch, and a hard prompt later when the user takes a relevant action.
Future<void> _requestPermission() async {
if (Platform.isIOS) {
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: true,
criticalAlert: false,
);
if (settings.authorizationStatus == AuthorizationStatus.provisional) {
// Notifications arrive silently in Notification Center until the user
// taps Keep on the first one. No system prompt shown.
}
await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
alert: true, badge: true, sound: true,
);
} else if (Platform.isAndroid) {
await _requestAndroidPostNotificationsPermission();
}
}iOS 15 introduced interruption levels. iOS 18 kept them and added smarter Focus integration. UseinterruptionLevel in the APS payload from your server:
- passive for marketing and digests.
- active for normal notifications (default).
- timeSensitive for things that should break through Focus modes (auth codes, ride-share, time-bound offers). Requires the Time Sensitive Notifications entitlement.
- critical for safety alerts only. Requires a separate Apple approval.
Android 13+ POST_NOTIFICATIONS runtime permission
Since Android 13 (API 33), notifications are an opt-in runtime permission like camera or microphone. If your app targets API 33 or higher and you do not request it, your app shows zero notifications, no error. This is the single most common reason people email me with "FCM stopped working."
Future<void> _requestAndroidPostNotificationsPermission() async {
if (!Platform.isAndroid) return;
final sdkInt = (await DeviceInfoPlugin().androidInfo).version.sdkInt;
if (sdkInt < 33) return;
final status = await Permission.notification.status;
if (status.isDenied) {
await Permission.notification.request();
}
}Best practice: do not call this at first launch either. Show a soft pre-prompt screen that explains the value ("Get notified the moment your order is ready"), and only then trigger the system prompt. If the user denies twice, Android stops showing the system dialog. Your only recourse is to deep-link them to settings via openAppSettings() frompermission_handler.
Foreground, background, and terminated state handlers
This is the part that confuses most people. Flutter apps have three states and FCM behaves differently in each one.
| App state | Handler that fires | What you must do |
|---|---|---|
| Foreground | FirebaseMessaging.onMessage | Manually show a banner with flutter_local_notifications. |
| Background (suspended) | FirebaseMessaging.onBackgroundMessage | Must be a top-level function. Do minimal work. OS renders the banner. |
| Terminated (killed) | None until tap, then getInitialMessage | Read the initial message after init and handle deep link. |
| Notification tapped (any state) | FirebaseMessaging.onMessageOpenedApp | Route to the destination screen. |
// MUST be a top-level function (not a method, not a closure).
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Persist, log, or schedule a local notification. Do not touch UI.
}
Future<void> _wireMessageHandlers() async {
// Foreground: render with local notifications.
FirebaseMessaging.onMessage.listen((message) {
final n = message.notification;
if (n == null) return;
_local.show(
n.hashCode,
n.title,
n.body,
NotificationDetails(
android: AndroidNotificationDetails(
message.data['channelId'] ?? 'default',
'Default',
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(presentBanner: true, presentSound: true),
),
payload: jsonEncode(message.data),
);
});
// Background to foreground via tap.
FirebaseMessaging.onMessageOpenedApp.listen((m) => _handleTap(m.data));
// App launched from a terminated state via tap.
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) _handleTap(initial.data);
}The @pragma('vm:entry-point') annotation is required on the background handler so the Dart compiler does not tree-shake it in release builds. Forget this and your background handler silently never fires in release. Ask me how I know.
Notification channels and importance on Android
Android channels are the user-facing dial for what your app is allowed to do. Each channel has its own importance, sound, vibration pattern, and on-lockscreen visibility. Users can disable channels individually in system settings, which is great for retention because they can mute marketing without muting transactional alerts.
Future<void> _createAndroidChannels() async {
if (!Platform.isAndroid) return;
final android = _local.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await android?.createNotificationChannel(const AndroidNotificationChannel(
'transactional',
'Order updates and security alerts',
description: 'Time-sensitive updates about your account.',
importance: Importance.max,
));
await android?.createNotificationChannel(const AndroidNotificationChannel(
'social',
'Mentions and replies',
description: 'When someone interacts with your content.',
importance: Importance.high,
));
await android?.createNotificationChannel(const AndroidNotificationChannel(
'marketing',
'News and recommendations',
description: 'Tips, offers, and product updates.',
importance: Importance.defaultImportance,
));
}Design rules I follow:
- One channel per category of notification, not per notification.
- Transactional always on
Importance.maxwith sound on. - Marketing always on
Importance.defaultImportanceor lower, no sound. - Never delete a channel and recreate it with the same ID. The OS remembers the user's preferences only for IDs that have never been seen disabled.
Rich notifications: images, actions, expandable
On Android, attach a BigPictureStyleInformation and your image renders inline. On iOS, you need a Notification Service Extension target that downloads the image and attaches it to the notification before delivery. Both are worth it for marketing because rich notifications convert roughly 2 to 3x better than text-only.
// Foreground render with rich image (Android).
final big = BigPictureStyleInformation(
FilePathAndroidBitmap(await _downloadToTemp(message.data['imageUrl'])),
largeIcon: const DrawableResourceAndroidBitmap('@drawable/ic_large'),
);
final android = AndroidNotificationDetails(
'marketing', 'News and recommendations',
styleInformation: big,
actions: const [
AndroidNotificationAction('view', 'View'),
AndroidNotificationAction('dismiss', 'Not now'),
],
);For iOS, add a Notification Service Extension in Xcode, set the mutable-content: 1flag in your APS payload, and download the image attachment in the extension. Apple still caps payload size at 4KB and image download time at 30 seconds, so host images on a fast CDN.
Deep linking from a notification tap with go_router
The whole point of a push is to drop the user into the right screen. The cleanest pattern is to treat the notification payload as a deep link and route it through the same handler you use for Universal Links. If your app already has go_router wired up (see the go_router 2026 guide), this is two lines.
void _handleTap(Map<String, dynamic> data) {
final path = data['path'] as String?;
if (path == null) return;
// Use a global key so this works before any widget is built.
final ctx = appRouter.routerDelegate.navigatorKey.currentContext;
if (ctx == null) {
// App not ready yet, queue it.
_pendingDeepLink = path;
return;
}
appRouter.go(path);
}Server side, put the destination path in the data payload alongside thenotification payload:
{
"message": {
"token": "<device-token>",
"notification": { "title": "Order ready", "body": "Pick up at counter 3" },
"data": { "path": "/orders/19284", "channelId": "transactional" },
"android": { "priority": "HIGH" },
"apns": { "headers": { "apns-priority": "10" }, "payload": { "aps": { "interruption-level": "time-sensitive" } } }
}
}Topic subscriptions and segmentation
Topics are FCM's built-in fan-out. Subscribe a device withFirebaseMessaging.instance.subscribeToTopic('ios_us_free') and any send to that topic goes to every subscribed device with no token list to manage. Combine topics with conditions like 'ios_us_free' in topics && 'v2_5' in topicsfor cheap segmentation.
Topics are great for broadcasts. They are terrible for personalized messages because you cannot target an individual device through a topic. Use them for product announcements, breaking news, and version-gated rollouts. Use direct tokens for everything user-specific.
Backend: sending from Cloud Functions vs your own server
For most indie apps, Cloud Functions for Firebase is the right choice. The admin SDK handles OAuth automatically, retries on transient failures, and runs next door to the FCM service.
// functions/src/sendPush.ts
import { onCall } from 'firebase-functions/v2/https';
import { getMessaging } from 'firebase-admin/messaging';
export const sendOrderReady = onCall(async (req) => {
const { token, orderId } = req.data;
await getMessaging().send({
token,
notification: { title: 'Order ready', body: 'Pick up at counter 3' },
data: { path: '/orders/' + orderId, channelId: 'transactional' },
android: { priority: 'high' },
apns: { headers: { 'apns-priority': '10' }, payload: { aps: { 'interruption-level': 'time-sensitive' } } },
});
});For a self-hosted backend, generate an OAuth access token from your service account JSON with the https://www.googleapis.com/auth/firebase.messaging scope and POST to the v1 endpoint. Cache the access token for its full one-hour lifetime, do not re-mint per request.
Testing on real devices and the FCM console
The simulator and emulator will both let you down. iOS Simulator has supported push since Xcode 14 via drag-and-drop APNs files, but the experience does not match a real device. Android emulators without Google Play services cannot receive FCM at all. Always validate on a real device of each platform before shipping.
The Firebase console has a Send test message button per token. Paste a token fromgetToken(), fill in title and body, and send. Use this to confirm delivery before you blame your server code. Once delivery works, move to your own send script for payload iteration.
// Quick test script (Node, with admin SDK initialized).
await getMessaging().send({
token: process.env.TEST_TOKEN!,
notification: { title: 'Hello from FCM', body: 'If you see this, you are wired up.' },
data: { path: '/' },
});Anti-patterns to avoid
- Do not request notification permission at app launch. Conversion rates are roughly 3x higher when you prompt after the user has seen value.
- Do not forget
@pragma('vm:entry-point')on the background handler. Release builds will silently drop it. - Do not send both
notificationanddatawith overlapping keys. The OS will use thenotificationpayload to render and yourdatahandler may not fire as you expect. - Do not store FCM tokens forever. Tokens rotate on reinstall, OS upgrade, and data clear. Refresh them via
onTokenRefreshand prune stale tokens server-side. - Do not send marketing pushes on the transactional channel. Users will mute the entire channel and you lose security alerts too.
- Do not skip Android channels. Targeting API 33 plus without channels means zero delivery, full stop.
- Do not test only on the latest OS. Android 13, 14, 15, and 16 all behave slightly differently around channels and importance. Test the floor of your minSdkVersion.
- Do not call
getTokenbeforeFirebase.initializeApp.Race conditions here are nasty because they only show up on cold start.
Analytics and conversion tracking
A push notification is a marketing campaign with a CTR. You need three events to measure it:
- push_sent from your server with the campaign ID.
- push_delivered via the FCM delivery receipt (Android) or BigQuery export.
- push_opened from
onMessageOpenedAppwith the campaign ID echoed back from the data payload.
Tag every push with a campaignId and a variant in the data payload. Log to PostHog or Amplitude on tap. Compare opened over delivered to get your CTR, and opened over a 7-day retention cohort to get the actual lift on retention. Without this, you are flying blind and you will gradually over-message your users.
What The Flutter Kit ships
The Flutter Kit ships with the full FCM stack pre-wired:firebase_messaging plus flutter_local_notifications, a top-level background handler with the correct pragma, three pre-configured Android channels (transactional, social, marketing), an iOS provisional auth flow, an Android 13 soft pre-prompt screen, deep-link routing through go_router, and a Cloud Function template for sending via the HTTP v1 API. Plus PostHog analytics events on send, deliver, and open.
$69 one-time, unlimited commercial projects. See every integration on the features page or jump to checkout.
Final recommendation
Push notifications are easy to wire up and hard to get right. The plumbing is a one-day job. The strategy (when to prompt, what channels to expose, how to segment, how to measure) is what actually moves retention. Get the plumbing right with the patterns above, then spend 80% of your time on the strategy. And test on real devices, every release, every OS version. The permission and channel landscape will shift again in 2027, and you want a setup you can update in an afternoon, not a refactor.