PushX.FCM (PushX v0.15.0)

Copy Markdown View Source

Firebase Cloud Messaging (FCM) client.

Sends push notifications to Android devices and web browsers using the FCM v1 API with OAuth2 authentication via Goth.

Configuration

Add to your config:

config :pushx,
  fcm_project_id: "my-project-id",
  fcm_credentials: {:file, "priv/keys/firebase-service-account.json"}

Usage

# Simple notification
PushX.FCM.send(device_token, %{
  "notification" => %{
    "title" => "Hello",
    "body" => "World"
  }
})

# Using Message struct
message = PushX.Message.new("Hello", "World")
PushX.FCM.send(device_token, message)

# Notification with custom data
PushX.FCM.send(device_token, %{
  "notification" => %{"title" => "Alert", "body" => "Something happened"},
  "data" => %{"event_id" => "1"}
})

# Data-only (silent) message — no visible notification
PushX.FCM.send_data(device_token, %{action: "sync", id: 123})

# Data-only via structured payload
PushX.FCM.send(device_token, %{"data" => %{"action" => "sync"}})

Topics and Conditions

Any send accepts a topic or condition where the token goes (see target/0), and subscribe/3 / unsubscribe/3 manage memberships:

PushX.FCM.subscribe(tokens, "news")
PushX.FCM.send({:topic, "news"}, message)
PushX.FCM.send({:condition, "'news' in topics && 'sports' in topics"}, message)

Dry Runs

# FCM validates token and payload without delivering anything
PushX.FCM.send(device_token, message, validate_only: true)

Web Push (Chrome, Firefox, Edge)

FCM supports web push using the same API. Web tokens come from the browser's Firebase Messaging SDK (firebase.messaging().getToken()).

# Web push with click action
PushX.FCM.send(web_token, payload,
  webpush: %{
    "fcm_options" => %{"link" => "https://example.com/page"}
  }
)

# Using web notification helper
payload = PushX.FCM.web_notification("Title", "Body", "https://example.com")
PushX.FCM.send(web_token, payload)

Summary

Types

Where an FCM message goes: a device registration token, a topic ({:topic, "news"} — the name only, without the /topics/ prefix), or a condition ({:condition, "'news' in topics && 'sports' in topics"}). Topics and conditions fan out server-side, so PushX.Response carries no per-device information for them and should_remove_token?/1 is never true.

Per-token outcome of subscribe/3 / unsubscribe/3.

Functions

Creates a simple notification payload.

Sends a push notification to an Android device with automatic retry.

Sends notifications to multiple devices concurrently.

Sends a data-only message (no visible notification) with automatic retry.

Sends a data-only message without retry.

Sends a push notification without retry.

Sends a web push notification with automatic retry.

Subscribes device tokens to an FCM topic ({:topic, name} targets in send/3 then reach them).

Unsubscribes device tokens from an FCM topic (iid/v1:batchRemove). See subscribe/3.

Creates a web push notification payload with click action.

Types

option()

@type option() ::
  {:project_id, String.t()}
  | {:data, map()}
  | {:android, map()}
  | {:apns, map()}
  | {:webpush, map()}
  | {:validate_only, boolean()}
  | {:retry, :blocking | :none}

payload()

@type payload() :: map() | PushX.Message.t()

target()

@type target() :: token() | {:topic, String.t()} | {:condition, String.t()}

Where an FCM message goes: a device registration token, a topic ({:topic, "news"} — the name only, without the /topics/ prefix), or a condition ({:condition, "'news' in topics && 'sports' in topics"}). Topics and conditions fan out server-side, so PushX.Response carries no per-device information for them and should_remove_token?/1 is never true.

token()

@type token() :: String.t()

topic_result()

@type topic_result() :: {token(), :ok | {:error, String.t()}}

Per-token outcome of subscribe/3 / unsubscribe/3.

Functions

notification(title, body, opts \\ [])

@spec notification(String.t(), String.t(), keyword()) :: map()

Creates a simple notification payload.

Examples

iex> PushX.FCM.notification("Hello", "World")
%{"title" => "Hello", "body" => "World"}

send(device_token, payload, opts \\ [])

@spec send(target(), payload(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a push notification to an Android device with automatic retry.

Uses exponential backoff for transient failures following Google's best practices. Permanent failures (bad token, invalid argument) are not retried.

Options

  • :project_id - Firebase project ID (default: from config)
  • :data - Custom data payload map
  • :android - Android-specific configuration
  • :apns - APNS configuration (for iOS via FCM)
  • :webpush - Web push configuration
  • :validate_only - true asks FCM to validate the message (token registration, payload) without delivering it — a dry run. A successful {:ok, %Response{status: :sent}} then means "FCM would have accepted this"; errors (:unregistered, :invalid_request, ...) are the real ones. Useful for verifying a stored token before a campaign.
  • :retry - :blocking (default) or :none (single attempt); see PushX.push/4

Returns

  • {:ok, %PushX.Response{}} on success
  • {:error, %PushX.Response{}} on failure

send_batch(device_tokens, payload, opts \\ [])

@spec send_batch(Enumerable.t(), payload(), [option()]) :: [
  {target(), {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}}
]

Sends notifications to multiple devices concurrently.

Options

All standard options plus:

  • :concurrency - Max concurrent requests (default: 50)
  • :timeout - Timeout per request in ms (default: PushX.Config.batch_timeout_ms/0, sized to the worst-case retry backoff)
  • :validate_tokens - Validate token format before sending (default: false). Invalid tokens get {:error, %Response{status: :invalid_token}} without hitting the network.

Each task runs the full blocking retry cycle; if you pass an explicit :timeout, make sure it exceeds the worst-case retry backoff or disable retries — see PushX.push_batch/4 for details.

Returns

A list of {token, result} tuples.

send_data(device_token, data, opts \\ [])

@spec send_data(target(), map(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a data-only message (no visible notification) with automatic retry.

send_data_once(device_token, data, opts \\ [])

@spec send_data_once(target(), map(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a data-only message without retry.

send_once(device_token, payload, opts \\ [])

@spec send_once(target(), payload(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a push notification without retry.

Use this when you want to handle retries yourself or for testing.

send_web(device_token, title, body, link, opts \\ [])

@spec send_web(token(), String.t(), String.t(), String.t(), keyword()) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a web push notification with automatic retry.

Convenience function that combines web_notification/4 with send/3.

Examples

PushX.FCM.send_web(web_token, "Hello", "World", "https://example.com")

# With options
PushX.FCM.send_web(web_token, "Alert", "Check this out",
  "https://example.com/page",
  icon: "https://example.com/icon.png"
)

subscribe(tokens, topic, opts \\ [])

@spec subscribe([token()], String.t(), keyword()) ::
  {:ok, [topic_result()]} | {:error, PushX.Response.t()}

Subscribes device tokens to an FCM topic ({:topic, name} targets in send/3 then reach them).

Uses the Instance ID API (iid/v1:batchAdd) with the same OAuth token as sends. Google accepts at most 1 000 tokens per request; larger lists are chunked and sent sequentially. Returns one outcome per token, in input order — :ok, or {:error, code} with Google's per-token error string ("NOT_FOUND" for an unregistered token, "INVALID_ARGUMENT", "TOO_MANY_TOPICS", ...). A request-level failure (OAuth, network, non-2xx) is returned as {:error, %PushX.Response{}} for the whole batch.

Options: :project_id is not needed (topics are per project of the OAuth credentials); :retry and :receive_timeout/:pool_timeout apply as for send/3. In delivery: :test mode nothing is contacted and every token is reported :ok.

Examples

{:ok, results} = PushX.FCM.subscribe(tokens, "news")
for {token, {:error, "NOT_FOUND"}} <- results, do: MyApp.Tokens.delete(token)

unsubscribe(tokens, topic, opts \\ [])

@spec unsubscribe([token()], String.t(), keyword()) ::
  {:ok, [topic_result()]} | {:error, PushX.Response.t()}

Unsubscribes device tokens from an FCM topic (iid/v1:batchRemove). See subscribe/3.

web_notification(title, body, link, opts \\ [])

@spec web_notification(String.t(), String.t(), String.t(), keyword()) :: map()

Creates a web push notification payload with click action.

This helper creates a notification optimized for web browsers (Chrome, Firefox, Edge). The link option specifies the URL to open when the notification is clicked.

Arguments

  • title - Notification title
  • body - Notification body
  • link - URL to open when clicked
  • opts - Optional keyword list:
    • :icon - Icon URL for the notification
    • :image - Large image URL
    • :badge - Badge icon URL (small monochrome icon)
    • :tag - Tag for notification grouping
    • :renotify - Whether to alert again for same tag (default: false)
    • :require_interaction - Keep notification until user interacts (default: false)

Examples

# Simple web notification
PushX.FCM.web_notification("New Message", "You have a new message", "https://example.com/messages")

# With icon and badge
PushX.FCM.web_notification("Sale!", "50% off today",
  "https://shop.com",
  icon: "https://shop.com/icon.png",
  badge: "https://shop.com/badge.png"
)