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: 5000Direct 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
Types
A device token, or for FCM also {:topic, name} / {:condition, expr} — see PushX.FCM.target/0.
Functions
Checks if a request can be made within rate limits.
Returns health status for the configured providers and every named instance.
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.
Lazy version of push_batch/4: returns a stream of {token, result} pairs
instead of a list.
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
@type instance_name() :: atom()
@type message() :: String.t() | map() | PushX.Message.t()
@type option() :: PushX.APNS.option() | PushX.FCM.option()
@type provider() :: :apns | :fcm
A device token, or for FCM also {:topic, name} / {:condition, expr} — see PushX.FCM.target/0.
@type token() :: String.t()
Functions
@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.
Returns health status for the configured providers and every named instance.
For the static :apns/:fcm configuration: whether credentials are
configured and the circuit breaker state. For each PushX.Instance
(keyed by name): its provider, whether it is enabled, and its own breaker
state — instance breakers are independent of the static ones and of each
other, so one tenant's outage is visible without hiding the rest.
Examples
PushX.health_check()
#=> %{
#=> apns: %{configured: true, circuit: :closed},
#=> fcm: %{configured: true, circuit: :closed},
#=> instances: %{
#=> tenant_42_apns: %{provider: :apns, enabled: true, circuit: :closed},
#=> tenant_7_fcm: %{provider: :fcm, enabled: false, circuit: :open}
#=> }
#=> }
@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")
@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")
@spec push(provider() | instance_name(), target(), message(), [option()]) :: {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}
Sends a push notification to a device.
Arguments
provider-:apnsfor iOS or:fcmfor Androiddevice_token- The device's push token. For FCM this may also be a topic ({:topic, "news"}) or a condition ({:condition, "'news' in topics && 'sports' in topics"}) — seePushX.FCM.target/0.message- A string, map, orPushX.Messagestructopts- Provider-specific options
Options
APNS Options
:topic- Bundle ID (required for APNS):mode-:prodor: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
Common options
:retry-:blocking(default) retries retryable failures in the calling process with backoff;:nonemakes exactly one attempt and returns retryable failures as-is (withretry_afterwhen the provider supplied it) so you can requeue on your own schedule — a connection error still triggers the automatic pool reconnect.true/falseare accepted as aliases. See "Blocking and retries" below.
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"})
# FCM topic / condition instead of a device token
PushX.push(:fcm, {:topic, "news"}, "Breaking news")
PushX.push(:fcm, {:condition, "'news' in topics && 'sports' in topics"}, "Match report")
# 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"}}Blocking and retries
Retries run in the calling process with Process.sleep backoff. With
the default config (3 attempts, 10s base delay) a single call can block
for ~30 seconds on repeated server errors, or ~60 seconds on a
rate-limited response. Don't call this synchronously from a
latency-sensitive process (e.g. a Phoenix request) unless you pass
retry: :none, disable retries globally (retry_enabled: false), or wrap
the call in your own task. Inside push_batch/4 a retrying task holds one
of the batch's concurrency slots for the whole backoff, so for large
audiences prefer retry: :none and requeue failures yourself.
@spec push!(provider() | instance_name(), target(), 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
@spec push_batch(provider() | instance_name(), Enumerable.t(), message(), [option()]) :: [ {target(), {: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-:apnsfor iOS or:fcmfor Androiddevice_tokens- Enumerable of device tokens (for FCM, topic/condition targets are accepted too — seetarget/0)message- A string, map, orPushX.Messagestructopts- Provider-specific options plus::concurrency- Max concurrent requests (default: 50):timeout- Timeout per request in ms. Defaults toPushX.Config.batch_timeout_ms/0, which covers the worst-case blocking-retry budget (3 minutes with default retry config; 30 seconds when retries are disabled):validate_tokens- Validate tokens before sending (default: false). Whentrue, invalid tokens get{:error, %Response{status: :invalid_token}}without ever leaving the local process — the result list always matches the input length.
Timeout vs. retries
Each batch task runs the full retry cycle (blocking backoff — see
push/4), and a task that exceeds :timeout is killed, reported as
{:error, %Response{status: :connection_error, reason: "timeout"}}. The
default timeout is computed from the retry config so a retrying task is
not cut short mid-backoff; if you pass an explicit :timeout, keep it
above retry_max_attempts × retry_max_delay_ms or disable retries for
batches (retry_enabled: false). Note that a killed task may have an
HTTP request already in flight that the provider still delivers — see
the "Delivery semantics" section in the README.
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()}.
Large audiences
The whole result list is held in memory, so for very large audiences (tens
of thousands of tokens and up) either chunk the input (Enum.chunk_every/2,
~10k per call) or use push_batch_stream/4, which yields results lazily
with bounded memory. Also consider retry: :none (see push/4) so a
provider blip doesn't park batch tasks in backoff.
@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!")
@spec push_batch_stream(provider() | instance_name(), Enumerable.t(), message(), [ option() ]) :: Enumerable.t()
Lazy version of push_batch/4: returns a stream of {token, result} pairs
instead of a list.
Same options and semantics as push_batch/4 — bounded concurrency,
per-task timeout, optional local token validation, one result per input,
in input order — but nothing runs until the stream is enumerated, and each
result is yielded as soon as it (and everything before it) has completed
rather than collected into a list. Use it for large audiences so memory
stays bounded and you can act on results incrementally (or stop early).
device_tokens can be any enumerable and is enumerated exactly once, so a
one-shot source such as a Repo.stream/2 works.
The stream must be consumed in the process that will own the sends; the
batch tasks are started under PushX.TaskSupervisor when enumeration
begins.
Examples
MyApp.Repo.transaction(fn ->
MyApp.Repo.stream(from t in Token, where: t.provider == :fcm, select: t.value)
|> then(&PushX.push_batch_stream(:fcm, &1, "Server maintenance in 10m", concurrency: 100))
|> Stream.each(fn
{token, {:error, resp}} ->
if PushX.Response.should_remove_token?(resp), do: MyApp.Tokens.delete(token)
_ ->
:ok
end)
|> Stream.run()
end)
@spec push_data(provider() | instance_name(), target(), 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-:fcmor a named instance atomdevice_token- The device's push tokendata- 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})
@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
Returns true if the token format is valid.
Delegates to PushX.Token.valid?/2.
@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")