Standards-based Web Push: send to any browser's push service — Chrome,
Firefox, Edge, Safari 16+ (macOS Ventura / iOS 16.4 and later), Opera,
Samsung Internet — using the subscription the browser's PushManager gives
you. Payloads are encrypted end-to-end per RFC 8291 (aes128gcm) and the
request is authenticated with VAPID (RFC 8292); transport is RFC 8030.
This is the modern path for all browsers. (FCM's webpush block is for
apps that use the Firebase JS SDK — see PushX.FCM.send_web/5; Safari's
legacy APNS website push is PushX.APNS.web_notification/4.)
Setup
Generate a VAPID key pair once (mix pushx.vapid, or generate_vapid_keys/0)
and configure it with a contact for the push services:
config :pushx,
webpush_vapid_subject: "mailto:ops@example.com",
webpush_vapid_public_key: "BJ...", # optional — derived from the private key
webpush_vapid_private_key: "k1..." # base64url (web-push CLI format) or EC PEMThe public key is what your front end passes as applicationServerKey
to pushManager.subscribe/1. Keep the private key secret; rotating it
invalidates every existing subscription.
Sending
The target is the subscription object from the browser, as JSON-decoded (string or atom keys):
subscription = %{
"endpoint" => "https://fcm.googleapis.com/fcm/send/...",
"keys" => %{"p256dh" => "BNc...", "auth" => "tBH..."}
}
PushX.push(:webpush, subscription, %{title: "Hi", body: "...", icon: "/icon.png"}) # unified API
PushX.WebPush.send(subscription, %{"title" => "Hi", "body" => "...", "icon" => "/icon.png"})A PushX.Message maps to the Notification API shape your service worker
shows (title, body, icon, tag, data — Message.to_webpush_payload/1;
its ttl/2 and priority/2 become the TTL / Urgency headers, see
Message.to_webpush_options/1); a map is sent as JSON as-is — through
PushX.push/4 too, which never rewrites Web Push maps; a binary is sent
verbatim by send/3, while PushX.push(:webpush, sub, "Hello") treats the
string as the title ({"title": "Hello", "body": ""}), as it does for
APNS/FCM. The service worker reads the payload with event.data.json() /
.text().
The circuit breaker and rate limiter are keyed per provider: for :webpush
that is one key across every push service (Google, Mozilla, Apple,
Microsoft, ...). Consecutive 5xx from one service can open the breaker for
all of them; they are off by default.
Options
:ttl— seconds the push service may hold the message for an offline browser (default2_419_200, four weeks;0= deliver now or drop):urgency—:very_low | :low | :normal | :high(default: provider's default, i.e. normal); lets the device defer low-urgency pushes to save power:topic— collapse key (≤ 32 base64url characters): a newer push with the same topic replaces an undelivered one:retry,:receive_timeout,:pool_timeout— as forPushX.push/4
Responses
Push services answer 201 Created → {:ok, %Response{status: :sent}}
(id is the Location header when present). 404/410 mean the
subscription is gone → :unregistered — PushX.Response.should_remove_token?/1
is true and :on_invalid_token fires with the subscription map, so delete
it from your store. 401/403 → :auth_error (VAPID; PushX re-signs and
retries once in case the cached JWT went stale), 413 → :payload_too_large,
429 → :rate_limited (with retry_after), 5xx → :server_error.
Payloads are limited to 3993 bytes of
plaintext (the 4 096-byte record every push service must accept, minus
framing); larger payloads are rejected locally with :payload_too_large.
Multi-tenant: PushX.Instance.start(name, :webpush, vapid_subject: ..., vapid_private_key: ...)
gives each tenant its own VAPID identity. Test delivery mode records the
plaintext payload and never encrypts or contacts a push service.
Standards compliance
Implemented (the mandatory application-server side of each RFC):
- RFC 8030 (transport) —
POSTto the push resource over TLS;TTLalways sent (required);Urgency(very-low | low | normal | high) andTopic(≤ 32 URL-safe base64 characters) when given;201 CreatedwithLocationis success;404/410mean the subscription is gone;413,429+Retry-After,5xxmapped. Plainhttpendpoints are accepted only so a local push-service stub can be used in tests. - RFC 8291 (encryption) / RFC 8188 (
aes128gcm) — ECDH P-256 with a fresh ephemeral key pair and a fresh 16-byte salt per message, HKDF-SHA-256 with the subscription'sauthsecret,"WebPush: info"key info, CEK/nonce derivation, single 4096-byte record with the0x02delimiter, GCM tag. The implementation reproduces RFC 8291 Appendix A bit-for-bit (see the encryption tests). - RFC 8292 (VAPID) — ES256 JWT with
aud= push-service origin,exp12 h ahead (the RFC allows up to 24 h),sub= yourmailto:/https:contact; sent asAuthorization: vapid t=<jwt>, k=<public key>with the same key that signed. JWTs are cached per origin and re-signed once when a push service answers 401/403.
Optional parts of the RFCs that are not implemented: multi-record
encryption and padding (RFC 8188 allows records > 4096 bytes; push services
are only required to accept 4096, so PushX limits plaintext to
3993 bytes instead), push-message
receipts and Prefer: respond-async (RFC 8030 §5.1, rarely supported by
push services), and HTTP/2 to push services (HTTP/1.1 is used, which RFC
8030 permits for application servers).
Summary
Types
A browser push subscription as produced by PushManager.subscribe/1 and
JSON-decoded: endpoint plus keys.p256dh / keys.auth (base64url).
String or atom keys are accepted.
Functions
Generates a VAPID key pair (base64url, the format the web-push CLI and
browsers use). Do this once per application and keep the private key secret.
Sends a Web Push message to subscription with automatic retry
(see PushX.push/4 for the retry semantics and the :retry option).
Sends a Web Push message without retry.
Validates a subscription object: https endpoint, a 65-byte uncompressed
P-256 p256dh key and a 16-byte auth secret (both base64url). Returns the
parsed form used internally or {:error, %Response{status: :invalid_token}}.
Types
@type option() :: {:ttl, non_neg_integer()} | {:urgency, urgency()} | {:topic, String.t()} | {:retry, :blocking | :none} | {:receive_timeout, pos_integer()} | {:pool_timeout, pos_integer()}
@type payload() :: binary() | map() | PushX.Message.t()
@type subscription() :: %{ required(:endpoint | String.t()) => String.t(), required(:keys | String.t()) => %{required(atom() | String.t()) => String.t()}, optional(any()) => any() }
A browser push subscription as produced by PushManager.subscribe/1 and
JSON-decoded: endpoint plus keys.p256dh / keys.auth (base64url).
String or atom keys are accepted.
@type urgency() :: :very_low | :low | :normal | :high
Functions
Generates a VAPID key pair (base64url, the format the web-push CLI and
browsers use). Do this once per application and keep the private key secret.
@spec send(subscription(), payload(), [option()]) :: {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}
Sends a Web Push message to subscription with automatic retry
(see PushX.push/4 for the retry semantics and the :retry option).
@spec send_once(subscription(), payload(), [option()]) :: {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}
Sends a Web Push message without retry.
@spec validate_subscription(term()) :: {:ok, %{endpoint: String.t(), ua_public: binary(), auth: binary()}} | {:error, PushX.Response.t()}
Validates a subscription object: https endpoint, a 65-byte uncompressed
P-256 p256dh key and a 16-byte auth secret (both base64url). Returns the
parsed form used internally or {:error, %Response{status: :invalid_token}}.