Sending FCM Messages from a Backend and End-to-End Testing
Sending FCM Messages from a Backend and End-to-End Testing
Until now you have been triggering test notifications from the Firebase Console. In production, your server is the one that dispatches messages — targeting individual devices, user segments, or entire topics. This lesson covers how to send FCM messages programmatically using the Firebase Admin SDK (Node.js / Python / Java / Go) and the FCM HTTP v1 REST API, how to subscribe Flutter devices to named topics, and how to verify the complete delivery chain across all three app states: foreground, background, and terminated.
Obtaining Credentials for the Admin SDK
The Admin SDK authenticates with a service account private key rather than the old server key. To obtain it:
- Open Firebase Console → Project Settings → Service accounts
- Click Generate new private key and download the JSON file
- Store it outside your source tree and load it via an environment variable (never commit it to git)
Install the Admin SDK in Node.js with npm install firebase-admin, then initialise once per process:
Node.js — initialise Firebase Admin SDK
const admin = require('firebase-admin');
const serviceAccount = require(process.env.FIREBASE_CREDENTIALS_PATH);
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
const messaging = admin.messaging();
Sending a Targeted (Token-Based) Message
Each Flutter device obtains a unique FCM registration token via FirebaseMessaging.instance.getToken(). Your app must send this token to your backend (e.g. on login or token refresh) so the server can address notifications to specific users.
Node.js — send a notification to a single device token
async function sendToDevice(token, title, body, data = {}) {
const message = {
token, // FCM registration token from the Flutter app
notification: {
title,
body,
},
data, // arbitrary key-value string pairs
android: {
priority: 'high',
notification: {
channelId: 'default_channel',
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
},
},
apns: {
payload: {
aps: {
sound: 'default',
badge: 1,
},
},
},
};
const response = await messaging.send(message);
console.log('Message sent:', response); // returns a message ID string
return response;
}
Topic-Based Messaging
Topics let you broadcast to groups of devices without managing individual tokens. Devices opt in by calling FirebaseMessaging.instance.subscribeToTopic() on the Flutter side, and your backend sends one message addressed to the topic name.
Dart — subscribe / unsubscribe a device to a topic
import 'package:firebase_messaging/firebase_messaging.dart';
Future<void> subscribeToTopic(String topic) async {
await FirebaseMessaging.instance.subscribeToTopic(topic);
print('Subscribed to topic: $topic');
}
Future<void> unsubscribeFromTopic(String topic) async {
await FirebaseMessaging.instance.unsubscribeFromTopic(topic);
print('Unsubscribed from topic: $topic');
}
On the server side you address the message to /topics/<name>. With the Admin SDK the syntax is:
Node.js — send a message to a topic
async function sendToTopic(topic, title, body) {
const message = {
topic, // e.g. 'breaking-news' (no leading slash needed)
notification: { title, body },
android: { priority: 'high' },
};
const response = await messaging.send(message);
console.log('Topic message sent:', response);
}
End-to-End Testing Strategy
A complete notification test must verify three distinct delivery paths because Flutter uses a different code path for each:
- Foreground — app is open and running; FCM calls
FirebaseMessaging.onMessage, no system tray notification appears automatically on Android (you must show one manually viaflutter_local_notifications). - Background — app is running but not in focus; FCM delivers silently and calls the background handler when the user taps the notification.
- Terminated — app is completely closed; FCM delivers via the OS; on tap, the app launches cold and you read the initial message via
FirebaseMessaging.instance.getInitialMessage().
Dart — handle all three app states
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
// Top-level background handler (must be outside any class)
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Re-initialise Firebase because this runs in a separate Dart isolate
await Firebase.initializeApp();
print('Background message: ${message.notification?.title}');
}
class NotificationService {
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
Future<void> init() async {
// Register the background handler before runApp()
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
// Foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
_showLocalNotification(message);
});
// App opened from background notification tap
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_handleNavigation(message);
});
// App launched from terminated state via notification tap
final RemoteMessage? initial =
await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
_handleNavigation(initial);
}
}
void _showLocalNotification(RemoteMessage message) {
// Display a system-tray notification while the app is in the foreground
final notification = message.notification;
if (notification == null) return;
_localNotifications.show(
notification.hashCode,
notification.title,
notification.body,
const NotificationDetails(
android: AndroidNotificationDetails(
'default_channel',
'Default',
importance: Importance.high,
priority: Priority.high,
),
),
);
}
void _handleNavigation(RemoteMessage message) {
final route = message.data['route'];
if (route != null) {
// Navigate to the screen specified in the notification payload
navigatorKey.currentState?.pushNamed(route);
}
}
}
Testing Checklist
Follow this sequence to confirm all delivery paths work before releasing to production:
- Token retrieval: Log
getToken()and confirm a non-null string on both Android and iOS. - Foreground test: Keep the app open, send a token-based message from your backend; verify the local notification appears and
onMessagefires. - Background test: Press the home button, send the message; verify the system tray notification appears and tapping it navigates correctly via
onMessageOpenedApp. - Terminated test: Force-quit the app, send the message; relaunch by tapping the notification; verify
getInitialMessage()returns the message and navigation works. - Topic test: Subscribe a device to a topic, send a topic message, repeat all three state tests above.
requestPermission() or if APNs credentials are missing in Firebase Console → Project Settings → Cloud Messaging → Apple app configuration. Always confirm APNs setup before testing on physical iOS devices.Summary
You can now send targeted FCM messages from a Node.js backend using the Admin SDK, broadcast to device groups via topics, and write Flutter code that correctly handles foreground, background, and terminated delivery. A structured end-to-end test covering all three states is essential before shipping notifications to real users — missing even one path leads to silent failures that are hard to reproduce in production.