PushX (PushX v0.15.0)

Copy Markdown View Source

Push notifications for Elixir: APNS, FCM and Web Push in one call.

One API sends to iOS/macOS (Apple APNS, HTTP/2 + JWT), Android (Google FCM HTTP v1, HTTP/2 + OAuth2) and every browser via standards-based Web Push:

  • RFC 8030 — Generic Event Delivery Using HTTP Push (the transport: TTL, Urgency, Topic, 201/404/410 semantics)
  • RFC 8291 — Message Encryption for Web Push (aes128gcm, RFC 8188 content coding; PushX reproduces the RFC's test vectors exactly)
  • RFC 8292 — VAPID (Voluntary Application Server Identification; ES256 JWTs signed with your application-server key)

See PushX.WebPush for the compliance notes (what is implemented, what is optional and omitted).

Features

  • HTTP/2 connections via Finch (Mint-based)
  • JWT authentication for APNS with automatic caching
  • OAuth2 authentication for FCM via Goth
  • VAPID + RFC 8291 encryption for Web Push, keys generated with mix pushx.vapid
  • Unified API with direct provider access
  • Structured response handling
  • Batch sending with configurable concurrency
  • Token validation
  • Client-side rate limiting
  • Test delivery mode (PushX.Test)

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

Types

A device token; for FCM also {:topic, name} / {:condition, expr} (see PushX.FCM.target/0); for Web Push the browser's subscription map (see PushX.WebPush.subscription/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.

Subscribes device tokens to an FCM topic, through the static :fcm configuration or a named FCM instance.

Unsubscribes device tokens from an FCM topic. See subscribe/4.

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 | :webpush

target()

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

A device token; for FCM also {:topic, name} / {:condition, expr} (see PushX.FCM.target/0); for Web Push the browser's subscription map (see PushX.WebPush.subscription/0).

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(),
  webpush: map(),
  instances: %{required(atom()) => map()}
}

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},
#=>   webpush: %{configured: false, circuit: :closed},
#=>   instances: %{
#=>     tenant_42_apns: %{provider: :apns, enabled: true, circuit: :closed},
#=>     tenant_7_fcm: %{provider: :fcm, enabled: false, circuit: :open}
#=>   }
#=> }

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(), target(), message(), [option()]) ::
  {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Sends a push notification to a device.

Arguments

  • provider - :apns for iOS, :fcm for Android, :webpush for browsers
  • device_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"}) — see PushX.FCM.target/0. For Web Push it is the browser's subscription map — see PushX.WebPush.subscription/0.
  • 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; 5 for push_type: "background")
  • :expiration - Unix timestamp after which APNS drops the notification (0 = deliver now or never); a PushX.Message ttl sets this for you
  • :collapse_id - notifications sharing an id replace each other
  • :apns_id - your own UUID for the notification, echoed back by Apple (see PushX.APNS.send/3)

FCM Options

  • :project_id - Firebase project ID (default: from config)
  • :data - Custom data payload map (values are stringified)
  • :android, :apns, :webpush - raw platform override blocks, deep-merged over what a PushX.Message derives (see PushX.FCM.send/3)
  • :validate_only - dry run: FCM validates without delivering (see PushX.FCM.send/3)

Web Push Options

  • :ttl, :urgency, :topic - see PushX.WebPush (how long the push service holds the message, power hint, collapse key)

Common options

  • :receive_timeout, :pool_timeout - per-call overrides of the config timeouts (static paths)
  • :retry - :blocking (default) retries retryable failures in the calling process with backoff; :none makes exactly one attempt and returns retryable failures as-is (with retry_after when the provider supplied it) so you can requeue on your own schedule — a connection error still triggers the automatic pool reconnect. true/false are 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"})

# Web Push: the browser's subscription object is the target
PushX.push(:webpush, subscription, %{title: "Hi", body: "From the server"})

# 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.

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

@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

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

@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 - :apns for iOS or :fcm for Android
  • device_tokens - Enumerable of device tokens (for FCM, topic/condition targets are accepted too — see target/0)
  • 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. Defaults to PushX.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). When true, 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.

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_batch_stream(provider, device_tokens, message, opts \\ [])

(since 0.13.0)
@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)

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

@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 - :fcm, :webpush, or a named instance atom
  • device_token - The device's push token (for Web Push: the subscription map)
  • data - A map of key-value data (values are stringified for FCM; sent as JSON as-is for Web Push, where the payload is whatever your service worker reads)
  • 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

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

(since 0.14.0)
@spec subscribe(:fcm | instance_name(), [token()], String.t(), [option()]) ::
  {:ok, [PushX.FCM.topic_result()]} | {:error, PushX.Response.t()}

Subscribes device tokens to an FCM topic, through the static :fcm configuration or a named FCM instance.

Delegates to PushX.FCM.subscribe/3 (see there for results, chunking and options). Named APNS instances return {:error, %Response{status: :invalid_request}}.

Examples

{:ok, results} = PushX.subscribe(:fcm, tokens, "news")
{:ok, results} = PushX.subscribe(:tenant_fcm, tokens, "news")

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

(since 0.14.0)
@spec unsubscribe(:fcm | instance_name(), [token()], String.t(), [option()]) ::
  {:ok, [PushX.FCM.topic_result()]} | {:error, PushX.Response.t()}

Unsubscribes device tokens from an FCM topic. See subscribe/4.

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