Last updated 2026-05-28
Mobile push channel
Mobile push delivers notifications to a native Android or iOS app via
Firebase Cloud Messaging (HTTP v1). v0.1 ships the FCM provider;
APNs / Azure / AWS slot into the same ChannelMobilePush
registry entry in later waves.
When you'd use this
Anything you'd want to push to a native app whose process isn't currently running. Chat messages, calendar reminders, system alerts, anything that needs the OS notification surface.
Provider: FCM HTTP v1
The provider is a ~150-line hand-rolled HTTP v1 client — we
deliberately do not use the Firebase Admin SDK because v1 send is
one POST and JWT auth is a single call into
golang.org/x/oauth2/google; the Admin SDK's transitive
closure isn't worth it for the surface notify exercises.
Configuration
-e NOTIFY_MOBILEPUSH_PROVIDER=fcm \-e NOTIFY_FCM_PROJECT_ID=my-firebase-project \-e NOTIFY_FCM_CREDENTIALS_JSON="$(cat ./service-account.json)"
The provider parses the service-account JSON eagerly during
fcm.New, so a malformed credential blob fails at
construction — not on first send. The OAuth2 token source is built
once and reused across sends.
Library wiring
import ( "os" "github.com/elloloop/notify" "github.com/elloloop/notify/channels/fcm")
provider, err := fcm.New(notify.MobilePushConfig{ FCMProjectID: "my-firebase-project", FCMCredentialsJSON: os.Getenv("FCM_SERVICE_ACCOUNT_JSON"),})if err != nil { /* invalid credentials or missing project id */ }
registry := notify.NewProviderRegistry()registry.Register(provider)Android — registering an FCM token
// Kotlinimport com.google.firebase.messaging.FirebaseMessaging
FirebaseMessaging.getInstance().token .addOnCompleteListener(OnCompleteListener { task -> if (!task.isSuccessful) { Log.w("notify", "Fetching FCM registration token failed", task.exception) return@OnCompleteListener } val token = task.result // Send token to notify. notifyApi.registerPushToken( RegisterPushTokenRequest( deviceType = DeviceType.DEVICE_TYPE_ANDROID, token = token, ) ) })iOS — registering an FCM token
// Swiftimport FirebaseMessaging
Messaging.messaging().token { token, error in guard let token = token, error == nil else { print("Error fetching FCM token: \(error?.localizedDescription ?? "")") return } notifyApi.registerPushToken( deviceType: .ios, token: token, )}Apple Push Notification service (APNs) sits under FCM here — your Xcode project registers for remote notifications and Firebase surfaces an FCM token. notify treats it as one address, regardless of upstream pipeline.
Sending a mobile push
curl -sX POST http://notify:8081/elloloop.notify.v1.NotificationInternalService/Notify \ -H 'Content-Type: application/json' \ -H "X-Notify-Internal-Token: $NOTIFY_INTERNAL_TOKEN" \ -d '{ "tenantId": "acme", "notificationId": "msg-9001", "userIds": ["user-alice"], "channels": ["DELIVERY_CHANNEL_MOBILE_PUSH"], "title": "Bob", "body": "Are you around?", "addresses": { "user-alice": { "byChannel": { "mobile_push": "fzM-zR...:APA91bH..." } } } }'Wire payload
The provider posts this body to /v1/projects/$PROJECT/messages:send:
{ "message": { "token": "fzM-zR...:APA91bH...", "notification": { "title": "Bob", "body": "Are you around?" }, "data": { "deep_link": "/chats/9001" } }}
The data map comes from Message.Data; it is
delivered to your client as a flat string-to-string map alongside
the notification.
Handling ErrUnregisteredToken
FCM signals "this device token is no longer valid" two ways:
HTTP 404 + status: NOT_FOUND, or any
response with details[].errorCode == "UNREGISTERED".
The provider folds both into ErrUnregisteredToken:
import "github.com/elloloop/notify/channels/fcm"
receipt, err := provider.Send(ctx, msg)if err != nil { if errors.Is(err, fcm.ErrUnregisteredToken) { // Purge the device row in your store. deviceStore.Delete(ctx, msg.Notification.TenantID, msg.Notification.UserID, "android") } else { log.Warn("fcm_send_failed", "err", err) }}The provider itself never auto-purges. See Channels & Providers for the rationale.
Testing without hitting FCM
Both the HTTP client and the OAuth2 token source are injectable via
constructor options. Tests use an httptest.Server and a
static-token oauth2.TokenSource so they never reach
google.com:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body fcmMessage _ = json.NewDecoder(r.Body).Decode(&body) if body.Message.Token != "test-token" { t.Fatalf("token = %q", body.Message.Token) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"name":"projects/p/messages/100"}`))}))defer srv.Close()
p, _ := fcm.New( notify.MobilePushConfig{FCMProjectID: "p", FCMCredentialsJSON: minimalServiceAccountJSON}, fcm.WithHTTPClient(srv.Client()), fcm.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake"})),)p.SetBaseURL(srv.URL)
receipt, err := p.Send(context.Background(), notify.Message{To: "test-token", Title: "hi", Body: "there"})Receipts
On success the receipt carries the FCM message name (e.g.
projects/my-project/messages/100) as
ProviderMessageID and StatusDelivered.
Future providers
- APNs direct — bypass FCM for iOS, P8 JWT auth.
- Azure Notification Hubs.
- AWS SNS Mobile Push.