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

LocalPush
Scheduled byThe device itselfYour server
Requires internetNoYes
Requires serverNoYes (+ credentials)
Works when killedYes (OS delivers on schedule)Yes (OS wakes on arrival)
Requires permissionYes (:notifications)Yes (:notifications)

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}
end

Scheduling

# At a specific time
Mob.Notify.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
Mob.Notify.schedule(socket,
  id:           "cooldown",
  title:        "Cooldown complete",
  body:         "Ready to go again",
  delay_seconds: 3600
)

Cancelling

Mob.Notify.cancel(socket, "reminder_1")

Receiving

All app states deliver the same message:

def handle_info({:notification, %{id: id, data: data, source: :local}}, socket) do
  case data["screen"] do
    "reminders" -> {:noreply, Mob.Socket.push_screen(socket, MyApp.RemindersScreen)}
    _           -> {:noreply, socket}
  end
end

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}
  1. The app registers for push and receives a device token
  2. Your app forwards the token to your server and stores it
  3. When you want to notify a user, call MobPush.send/3 from your server
  4. 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

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, Mob.Notify.register_push(socket)}
  end

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

2. 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}
end

platform 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 Mob.Notify.register_push/1 keeps your stored token current.

3. Handle received notifications

All three delivery scenarios deliver the same message to your screen:

def handle_info({:notification, notif}, socket) do
  # notif has string keys: "title", "body", "data", "source"
  # notif["source"] is "push" for remote or "local" for scheduled
  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

Delivery scenarios:

App stateWhat happens
ForegroundOS does not show a system notification. {:notification, notif} arrives directly.
Background (home button pressed)OS shows the notification in the tray. When tapped, the app foregrounds and {:notification, notif} arrives.
Killed (fully closed)OS shows the notification. When tapped, the app launches and {:notification, notif} arrives once BEAM has booted.

You don't need separate code paths — the same handle_info clause handles all three.

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)
end

Android: 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"}}.


Further reading

  • mob_push on HexDocs — full server-side documentation: credential setup, all payload options, notification appearance, token lifecycle
  • Mob.Notify — schedule/cancel local notifications, register for push
  • Mob.Permissions — request OS permission