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

// Kotlin
import 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

// Swift
import 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.

Related