Push Notifications & Background Services

Sending FCM Messages from a Backend and End-to-End Testing

16 min Lesson 11 of 11

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.

Note: The legacy FCM HTTP API (v1-before-2023) is deprecated. Google replaced it with the FCM HTTP v1 API which uses short-lived OAuth 2.0 access tokens instead of a static Server Key. Always use the v1 API or the Admin SDK (which wraps it) for new projects.

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);
}
Tip: Topic names may only contain letters, digits, hyphens, underscores, and dots. They are case-sensitive. A device can be subscribed to up to 2,000 topics. Subscriptions are stored on FCM servers, not in your database — no additional backend storage is required.

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 via flutter_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 onMessage fires.
  • 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.
Warning: On iOS, notifications are silently dropped if the user has not granted permission via 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.

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.