Mob supports both local notifications (scheduled on-device) and remote push notifications (sent from your server). Both deliver the same {:notification, notif} message to your screen process, regardless of whether the app was in the foreground, backgrounded, or fully killed when the notification arrived.
Overview
| Local | Push | |
|---|---|---|
| Scheduled by | The device itself | Your server |
| Requires internet | No | Yes |
| Requires server | No | Yes (+ credentials) |
| Works when killed | Yes (OS delivers on schedule) | Yes (OS wakes on arrival) |
| Requires permission | Yes (:notifications) | Yes (:notifications) |
The device-side API (MobNotify) ships in the mob_notify plugin — add the
dep and activate it in mob.exs (see the Plugins guide):
# mix.exs
{:mob_notify, "~> 0.1"}
# mob.exs
config :mob, :plugins, [:mob_notify]Delivery is core behavior: {:notification, notif} and
{:push_token, platform, token} arrive in your screen's handle_info/2
(the shape is below and in Mob.Notification). The server side is the
separate mob_push package.
Local notifications
Schedule a notification to fire at a specific time or after a delay.
Requesting permission
def on_mount(socket) do
socket = Mob.Permissions.request(socket, :notifications)
{:ok, socket}
end
def handle_info({:permission, :notifications, :granted}, socket) do
{:noreply, Mob.Socket.assign(socket, :notify_ok, true)}
end
def handle_info({:permission, :notifications, :denied}, socket) do
{:noreply, socket}
endScheduling
# At a specific time
MobNotify.schedule(socket,
id: "reminder_1",
title: "Time to check in",
body: "Open the app to see today's updates",
at: ~U[2026-06-01 09:00:00Z],
data: %{screen: "reminders"}
)
# After a delay
MobNotify.schedule(socket,
id: "cooldown",
title: "Cooldown complete",
body: "Ready to go again",
delay_seconds: 3600
)Cancelling
MobNotify.cancel(socket, "reminder_1")Receiving
Every notification arrives as {:notification, notif}, where notif is a
Mob.Notification map:
%{
presentation: :tap, # or :foreground
action: "default", # nil for :foreground
source: :local, # or :push
id: "reminder_1",
title: "Time to check in",
body: "Open the app to see today's updates",
data: %{screen: "reminders"} # atom keys at the top level
}presentation tells an arrival from a tap:
:foreground— the notification arrived while the app was in the foreground. The OS still shows its banner. Refresh state, don't navigate.:tap— the user opened it, whether the app was in the foreground, in the background, or killed. Navigate here.
def handle_info({:notification, %{presentation: :tap, data: data}}, socket) do
case data[:screen] do
"reminders" -> {:noreply, Mob.Socket.push_screen(socket, MyApp.RemindersScreen)}
_ -> {:noreply, socket}
end
end
def handle_info({:notification, %{presentation: :foreground}}, socket) do
{:noreply, socket}
endaction is "default" for a tap on the notification itself. An app that
registers its own iOS notification categories also sees "dismiss" and its
own action identifiers there.
Which process receives it. The process that registered through
mob_notify (MobNotify.register_push/1; on iOS also MobNotify.schedule/2)
while it is alive, otherwise the screen currently showing. A tap that
launched the app goes to the root screen once it has mounted, exactly once.
Push notifications
Push notifications are sent from your server to the device. Mob handles the
app-side registration and delivery. You use
mob_push on the server side to send them.
Architecture
Your server ──mob_push──► APNs / FCM ──► Device OS ──► Mob ──► {:notification, notif}- The app registers for push and receives a device token
- Your app forwards the token to your server and stores it
- When you want to notify a user, call
MobPush.send/3from your server - The OS delivers the notification — Mob sends
{:notification, notif}to your screen
Installing mob_push on your server
Add to your server's mix.exs:
{:mob_push, "~> 0.2"}Then run mix mob_push.install for interactive credential setup, or configure
manually in config/runtime.exs:
# iOS (APNs)
config :mob_push, :apns,
key_id: System.get_env("APNS_KEY_ID"),
team_id: System.get_env("APNS_TEAM_ID"),
bundle_id: System.get_env("APNS_BUNDLE_ID", "com.example.myapp"),
key_file: System.get_env("APNS_KEY_FILE", "/path/to/AuthKey_XXXXXXXXXX.p8"),
env: if(config_env() == :prod, do: :production, else: :sandbox)
# Android (FCM)
config :mob_push, :fcm,
project_id: System.get_env("FCM_PROJECT_ID"),
service_account_key: System.get_env("FCM_SERVICE_ACCOUNT_KEY", "/path/to/sa.json")See the mob_push docs for the full credential walkthrough (Apple Developer portal + Firebase console).
App-side setup
Push registration needs a few host-app pieces the build can't fully automate:
the FCM <service> entry in AndroidManifest.xml plus a google-services.json
on Android, and the APNs token-forwarding hook in the AppDelegate on iOS.
The mob_notify plugin declares these as host_requirements, so every
mix mob.deploy --native prints exactly what's missing — follow the printed
snippets if registration silently yields no token.
1. Request permission and register
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
# Register with APNs / FCM — token arrives asynchronously
{:noreply, MobNotify.register_push(socket)}
end
def handle_info({:permission, :notifications, :denied}, socket) do
{:noreply, socket}
end
end2. Receive and store the token
def handle_info({:push_token, platform, token}, socket) do
# Send the token to your server and store it with the user
MyApp.PushTokens.upsert(socket.assigns.user_id, token, platform)
{:noreply, socket}
endplatform is :ios or :android. Each user may have multiple tokens (multiple
devices). Store the platform alongside the token — you need it when calling
MobPush.send/3.
Tokens can change: the OS may issue a new token after an app reinstall or backup
restore. Re-registering on each launch with MobNotify.register_push/1 keeps
your stored token current.
3. Handle received notifications
Pushes arrive as the same Mob.Notification map as local notifications, with
source: :push (see Receiving). data holds your payload's
custom keys; on iOS, APNs' own aps dictionary is left out (its alert is in
title and body).
def handle_info({:notification, %{presentation: :tap, data: data}}, socket) do
case data[:screen] do
"chat" -> {:noreply, Mob.Socket.push_screen(socket, MyApp.ChatScreen)}
"inbox" -> {:noreply, Mob.Socket.push_screen(socket, MyApp.InboxScreen)}
_ -> {:noreply, socket}
end
endDelivery scenarios:
| App state | What happens |
|---|---|
| Foreground | iOS shows its banner and {:notification, %{presentation: :foreground}} arrives; tapping the banner delivers a second message with presentation: :tap. Android: a push reaches mob in the foreground only through an app-owned MobFirebaseService (see the upgrade step below); FCM documents that it shows no tray banner for a push it hands to that service. |
| Background (home button pressed) | OS shows the notification. When tapped, the app foregrounds and {:notification, %{presentation: :tap}} arrives. |
| Killed (fully closed) | OS shows the notification. When tapped, the app launches and {:notification, %{presentation: :tap}} arrives at the root screen once it has mounted. |
Match on presentation when an arrival and a tap need different handling.
Android pushes. Mob delivers what reaches the app through its
NotificationReceiver(local notifications) andMainActivity(taps that carrymob_notification_json).mob_pushputs that key in the FCMdatablock of every visible push so thatMainActivitycan rebuild the notification when the user taps it in the tray; FCM documents copying thedatablock into the launch intent's extras for such a tap (not verified on a device for this guide). A push sent without that key (another sender, the Firebase console) carries no mob payload, so its tap reaches the app without a{:notification, _}.
Upgrading an app with its own MobFirebaseService
Apps generated by mob_new 0.1.45–0.4.10 own a MobFirebaseService whose
onMessageReceived hands a push that arrives in the foreground to
MobBridge.nativeDeliverNotification. Its JSON has no presentation, and a
missing presentation means :tap, so a screen that navigates on taps would
navigate on every push that arrives while the app is open. Mark it as an
arrival before the call:
val json = JSONObject(
message.data["mob_notification_json"] ?: run {
val notif = message.notification ?: return
JSONObject().apply {
put("title", notif.title ?: "")
put("body", notif.body ?: "")
put("source", "push")
put("data", JSONObject())
}.toString()
}
).put("presentation", "foreground").toString()
MobBridge.nativeDeliverNotification(pid, json)Sending from your server
# Basic notification
MobPush.send(token, :ios, %{
title: "New message",
body: "Alice: Hey, are you free tonight?"
})
# With data payload for navigation
MobPush.send(token, :android, %{
title: "New message",
body: "Alice: Hey, are you free tonight?",
data: %{screen: "chat", thread_id: "42"}
})
# iOS — subtitle, badge, sound
MobPush.send(token, :ios, %{
title: "3 new messages",
body: "Alice, Bob and 1 other",
subtitle: "in #general",
badge: 3,
sound: "default"
})
# Android — custom icon, accent color, notification channel
MobPush.send(token, :android, %{
title: "New message",
body: "Alice: Hey!",
data: %{screen: "chat"},
android: %{
"notification" => %{
"icon" => "ic_notification",
"color" => "#FF6200EE",
"channel_id" => "messages"
},
"priority" => "high"
}
})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 failed for #{platform}/#{user_id}: #{inspect(reason)}")
end
end)
endAndroid: notification channels (Android 8+)
Android 8+ requires a notification channel to be created by the app before a
notification can use it. Create channels in your MainActivity.onCreate:
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)
}Then reference the channel ID in your server payload: "channel_id" => "messages".
If the channel doesn't exist on the device, Android silently drops the notification.
iOS: APNs environments
APNs has separate sandbox and production environments. Use :sandbox for Xcode /
TestFlight development builds and :production for App Store / TestFlight production
builds. A token from one environment is not valid in the other — sending to the wrong
environment returns {:error, {:apns_error, "BadDeviceToken"}}.
Silent pushes and OS-triggered handlers
Everything above covers user-visible notifications — the ones with a
title and body that show in the tray. There's a second, silent variant
you'd use for waking the app to run code without showing anything:
sync data, refresh a cache, respond to a peer signal. That's what the
mob_wake plugin is for, and it uses
the same mob_push send-side pipeline with a specific payload shape:
# Device — register the handler once at boot
Mob.Wake.register(:sync_notes, :push, {MyApp.Sync, :run_sync})
# Server — send a silent push that fires that handler
payload = MobWake.wake_payload(:sync_notes, data: %{"peer" => "abc"})
MobPush.send(ios_token, :ios, payload)mob_wake's moduledoc covers the three-device-state matrix (foreground,
backgrounded, force-quit) and lays out the iOS-specific first-time
setup — the App ID Push capability, the provisioning profile
regeneration, and the AppDelegate dispatch handler that mob_new ships
in its template (MOB-271).
Further reading
mob_pushon HexDocs — full server-side documentation: credential setup, all payload options, notification appearance, token lifecycleMobNotify— schedule/cancel local notifications, register for push (ships in themob_notifyplugin)MobWake— OS-triggered background handlers via scheduler firings and silent pushes; complements the user-visible flow aboveMobBackground— keep the app alive continuously while backgrounded (a different concern than push wake)Mob.Permissions— request OS permission