PushX (PushX v0.11.0)

Copy Markdown View Source

Modern push notifications for Elixir.

PushX provides a simple, unified API for sending push notifications to iOS (APNS) and Android (FCM) devices using HTTP/2 connections.

Features

  • HTTP/2 connections via Finch (Mint-based)
  • JWT authentication for APNS with automatic caching
  • OAuth2 authentication for FCM via Goth
  • Unified API with direct provider access
  • Structured response handling
  • Batch sending with configurable concurrency
  • Token validation
  • Client-side rate limiting

Quick Start

# Send to iOS
PushX.push(:apns, device_token, "Hello World", topic: "com.example.app")

# Send to Android
PushX.push(:fcm, device_token, "Hello World")

# With title and body
PushX.push(:apns, token, %{title: "New Message", body: "You have a notification"}, topic: "...")

# Notification with custom data (FCM)
PushX.push(:fcm, token, %{
  "notification" => %{"title" => "Alert", "body" => "Event triggered"},
  "data" => %{"event_id" => "123"}
})

# Data-only (silent) message (FCM)
PushX.push_data(:fcm, token, %{action: "sync", id: 123})

# Batch send to multiple devices
results = PushX.push_batch(:fcm, tokens, "Hello Everyone!")

Configuration

config :pushx,
  # APNS (Apple)
  apns_key_id: "ABC123DEFG",
  apns_team_id: "TEAM123456",
  apns_private_key: {:file, "priv/keys/AuthKey.p8"},
  apns_mode: :prod,

  # FCM (Firebase)
  fcm_project_id: "my-project-id",
  fcm_credentials: {:file, "priv/keys/firebase.json"},

  # Batch sending
  batch_concurrency: 50,

  # Rate limiting (optional)
  rate_limit_enabled: false,
  rate_limit_apns: 5000,
  rate_limit_fcm: 5000

Direct Provider Access

For more control, use the provider modules directly:

# APNS
PushX.APNS.send(token, payload, topic: "com.app.bundle", mode: :sandbox)

# FCM
PushX.FCM.send(token, payload, data: %{"key" => "value"})

Summary

Functions

Checks if a request can be made within rate limits.

Returns health status for each configured provider.

Creates a new message using the builder pattern.

Creates a new message with title and body.

Sends a push notification to a device.

Sends a push notification and returns only :ok or :error.

Sends a push notification to multiple devices concurrently.

Sends a push notification to multiple devices and returns success count.

Sends a data-only (silent) push notification to a device.

Restarts the Finch HTTP pool, forcing fresh connections.

Returns true if the token format is valid.

Validates a device token format.

Types

instance_name()

@type instance_name() :: atom()

message()

@type message() :: String.t() | map() | PushX.Message.t()

option()

@type option() :: PushX.APNS.option() | PushX.FCM.option()

provider()

@type provider() :: :apns | :fcm

token()

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

Functions

check_rate_limit(provider)

@spec check_rate_limit(provider()) :: :ok | {:error, :rate_limited}

Checks if a request can be made within rate limits.

Delegates to PushX.RateLimiter.check/1. Only applies when rate limiting is enabled in config.

health_check()

@spec health_check() :: %{apns: map(), fcm: map()}

Returns health status for each configured provider.

Includes configuration status and circuit breaker state.

Examples

PushX.health_check()
#=> %{
#=>   apns: %{configured: true, circuit: :closed},
#=>   fcm: %{configured: true, circuit: :closed}
#=> }

message()

@spec message() :: PushX.Message.t()

Creates a new message using the builder pattern.

Alias for PushX.Message.new/0.

Examples

message = PushX.message()
  |> PushX.Message.title("Hello")
  |> PushX.Message.body("World")

message(title, body)

@spec message(String.t(), String.t()) :: PushX.Message.t()

Creates a new message with title and body.

Alias for PushX.Message.new/2.

Examples

message = PushX.message("Hello", "World")

push(provider, device_token, message, opts \\ [])

@spec push(provider() | instance_name(), token(), message(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a push notification to a device.

Arguments

  • provider - :apns for iOS or :fcm for Android
  • device_token - The device's push token
  • message - A string, map, or PushX.Message struct
  • opts - Provider-specific options

Options

APNS Options

  • :topic - Bundle ID (required for APNS)
  • :mode - :prod or :sandbox (default: from config)
  • :push_type - "alert", "background", "voip" (default: "alert")
  • :priority - 5 or 10 (default: 10)

FCM Options

  • :project_id - Firebase project ID (default: from config)
  • :data - Custom data payload map

Examples

# Simple string message
PushX.push(:apns, token, "Hello!", topic: "com.example.app")

# Map with title and body
PushX.push(:fcm, token, %{title: "Alert", body: "Something happened"})

# Using Message struct
message = PushX.Message.new()
  |> PushX.Message.title("Order Update")
  |> PushX.Message.body("Your order has been shipped!")
  |> PushX.Message.badge(1)

PushX.push(:apns, token, message, topic: "com.example.app")

Returns

{:ok, %PushX.Response{provider: :apns, status: :sent, id: "..."}}
{:error, %PushX.Response{provider: :apns, status: :invalid_token, reason: "BadDeviceToken"}}

push!(provider, device_token, message, opts \\ [])

@spec push!(provider(), token(), message(), [option()]) :: :ok | :error

Sends a push notification and returns only :ok or :error.

Useful when you don't need the full response details.

Examples

case PushX.push!(:apns, token, "Hello", topic: "com.app") do
  :ok -> Logger.info("Sent!")
  :error -> Logger.warning("Failed")
end

push_batch(provider, device_tokens, message, opts \\ [])

@spec push_batch(provider() | instance_name(), [token()], message(), [option()]) :: [
  {token(), {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}}
]

Sends a push notification to multiple devices concurrently.

Uses Task.async_stream for parallel sending with configurable concurrency. Each result contains the token and the response.

Arguments

  • provider - :apns for iOS or :fcm for Android
  • device_tokens - List of device tokens
  • message - A string, map, or PushX.Message struct
  • opts - Provider-specific options plus:
    • :concurrency - Max concurrent requests (default: 50)
    • :timeout - Timeout per request in ms (default: 30_000)
    • :validate_tokens - Validate tokens before sending (default: false). When true, invalid tokens get {:error, %Response{status: :invalid_token}} without ever leaving the local process — the result list always matches the input length.

Examples

# Send to multiple iOS devices
results = PushX.push_batch(:apns, tokens, "Hello!", topic: "com.example.app")

# Process results
Enum.each(results, fn
  {token, {:ok, response}} ->
    Logger.info("Sent to #{token}: #{response.id}")

  {token, {:error, response}} ->
    if PushX.Response.should_remove_token?(response) do
      MyApp.Tokens.delete(token)
    end
end)

# With higher concurrency
PushX.push_batch(:fcm, tokens, "Alert!", concurrency: 100)

Returns

A list of {token, result} tuples where result is {:ok, Response.t()} or {:error, Response.t()}.

push_batch!(provider, device_tokens, message, opts \\ [])

@spec push_batch!(provider() | instance_name(), [token()], message(), [option()]) ::
  %{
    success: non_neg_integer(),
    failure: non_neg_integer(),
    total: non_neg_integer()
  }

Sends a push notification to multiple devices and returns success count.

Simplified version of push_batch/4 that returns aggregate results.

Returns

A map with :success, :failure, and :total counts.

Examples

%{success: 95, failure: 5, total: 100} =
  PushX.push_batch!(:fcm, tokens, "Hello!")

push_data(provider_or_instance, device_token, data, opts \\ [])

@spec push_data(provider() | instance_name(), token(), map(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a data-only (silent) push notification to a device.

The message contains only a data payload with no visible notification. Useful for triggering background syncs or delivering structured data.

Arguments

  • provider - :fcm or a named instance atom
  • device_token - The device's push token
  • data - A map of key-value data (values will be stringified)
  • opts - Provider-specific options

Examples

# Via default FCM config
PushX.push_data(:fcm, token, %{action: "sync", id: 123})

# Via named instance
PushX.push_data(:my_fcm, token, %{action: "sync", id: 123})

reconnect()

@spec reconnect() :: :ok | {:error, term()}

Restarts the Finch HTTP pool, forcing fresh connections.

Call this when connections become stale (e.g., after persistent too_many_concurrent_requests or request_timeout errors). On cloud infrastructure like Fly.io, idle HTTP/2 connections can be silently dropped, and Finch cannot detect these zombie connections. Restarting the pool forces new TCP/TLS handshakes.

This is called automatically by the retry logic on connection errors. You can also call it manually if needed.

Examples

PushX.reconnect()
#=> :ok

valid_token?(provider, token)

@spec valid_token?(provider(), token()) :: boolean()

Returns true if the token format is valid.

Delegates to PushX.Token.valid?/2.

validate_token(provider, token)

@spec validate_token(provider(), token()) ::
  :ok | {:error, PushX.Token.validation_error()}

Validates a device token format.

Delegates to PushX.Token.validate/2.

Examples

:ok = PushX.validate_token(:apns, valid_token)
{:error, :invalid_length} = PushX.validate_token(:apns, "too-short")