Server-side push notifications for mobile apps built with Mob (or any app that uses APNs and FCM).

Hex.pm Docs

A focused Elixir library that wraps:

  • APNs HTTP/2 (iOS) — token-based auth with a .p8 key
  • FCM HTTP v1 (Android) — OAuth2 via Google service account

Token storage and fan-out are intentionally out of scope — bring your own persistence.

Installation

def deps do
  [
    {:mob_push, "~> 0.1"}
  ]
end

Run the onboarding task to generate config stubs and get step-by-step setup guidance:

mix mob_push.install

Or configure manually (see Configuration).


Account setup

iOS — Apple Developer account

Push notifications require a paid Apple Developer account ($99/year).

  1. Enroll at developer.apple.com/programs/enroll
  2. Individual accounts are approved instantly. Organisation accounts require a D-U-N-S number and can take several days.

Official docs: Apple — Registering your app with APNs

Android — Firebase project

FCM is free. You need a Google account and a Firebase project:

  1. Go to console.firebase.google.com
  2. Click Add project, follow the wizard (~2 minutes)
  3. Google Analytics is not required for push notifications

If you already have a Google Cloud project you can import it into Firebase instead of creating a new one.

Official docs: Firebase — Add Firebase to your Android project


Getting your credentials

iOS — APNs auth key

You need five things from the Apple Developer portal:

Step 1 — Enable push on your App ID

  • Go to Certificates, Identifiers & Profiles → Identifiers
  • Select your app (or create one if you haven't yet)
  • Under Capabilities, enable Push Notifications and save

Official docs: Configuring push notifications

Step 2 — Create an APNs Auth Key (.p8)

  • Go to Certificates, Identifiers & Profiles → Keys
  • Click +, give it a name, tick Apple Push Notifications service (APNs), click Continue → Register
  • Click Download — Apple only lets you download it once. Store it safely (treat it like a private key).

Official docs: Creating APNs authentication token signing key

Step 3 — Note your Key ID

Shown next to the key name on the Keys list, and embedded in the downloaded filename (AuthKey_XXXXXXXXXX.p8). 10 characters, uppercase alphanumeric.

Step 4 — Note your Team ID

Shown in the top-right corner of the developer portal, and under Membership Details. 10 characters, uppercase alphanumeric.

Step 5 — Note your Bundle ID

Your app's bundle identifier — the one you used when creating the App ID, e.g. com.example.myapp. Found in Identifiers. Must match exactly what's in your Xcode project and what you pass to Mob.Notify.register_push/1.

Android — FCM service account

Step 1 — Open your Firebase project

Go to console.firebase.google.com and select your project.

Step 2 — Generate a service account key

  • Click the gear icon (⚙) → Project Settings
  • Select the Service accounts tab
  • Under Firebase Admin SDK, click Generate new private keyGenerate key
  • A JSON file is downloaded — store it safely (treat it like a password). It grants full FCM send access.

Official docs: Firebase Admin SDK — Initialize the SDK

Step 3 — Note your Project ID

Shown at the top of Project Settings (also visible in the Firebase console URL as https://console.firebase.google.com/project/YOUR-PROJECT-ID). Looks like my-app-a1b2c.

Step 4 — Enable the FCM API (if needed)

New Firebase projects have the FCM HTTP v1 API enabled by default, but if you see authentication errors, verify it:

Step 5 — Add google-services.json to your Android project

The Android Firebase SDK requires a google-services.json config file. Without it, the Android build will fail.

  • In Firebase console → gear icon → Project SettingsYour apps
  • Select your Android app (or click Add app → Android to register it — you'll need your app's package name, e.g. com.example.myapp)
  • Click Download google-services.json
  • Place the file at android/app/google-services.json in your Mob project

This file contains project identifiers (not credentials) and is generally safe to commit. Keep it out of public repos if your Firebase project has billing enabled.


Configuration

Add to config/runtime.exs (recommended — keeps secrets out of source control):

import Config

# iOS push notifications (APNs)
config :mob_push, :apns,
  key_id:    System.get_env("APNS_KEY_ID",    "YOUR_KEY_ID"),
  team_id:   System.get_env("APNS_TEAM_ID",   "YOUR_TEAM_ID"),
  bundle_id: System.get_env("APNS_BUNDLE_ID", "com.example.yourapp"),
  key_file:  System.get_env("APNS_KEY_FILE",  "/path/to/AuthKey_XXXXXXXXXX.p8"),
  env:       if(config_env() == :prod, do: :production, else: :sandbox)

# Android push notifications (FCM HTTP v1)
config :mob_push, :fcm,
  project_id:          System.get_env("FCM_PROJECT_ID",          "your-firebase-project-id"),
  service_account_key: System.get_env("FCM_SERVICE_ACCOUNT_KEY", "/path/to/service-account.json")

APNs config reference

KeyTypeRequiredDescription
:key_idstringyes10-char Key ID from the Apple Developer portal Keys page
:team_idstringyes10-char Team ID from Membership Details
:bundle_idstringyesYour app's bundle identifier, e.g. com.example.myapp
:key_filestringone ofPath to the .p8 auth key file on disk
:key_pemstringone ofPEM string contents of the .p8 key (alternative to :key_file)
:envatomno:sandbox (default) or :production. Use :sandbox during development — APNs sandbox and production use different endpoints and different device tokens.

Sandbox vs production: The sandbox APNs endpoint (api.sandbox.push.apple.com) only accepts tokens from apps installed via Xcode or TestFlight development builds. The production endpoint (api.push.apple.com) only accepts tokens from App Store or TestFlight production builds. Using the wrong environment returns a 403 or silently drops the notification.

FCM config reference

KeyTypeRequiredDescription
:project_idstringyesFirebase project ID (e.g. my-app-a1b2c)
:service_account_keystringone ofPath to the service account JSON file on disk
:service_account_jsonmapone ofAlready-decoded service account map (alternative to file path, useful when credentials come from a secret manager)

Usage

Step 1 — Request permission and register in the app

In your Mob screen, call Mob.Permissions.request/2 and Mob.Notify.register_push/1:

defmodule MyApp.HomeScreen do
  use Mob.Screen

  @impl Mob.Screen
  def on_mount(socket) do
    socket = Mob.Permissions.request(socket, :notifications)
    {:ok, socket}
  end

  @impl Mob.Screen
  def handle_info({:permission, :notifications, :granted}, socket) do
    {:noreply, Mob.Notify.register_push(socket)}
  end

  def handle_info({:permission, :notifications, :denied}, socket) do
    {:noreply, socket}
  end

  def handle_info({:push_token, platform, token}, socket) do
    MyApp.PushTokens.upsert(socket.assigns.user_id, token, platform)
    {:noreply, socket}
  end
end

The {:push_token, platform, token} message arrives once the OS issues a registration token. platform is :ios or :android. Store the token alongside the platform — you need both when sending.

Step 2 — Send a notification from your server

Call MobPush.send/3 from anywhere on your server — a Phoenix controller, LiveView event, background job (Oban), etc.:

# Basic alert
MobPush.send(device_token, :ios, %{
  title: "New message",
  body:  "Alice: Hey, are you free tonight?"
})

# With data payload
MobPush.send(device_token, :android, %{
  title: "New message",
  body:  "Alice: Hey, are you free tonight?",
  data:  %{screen: "chat", thread_id: "42"}
})

# iOS — badge count + sound
MobPush.send(device_token, :ios, %{
  title:    "3 new messages",
  body:     "Alice, Bob and 1 other",
  subtitle: "in #general",
  badge:    3,
  sound:    "default"
})

# iOS — silent background push (wakes app, no alert shown)
MobPush.send(device_token, :ios, %{
  title:             "",
  body:              "",
  content_available: true,
  data:              %{action: "sync"}
})

# Raise on failure instead of returning {:error, reason}
MobPush.send!(device_token, :android, %{title: "Hi", body: "World"})

Payload options

KeyPlatformsTypeDescription
:titlebothstringNotification title (required)
:bodybothstringNotification body text (required)
:subtitleiOSstringSecond line under the title in the notification tray
:databothmapArbitrary key-value pairs delivered to the app. Values are coerced to strings.
:badgeiOSintegerBadge count shown on the app icon
:soundiOSstring"default" for the system sound, or a filename bundled in the app (without extension)
:content_availableiOSbooleanSilent push — wakes the app in the background without showing an alert
:androidAndroidmapRaw FCM AndroidConfig map — see Notification appearance (Android)

Return values

ValueMeaning
:okAccepted by APNs / FCM (delivery is best-effort from here)
{:error, :device_token_expired}APNs rejected — token is stale, deregister it
{:error, :device_token_not_found}FCM rejected — token unknown, deregister it
{:error, :auth_failed}Credentials rejected — check your config
{:error, {:apns_error, reason}}APNs rejected with a reason string (e.g. "BadDeviceToken", "Unregistered")
{:error, {:fcm_error, status, message}}FCM HTTP error
{:error, :missing_apns_key_config}:key_file or :key_pem not set in config
{:error, {:apns_key_file_unreadable, path, reason}}.p8 file could not be read
{:error, :missing_fcm_service_account_config}Neither :service_account_key nor :service_account_json is set

Notification delivery lifecycle

Understanding when and how your app receives the notification payload matters for building a good UX.

Three delivery scenarios

1. Foreground — app is running and the screen is visible

The OS does not show a system notification. Mob intercepts the payload and sends {:notification, notif} directly to your screen process.

2. Background — app is running but hidden (user pressed Home)

The OS shows a system notification in the tray. When the user taps it, the app comes to the foreground and your screen receives {:notification, notif}.

3. Killed — app is not running

The OS shows a system notification. When tapped, the app launches fresh and {:notification, notif} is delivered to your screen once the BEAM has booted.

Handling notifications in your screen

def handle_info({:notification, notif}, socket) do
  # notif is a map with string keys:
  # %{"title" => "...", "body" => "...", "data" => %{"screen" => "chat"}}
  case get_in(notif, ["data", "screen"]) do
    "chat"    -> {:noreply, Mob.Socket.push_screen(socket, MyApp.ChatScreen)}
    "inbox"   -> {:noreply, Mob.Socket.push_screen(socket, MyApp.InboxScreen)}
    _         -> {:noreply, socket}
  end
end

How delivery works under the hood (Android)

FCM carries two parallel payloads: a notification object (displayed by the OS when the app is killed/backgrounded) and a data object containing mob_notification_json — a JSON-encoded copy of the title, body, and data. This duplication is intentional:

  • Killed/backgrounded: The OS displays the notification object. When tapped, Android puts mob_notification_json in the launch intent's extras. MainActivity reads it and delivers it to BEAM once it's running.
  • Foreground: MobFirebaseService.onMessageReceived fires. It reads mob_notification_json from the data payload and delivers it to BEAM directly, bypassing the system tray.

This means your Elixir screen always gets a {:notification, notif} regardless of the app state — you don't need to write separate code paths for foreground vs. background.


Notification appearance

Android

Android notification appearance is controlled via the :android key in the payload, which maps directly to the FCM AndroidConfig and AndroidNotification objects.

MobPush.send(token, :android, %{
  title: "New message",
  body:  "Alice: Hey!",
  data:  %{screen: "chat"},
  android: %{
    "notification" => %{
      "icon"       => "ic_notification",  # drawable resource name (no extension)
      "color"      => "#FF6200EE",        # accent color in #RRGGBB or #AARRGGBB
      "sound"      => "default",          # "default" or filename in res/raw/ (no extension)
      "channel_id" => "messages",         # notification channel (Android 8+)
      "image"      => "https://cdn.example.com/avatar.jpg",  # BigPictureStyle
      "tag"        => "msg-thread-42"     # replaces previous notification with same tag
    },
    "priority" => "high"  # "high" = wakes the screen; "normal" = quiet delivery
  }
})

Small icon

The small icon appears in the status bar and notification drawer. It must be a white/transparent PNG bundled as a drawable resource in your Android project — Android does not render colored icons in the status bar.

  1. Create a white/transparent PNG at android/app/src/main/res/drawable/ic_notification.png
  2. Reference it by name (without path or extension): "icon" => "ic_notification"

If no icon is specified, Android falls back to the app launcher icon, which is often rejected by newer Android versions with a grey box.

Accent color

Sets the circle background behind the small icon and the notification accent stripe. Hex string in #RRGGBB or #AARRGGBB format.

Notification channels (Android 8+)

Android 8 (API 26) introduced notification channels. Each channel has its own sound, vibration, and importance settings that the user can control in system settings. If you specify a channel_id that doesn't exist, the notification is silently dropped on Android 8+ devices.

Channels are created by your Android app at runtime — typically in MainActivity.onCreate. If you're using the Mob generator, add channel creation to your Kotlin setup code:

// In MainActivity.onCreate, before nativeStartBeam()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    val channel = NotificationChannel(
        "messages",
        "Messages",
        NotificationManager.IMPORTANCE_HIGH
    ).apply {
        description = "New message notifications"
    }
    getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}

If you don't specify channel_id, FCM uses a default channel. The default channel uses the device's default sound and importance.

Large image (BigPictureStyle)

The "image" key displays a large image below the notification text. The URL must be publicly accessible over HTTPS. Android downloads it at display time.

Delivery priority

"priority" => "high" causes the notification to wake the screen and appear as a heads-up notification. "normal" (the default) delivers quietly. Note: this is the FCM delivery priority, separate from the notification display importance set on the channel.

iOS

iOS notification appearance is controlled by the standard payload keys:

KeyTypeDescription
:titlestringBold first line
:subtitlestringLighter second line, below the title
:bodystringMain notification text
:badgeintegerBadge count on the app icon. Pass 0 to clear.
:soundstring"default" for the system sound, or a filename (without extension) bundled in the app's main bundle

Custom sounds (iOS)

Bundle an .aiff, .wav, or .caf file in your Xcode project (add it to the app target, not a folder reference). Pass the filename without extension as :sound. Sounds longer than 30 seconds play the default sound instead.

Images (iOS)

iOS requires a Notification Service Extension (NSE) to attach images. The NSE is a separate build target in Xcode that intercepts the notification before display, downloads the image from a URL you include in the :data map, and attaches it. This is an app-side build step and is not handled by mob_push. See Apple's UNNotificationServiceExtension docs for setup.


Token management

Storing tokens

Persist tokens in your database or ETS. Each user may have multiple tokens (multiple devices, or the same device reinstalled). Always store the platform alongside the token:

# Schema example
# push_tokens: user_id, platform (:ios | :android), token, inserted_at, updated_at

def handle_info({:push_token, platform, token}, socket) do
  MyApp.PushTokens.upsert(%{
    user_id:  socket.assigns.user_id,
    platform: platform,
    token:    token
  })
  {:noreply, socket}
end

Token expiry and deregistration

Tokens become invalid when:

  • The user uninstalls and reinstalls the app
  • The user restores the device from backup
  • APNs/FCM rotates the token (rare but possible)

When MobPush.send/3 returns :device_token_expired or :device_token_not_found, delete the token immediately to avoid sending to it again:

case MobPush.send(token, platform, payload) do
  :ok ->
    :ok
  {:error, reason} when reason in [:device_token_expired, :device_token_not_found] ->
    MyApp.PushTokens.delete(token)
  {:error, reason} ->
    Logger.warning("Push failed: #{inspect(reason)}")
end

Fan-out to multiple devices

def notify_user(user_id, payload) do
  user_id
  |> MyApp.PushTokens.list()
  |> Enum.each(fn %{token: token, platform: platform} ->
    case MobPush.send(token, platform, payload) do
      :ok ->
        :ok
      {:error, reason} when reason in [:device_token_expired, :device_token_not_found] ->
        MyApp.PushTokens.delete(token)
      {:error, reason} ->
        Logger.warning("Push to #{platform} failed for user #{user_id}: #{inspect(reason)}")
    end
  end)
end

For high-volume fan-out, run sends concurrently with Task.async_stream/3 and set a reasonable concurrency limit.


Token caching

APNs JWTs (valid 1 hour) and FCM OAuth2 tokens (valid 1 hour) are cached in ETS and refreshed automatically 5 minutes before expiry. The MobPush.TokenCache GenServer is started automatically by the MobPush application — no setup needed.

If a 401 or 403 is received from APNs or FCM, the cached token is evicted and a fresh one is fetched on the next call. This handles the rare case where a token is invalidated server-side before its expiry.


Sandbox vs production (iOS)

APNs has two separate environments — sandbox and production — with different URLs and different device token namespaces:

  • Sandbox (api.sandbox.push.apple.com): for apps installed via Xcode or TestFlight development builds
  • Production (api.push.apple.com): for App Store builds and TestFlight production builds

A sandbox token sent to the production endpoint (or vice versa) returns {:error, {:apns_error, "BadDeviceToken"}}. The standard pattern is:

env: if(config_env() == :prod, do: :production, else: :sandbox)

If you're testing TestFlight production builds, you need :production in your :staging / :prod environment.


License

MIT