PushX

CIHex.pmHex DocsLicense

Modern push notifications for Elixir. Supports Apple APNS and Google FCM with HTTP/2, JWT authentication, and a clean unified API.

Features

Installation

Add pushx to your dependencies in mix.exs:

def deps do
[
{:pushx, "~> 0.2.0"}
]
end

API Reference

PushX.push/4

Send a push notification to a device.

PushX.push(provider, device_token, message, opts)
ParameterTypeDescription
provider:apns | :fcmTarget platform
device_tokenString.t()Device push token
messageString.t() | map() | PushX.Message.t()Notification content
optsKeyword.t()Provider-specific options

Returns:{:ok, %PushX.Response{}} | {:error, %PushX.Response{}}

APNS Options

OptionTypeDefaultDescription
:topicString.t()requiredApp bundle ID (e.g., "com.example.app")
:mode:prod | :sandboxfrom configAPNS environment
:push_typeString.t()"alert""alert", "background", "voip", "complication", "fileprovider", "mdm"
:priority5 | 1010Delivery priority (5 = throttled, 10 = immediate)
:expirationinteger()nilUnix timestamp when notification expires
:collapse_idString.t()nilGroup notifications (max 64 bytes)

FCM Options

OptionTypeDefaultDescription
:project_idString.t()from configFirebase project ID
:datamap()nilCustom key-value data payload
:androidmap()nilAndroid-specific options
:apnsmap()nilAPNS options (for iOS via FCM)
:webpushmap()nilWeb push options

PushX.push!/4

Same as push/4 but returns :ok | :error (without response details).

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

PushX.Message

Fluent builder for constructing notification payloads.

message = PushX.message()
|> PushX.Message.title("New Message")
|> PushX.Message.body("You have a new notification")
|> PushX.Message.badge(1)
|> PushX.Message.sound("default")
|> PushX.Message.data(%{user_id: "12345"})
FunctionDescription
title(msg, string)Set notification title
body(msg, string)Set notification body
badge(msg, integer)Set app badge count (iOS)
sound(msg, string)Set notification sound
data(msg, map)Set custom data payload
put_data(msg, key, value)Add single data key-value
category(msg, string)Set notification category (iOS)
thread_id(msg, string)Set thread ID for grouping (iOS)
image(msg, url)Set image URL (FCM)
priority(msg, :high | :normal)Set delivery priority
ttl(msg, seconds)Set time-to-live
collapse_key(msg, string)Set collapse key (FCM)

PushX.Response

Response struct returned from push operations.

%PushX.Response{
provider: :apns | :fcm,
status: :sent | :invalid_token | :expired_token | ...,
id: "message-id" | nil,
reason: "error reason" | nil,
raw: raw_response_body
}
StatusDescriptionAction
:sentSuccessfully deliveredNone
:invalid_tokenToken is malformed or invalidRemove token
:expired_tokenToken has expiredRemove token
:unregisteredDevice unregisteredRemove token
:payload_too_largePayload exceeds limitReduce payload size
:rate_limitedToo many requestsRetry with backoff
:server_errorProvider server errorRetry later
:connection_errorNetwork failureRetry later
:unknown_errorUnrecognized errorCheck reason field

Helper functions:

PushX.Response.success?(response) # true if status == :sent
PushX.Response.should_remove_token?(response) # true for invalid/expired/unregistered

Direct Provider Access

For more control, use the provider modules directly:

# APNS
PushX.APNS.send(token, payload, opts)
PushX.APNS.notification(title, body, badge \\ nil)
PushX.APNS.notification_with_data(title, body, data, badge \\ nil)
PushX.APNS.silent_notification(data \\ %{})
# FCM
PushX.FCM.send(token, payload, opts)
PushX.FCM.send_data(token, data, opts) # Data-only message
PushX.FCM.notification(title, body, opts \\ [])

Configuration

APNS (Apple Push Notification Service)

config :pushx,
apns_key_id: "ABC123DEFG",
apns_team_id: "TEAM123456",
apns_private_key: {:file, "priv/keys/AuthKey.p8"},
apns_mode: :prod # or :sandbox
OptionTypeDescription
:apns_key_idString.t()10-character Key ID from Apple
:apns_team_idString.t()10-character Team ID from Apple
:apns_private_keyString.t() | {:file, path}PEM string or file path
:apns_mode:prod | :sandboxAPNS environment (default: :prod)

FCM (Firebase Cloud Messaging)

config :pushx,
fcm_project_id: "my-project-id",
fcm_credentials: {:file, "priv/keys/firebase-service-account.json"}
OptionTypeDescription
:fcm_project_idString.t()Firebase project ID
:fcm_credentialsmap() | {:file, path}Service account JSON or file path

Pool Configuration (Optional)

config :pushx,
finch_pool_size: 10, # connections per pool (default: 10)
finch_pool_count: 1 # number of pools (default: 1)

Credential Storage Options

Option 1: File System (Development)

Store credentials in priv/keys/ (gitignored):

# config/dev.exs
config :pushx,
apns_private_key: {:file, "priv/keys/AuthKey.p8"},
fcm_credentials: {:file, "priv/keys/firebase-service-account.json"}

Add to .gitignore:

/priv/keys/

Option 2: Environment Variables (Production)

Store credentials as environment variables:

# config/runtime.exs
config :pushx,
apns_key_id: System.get_env("APNS_KEY_ID"),
apns_team_id: System.get_env("APNS_TEAM_ID"),
apns_private_key: System.get_env("APNS_PRIVATE_KEY"),
apns_mode: if(System.get_env("APNS_SANDBOX") == "true", do: :sandbox, else: :prod),
fcm_project_id: System.get_env("FCM_PROJECT_ID"),
fcm_credentials: System.get_env("FCM_CREDENTIALS") |> Jason.decode!()

Tip: For multiline keys (APNS .p8), encode as base64 or replace newlines with \n.

Option 3: Fly.io Secrets

# Set APNS credentials
fly secrets set APNS_KEY_ID="ABC123DEFG"
fly secrets set APNS_TEAM_ID="TEAM123456"
fly secrets set APNS_PRIVATE_KEY="$(cat AuthKey.p8)"
# Set FCM credentials (JSON as string)
fly secrets set FCM_PROJECT_ID="my-project-id"
fly secrets set FCM_CREDENTIALS="$(cat firebase-service-account.json)"

Then in config/runtime.exs:

if config_env() == :prod do
config :pushx,
apns_key_id: System.fetch_env!("APNS_KEY_ID"),
apns_team_id: System.fetch_env!("APNS_TEAM_ID"),
apns_private_key: System.fetch_env!("APNS_PRIVATE_KEY"),
apns_mode: :prod,
fcm_project_id: System.fetch_env!("FCM_PROJECT_ID"),
fcm_credentials: System.fetch_env!("FCM_CREDENTIALS") |> JSON.decode!()
end

Option 4: AWS Secrets Manager / Vault

Fetch secrets at runtime in config/runtime.exs:

if config_env() == :prod do
# Example with ExAws
{:ok, %{"SecretString" => apns_key}} =
ExAws.SecretsManager.get_secret_value("pushx/apns-key")
|> ExAws.request()
config :pushx,
apns_private_key: apns_key
end

Getting Your Credentials

Apple APNS Setup

You'll need: Key ID, Team ID, and a Private Key (.p8 file).

Step 1: Get Your Team ID

  1. Go to Apple Developer Account
  2. Your Team ID is shown in the top-right corner (10 characters)

Step 2: Create an APNS Key

  1. Go to Certificates, Identifiers & Profiles
  2. Click Keys+ (Create a new key)
  3. Enter a name (e.g., "Push Notifications Key")
  4. Check Apple Push Notifications service (APNs)
  5. Click ContinueRegister
  6. Download the .p8 file (you can only download it once!)
  7. Note the Key ID shown (10 characters)

Google FCM Setup

You'll need: Project ID and a Service Account JSON file.

Step 1: Create/Open Firebase Project

  1. Go to Firebase Console
  2. Create a new project or select an existing one
  3. Note your Project ID in Project Settings

Step 2: Enable Cloud Messaging API

  1. Go to Google Cloud Console
  2. Select your Firebase project
  3. Go to APIs & ServicesLibrary
  4. Search for "Firebase Cloud Messaging API" and Enable it

Step 3: Create Service Account Key

  1. In Firebase Console, go to Project Settings (gear icon)
  2. Click Service accounts tab
  3. Click Generate new private key
  4. Save the JSON file securely

Usage Examples

Basic Usage

# Send to iOS
PushX.push(:apns, device_token, "Hello!", topic: "com.example.app")
# Send to Android
PushX.push(:fcm, device_token, "Hello!")
# With title and body
PushX.push(:apns, token, %{title: "Welcome", body: "Thanks for signing up!"},
topic: "com.example.app")

Using Message Builder

message = PushX.message()
|> PushX.Message.title("Order Update")
|> PushX.Message.body("Your order #1234 has shipped")
|> PushX.Message.badge(1)
|> PushX.Message.sound("default")
|> PushX.Message.data(%{order_id: "1234", status: "shipped"})
PushX.push(:apns, token, message, topic: "com.example.app")

Silent/Background Notification

payload = PushX.APNS.silent_notification(%{action: "sync", resource: "messages"})
PushX.APNS.send(token, payload,
topic: "com.example.app",
push_type: "background",
priority: 5
)

Response Handling

case PushX.push(:apns, token, message, topic: "com.example.app") do
{:ok, %PushX.Response{status: :sent, id: apns_id}} ->
Logger.info("Notification sent with ID: #{apns_id}")
{:error, %PushX.Response{status: :invalid_token}} ->
# Remove invalid token from database
MyApp.Tokens.delete(token)
{:error, %PushX.Response{status: :rate_limited}} ->
# Retry later with exponential backoff
Logger.warning("Rate limited, will retry")
{:error, %PushX.Response{status: status, reason: reason}} ->
Logger.error("Push failed: #{status} - #{reason}")
end

Batch Pattern

# Send to multiple tokens (current approach)
tokens
|> Task.async_stream(fn token ->
PushX.push(:apns, token, message, topic: "com.example.app")
end, max_concurrency: 50)
|> Enum.to_list()

Requirements

Tested on Elixir 1.18/1.19 with OTP 26, 27, and 28.

Roadmap

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

MIT License. See LICENSE for details.


Built with ❤️ by Cigno Systems AB