Documentation

Push notifications

Register FCM and APNs tokens with AppEngine and handle foreground, background, and terminated state.

Push notifications travel from your code on AppEngine through Firebase Cloud Messaging (FCM, Android) or Apple Push Notification service (APNs, iOS) to the device. AppEngine stores the device token against the signed-in customer or user record and uses it on broadcast, lead-assignment, order-update, or any custom automation that wants to reach the device.

The Flutter side is firebase_messaging. The server side is the existing client-account endpoint.

POST/client-account/notifications/push-tokenJWT
POST /client-account/notifications/push-token
Authorization: Bearer <jwt>
orgid: <your-org-id>

{
  "token": "<fcm-or-apns-token>",
  "platform": "ios" | "android",
  "deviceId": "<unique-device-id>",
  "appVersion": "1.0.0+1"
}

The endpoint stores the token on the customer/user record, scoped to the org. Refresh the token on every app start — FCM rotates tokens, and a stale token sends pushes into the void.

Setup

dependencies:
  firebase_core: ^3.6.0
  firebase_messaging: ^15.1.3
  flutter_local_notifications: ^17.2.3 # foreground UI on iOS/Android
  device_info_plus: ^10.1.2

Run the FlutterFire CLI to register the app with Firebase and generate firebase_options.dart:

dart pub global activate flutterfire_cli
flutterfire configure

iOS

Enable Push Notifications and Background Modes (Remote notifications) capabilities in ios/Runner.xcworkspace. Add an APNs Authentication Key to your Firebase console.

In ios/Runner/AppDelegate.swift:

import UIKit
import Flutter
import Firebase

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    FirebaseApp.configure()
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Android

Drop google-services.json into android/app/ (FlutterFire CLI does this). Update android/build.gradle and android/app/build.gradle per the FlutterFire docs — the CLI applies the changes automatically.

Add the notification permission to AndroidManifest.xml for Android 13+:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

PushService

// lib/services/push_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'http_client.dart';

class PushService {
  static final PushService _instance = PushService._internal();
  factory PushService() => _instance;
  PushService._internal();

  final _fcm = FirebaseMessaging.instance;
  final _http = appmintHttp;

  Future<void> initialize() async {
    // Permission prompt (iOS shows native dialog; Android 13+ also prompts).
    final settings = await _fcm.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );
    if (settings.authorizationStatus == AuthorizationStatus.denied) return;

    // Foreground presentation on iOS.
    await _fcm.setForegroundNotificationPresentationOptions(
      alert: true,
      badge: true,
      sound: true,
    );

    final token = await _fcm.getToken();
    if (token != null) await _registerToken(token);

    // Refresh the server every time FCM rotates.
    _fcm.onTokenRefresh.listen(_registerToken);

    // Listen to handlers.
    FirebaseMessaging.onMessage.listen(_onForeground);
    FirebaseMessaging.onMessageOpenedApp.listen(_onOpenedFromBackground);
    FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
  }

  Future<void> _registerToken(String token) async {
    final info = await _platformInfo();
    try {
      await _http.post('/client-account/notifications/push-token', body: {
        'token': token,
        'platform': info.platform,
        'deviceId': info.deviceId,
        'appVersion': info.appVersion,
      });
    } catch (e) {
      if (kDebugMode) debugPrint('push register failed: $e');
    }
  }

  void _onForeground(RemoteMessage message) {
    // Show an in-app banner or update a stream — never assume the OS will.
    // On iOS, foreground notifications are silent unless setForegroundNotificationPresentationOptions
    // is enabled (above) or you draw the UI yourself with flutter_local_notifications.
  }

  void _onOpenedFromBackground(RemoteMessage message) {
    // Deep-link: `data['route']` from the server payload.
    final route = message.data['route'];
    if (route != null) {
      // navigatorKey.currentState?.pushNamed(route);
    }
  }

  static Future<_PlatformInfo> _platformInfo() async {
    final plugin = DeviceInfoPlugin();
    if (defaultTargetPlatform == TargetPlatform.iOS) {
      final ios = await plugin.iosInfo;
      return _PlatformInfo(
        platform: 'ios',
        deviceId: ios.identifierForVendor ?? 'unknown',
        appVersion: '1.0.0+1',
      );
    }
    final android = await plugin.androidInfo;
    return _PlatformInfo(
      platform: 'android',
      deviceId: android.id,
      appVersion: '1.0.0+1',
    );
  }
}

@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
  // Runs in a separate isolate. Keep work minimal — no HTTP calls,
  // no UI. For data-only pushes, optionally write to a Hive box that
  // the main isolate reads on resume.
}

class _PlatformInfo {
  final String platform;
  final String deviceId;
  final String appVersion;
  _PlatformInfo({required this.platform, required this.deviceId, required this.appVersion});
}

Register after the user signs in, so the token is associated with a real customer. Calling /client-account/notifications/push-token requires JWT auth — if the user signs out, you should also call a delete-token endpoint (or wait for natural rotation).

Foreground vs background vs terminated

Each state behaves differently:

StateWhat happensWhere you handle it
Foreground (app open)iOS suppresses the OS banner unless you opt in via setForegroundNotificationPresentationOptions. Android always delivers if it was sent as a notification message.FirebaseMessaging.onMessage
Background (app backgrounded)OS shows the notification. Tapping wakes the app and triggers the opened handler.FirebaseMessaging.onMessageOpenedApp
Terminated (force-quit / cold-start by tap)Tap launches the app. The triggering message is in getInitialMessage().FirebaseMessaging.instance.getInitialMessage() after the navigator is ready
Background isolate (data-only push)A second isolate boots, runs your handler, then dies.FirebaseMessaging.onBackgroundMessage (must be a top-level function)

Wire the cold-start case once the navigator is mounted:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) async {
    final initial = await FirebaseMessaging.instance.getInitialMessage();
    if (initial != null) _handleDeepLink(initial);
  });
}

Topic subscription

Some pushes are broadcast to all users in a tenant — promotions, system announcements, new event drops. AppEngine can target FCM topics directly. Subscribe from the client per audience:

await FirebaseMessaging.instance.subscribeToTopic('org-${orgId}-promos');
await FirebaseMessaging.instance.unsubscribeFromTopic('org-${orgId}-promos');

Topic names are 1-256 chars, alphanumeric, dot/dash/underscore only. Namespace by orgId so a multi-tenant build never crosses streams.

Server-side: how AppEngine sends pushes

AppEngine ships notifications through the broadcast and automation modules. A typical flow:

  1. An automation or controller calls into the notification command.
  2. The command looks up stored push tokens for the target customer/user and platform.
  3. It signs the FCM/APNs payload and dispatches via the worker queue.

For your client, none of that matters — you just need the token registered and the handlers wired. Server-side details live in the Broadcast and Automation sections.

Quiet hours and badges

The server doesn't track whether the user is asleep — that's a client concern. If you need quiet hours, store the user's preferences in a custom collection and check them in your onBackgroundMessage handler before showing a local notification. For badges, decrement on app open by reading an unread count from the relevant collection (orders pending, leads new, etc.) rather than letting the push payload set the badge — push delivery is unreliable enough that an authoritative read is safer.

Testing

Send a test push from Firebase Console (Cloud Messaging) using the FCM token printed during init. For local tests, the firebase_messaging example app shows how to consume tokens directly. Don't rely on simulators for APNs — run on a real iOS device with a development certificate.

The next page covers error handling — including how to gracefully recover when push registration fails because the user denies notification permissions.