Runtime management of named push notification instances.
Allows starting, stopping, and reconfiguring APNS and FCM instances at runtime, enabling multi-provider setups from a database-backed admin panel.
Usage
# Start an APNS instance
PushX.Instance.start(:apns_prod, :apns,
key_id: "ABC123",
team_id: "TEAM456",
private_key: "-----BEGIN EC PRIVATE KEY-----\n...",
mode: :prod
)
# Start an FCM instance
PushX.Instance.start(:my_fcm, :fcm,
project_id: "my-project",
credentials: %{"type" => "service_account", ...}
)
# Send via instance
PushX.push(:apns_prod, token, msg, topic: "com.example.app")
# Lifecycle management
PushX.Instance.disable(:apns_prod)
PushX.Instance.enable(:apns_prod)
PushX.Instance.reconfigure(:apns_prod, mode: :sandbox)
PushX.Instance.stop(:apns_prod)Credential Rotation Without Restart
Use reconfigure/2 to hot-swap credentials (e.g., after revoking an APNS
.p8 key or rotating an FCM service account). It stops the old pool and
starts a fresh one with new credentials. In-flight requests on the old pool
get connection errors, which the retry logic handles automatically.
# Load new key from database/file/env
new_key = MyApp.Repo.get_latest_apns_key()
PushX.Instance.reconfigure(:apns_prod,
key_id: "NEW_KEY_ID",
private_key: new_key
)Lifecycle: instances live in memory only
Instances are registered in an ETS table and supervised under PushX's own
supervision tree. They are not persisted: after a node restart (deploy,
crash, scale-out to a new node) no instances exist until your application
starts them again. The idiomatic pattern is to load tenant credentials from
your database and call start/3 for each on boot — e.g. from a small
worker in your supervision tree placed after your Repo — and again
whenever a tenant is provisioned. start/3 is idempotent enough for this:
it returns {:error, :already_started} for a name that is running, so a
re-run is safe. Each node in a cluster starts its own instances (they are
per-VM processes, not cluster-wide).
Stopping an instance (stop/1) or reconfiguring it invalidates its cached
APNS JWT; per-instance circuit-breaker state is keyed by name and survives
a reconfigure/2 (it is process-independent) but not a node restart.
Summary
Functions
Disables an instance. New pushes are rejected, but the pool stays warm.
Re-enables a disabled instance.
Lists all running instances.
Stops and restarts an instance with updated config.
Restarts the Finch HTTP pool for a named instance.
Resolves an instance name to its info for sending.
Starts a named instance.
Returns the status of a named instance.
Stops a named instance and cleans up all resources.
Functions
@spec disable(atom()) :: :ok | {:error, :not_found}
Disables an instance. New pushes are rejected, but the pool stays warm.
@spec enable(atom()) :: :ok | {:error, :not_found}
Re-enables a disabled instance.
@spec list() :: [map()]
Lists all running instances.
Stops and restarts an instance with updated config.
Merges new_config into the existing config. Use this to hot-swap
credentials (e.g., after revoking an APNS .p8 key) without restarting
the application. The old Finch pool is terminated and a new one starts
with fresh connections. In-flight requests on the old pool receive
connection errors, which the retry logic handles automatically.
The merged config is validated before the running instance is stopped, so
a rotation to an unusable APNS key ({:error, {:invalid_private_key, reason}})
or unusable FCM credentials ({:error, {:invalid_credentials, reason}})
leaves the current instance serving traffic.
Examples
# Rotate APNS key
PushX.Instance.reconfigure(:apns_prod,
key_id: "NEW_KEY_ID",
private_key: new_pem_string
)
# Switch APNS environment
PushX.Instance.reconfigure(:apns_prod, mode: :sandbox)
Restarts the Finch HTTP pool for a named instance.
Resolves an instance name to its info for sending.
Returns {:error, :disabled} if the instance exists but is disabled,
{:error, :not_found} if it doesn't exist.
Starts a named instance.
Arguments
name- Unique atom name for this instance (e.g.,:apns_prod)provider-:apns,:fcmor:webpushconfig- Provider-specific configuration (keyword list)
APNS Config Keys
:key_id- (required) Apple Key ID:team_id- (required) Apple Team ID:private_key- (required) PEM string,{:file, path}, or{:system, "ENV_VAR"}. Must be a P-256 (prime256v1) EC key — APNS signs with ES256, and a key on any other curve is rejected at start time.:mode-:prodor:sandbox(default::prod):pool_size- Finch pool size (default: 2):pool_count- Finch pool count (default: 1):receive_timeout/:pool_timeout/:connect_timeout- per-instance request timeouts in ms (defaults: 15_000 / 5_000 / 10_000)
Web Push Config Keys
:vapid_subject- (required)"mailto:..."or https URL contact:vapid_private_key- (required) base64url 32-byte scalar or EC PEM; validated (must sign) at start time:vapid_public_key- (optional) base64url 65-byte point; derived from the private key when omitted. Give this to the tenant's front end asapplicationServerKey.:pool_size/:pool_count/ timeouts - as for APNS, but the pool is HTTP/1.1 (one per push-service origin), sopool_size × pool_count(default 2) is the instance's per-origin concurrency cap: a batch beyond it queues for:pool_timeout, then fails with a retryable:connection_error(pool_timeout)
FCM Config Keys
:project_id- (required) Firebase project ID:credentials- (required unless:token_fetcheris set) Service account credentials map or JSON string. Must contain"private_key"(an RSA PEM able to sign RS256) and"client_email"; anything else is rejected at start time. PushX starts a Goth process for the instance from them.:token_fetcher- (optional) bring your own OAuth for this instance: an{module, function, args}tuple invoked asapply(module, function, [goth_name | args])that returns{:ok, %{token: access_token}}or{:error, reason}. When set, no Goth process is started for the instance and:credentialsbecomes optional. The global:fcm_token_fetcherconfig never applies to instances.:pool_size- Finch pool size (default: 2):pool_count- Finch pool count (default: 1):receive_timeout/:pool_timeout/:connect_timeout- as for APNS
Returns
{:ok, name}on success{:error, :reserved_name}if name is:apns,:fcmor:webpush(the static config-based providers —PushX.push/4routes those atoms to the static path, so an instance by that name would be unreachable){:error, :already_started}if instance already exists{:error, {:missing_config, keys}}if required config is missing{:error, {:invalid_private_key, reason}}if an APNS:private_keycannot sign — a malformed PEM, a key on the wrong curve, a{:file, path}whose file is missing, or a{:system, VAR}that is unset{:error, {:invalid_credentials, reason}}if FCM:credentialsare not a service-account map (or its JSON), lack"private_key"/"client_email", or hold a key that cannot sign RS256{:error, {:invalid_token_fetcher, reason}}if an FCM:token_fetcheris not an{module, function, args}tuple{:error, {:invalid_vapid_key, reason}}if a Web Push:vapid_subjectis not amailto:/https:URI, or:vapid_private_keycannot be decoded / does not match:vapid_public_key
Credentials are verified with a test signature before the instance starts, so a bad key fails here rather than on the first push (APNS) or by crashing the OAuth process after start (FCM).
Returns the status of a named instance.
@spec stop(atom()) :: :ok | {:error, :not_found}
Stops a named instance and cleans up all resources.