Web Push notifications for Elixir. Payload encryption (RFC 8291 + RFC 8188 aes128gcm) and VAPID authentication (RFC 8292), sent over Finch.

Raw library: it starts no processes and holds no state. Your application supervises the Finch pool, stores the subscriptions, and decides what a notification looks like. WebPush handles the cryptography and the HTTP contract with the push service.

Features

  • VAPID (RFC 8292): ES256 JWT signing with bare :crypto, no JWT dependency
  • Payload encryption (RFC 8291/8188): ECDH + HKDF + AES-128-GCM, verified byte-for-byte against the RFC 8291 §5 test vector
  • Zero state: no application, no GenServers, no Ecto. Bring your own Finch pool and storage
  • Single dependency: Finch (JSON encoding uses Elixir's native JSON module)

Installation

defp deps do
  [
    {:web_push, "~> 0.1.0"}
  ]
end

Setup

Three moving parts: a Finch pool, VAPID keys, and stored subscriptions.

1. Supervise a Finch pool

# lib/my_app/application.ex
children = [
  {Finch, name: MyApp.WebPushFinch}
]
# config/config.exs
config :web_push, finch: MyApp.WebPushFinch

2. Generate VAPID keys

mix web_push.gen.vapid
# config/runtime.exs
config :web_push, :vapid,
  public_key: System.fetch_env!("VAPID_PUBLIC_KEY"),
  private_key: System.fetch_env!("VAPID_PRIVATE_KEY"),
  subject: "mailto:you@example.com"

The public key is not secret: the browser needs it to subscribe (see below). The private key signs every push request.

3. Store subscriptions

Browsers hand you a JSON object per subscribed device:

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/...",
  "keys": {"p256dh": "B...", "auth": "..."}
}

Persist it however you like (an Ecto table with a unique index on endpoint works well). When sending, map the row into a WebPush.Subscription:

%WebPush.Subscription{endpoint: row.endpoint, p256dh: row.p256dh, auth: row.auth}

For request bodies straight from the browser, WebPush.Subscription.from_map/1 parses the nested JSON shape.

Sending

alias WebPush.Subscription

sub = %Subscription{endpoint: endpoint, p256dh: p256dh, auth: auth}

case WebPush.send(sub, %{"title" => "new order", "url" => "/admin/orders/1"}, urgency: "high") do
  :ok -> :sent
  {:error, :gone} -> MyApp.Notifications.delete_subscription(sub.endpoint)
  {:error, reason} -> Logger.warning("push failed: #{inspect(reason)}")
end

Options:

optiondefaultmeaning
:ttl86400seconds the push service may hold the message
:urgency"normal""very-low", "low", "normal", "high"
:topicnonecollapse key; pending messages with the same topic are replaced
:finchconfiguredper-call Finch pool override

Return values:

  • :ok: the push service accepted the message (200/201/202/204)
  • {:error, :gone}: 404 or 410, the subscription is dead. Delete it from storage
  • {:error, term}: transport failure or unexpected status

Client side

WebPush is server-side only. The browser half is two small files, adapted here as a starting point. Yours will differ in routes, CSRF handling, and copy.

Service worker (priv/static/sw.js)

self.addEventListener("push", (event) => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(data.title || "notification", {
      body: data.body || "",
      tag: data.tag,
      data: { url: data.url || "/" },
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const url = (event.notification.data && event.notification.data.url) || "/";
  event.waitUntil(self.clients.openWindow(url));
});

Subscribing

// Your server exposes the VAPID public key (WebPush.Vapid.public_key/0)
// at some endpoint, e.g. GET /push/vapid-key.
async function subscribe() {
  const reg = await navigator.serviceWorker.ready;
  const { public_key } = await (await fetch("/push/vapid-key")).json();

  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(public_key),
  });

  // POST sub.toJSON() to your server and persist it.
  await fetch("/push/subscribe", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(sub.toJSON()),
  });
}

function urlBase64ToUint8Array(b64) {
  const padding = "=".repeat((4 - (b64.length % 4)) % 4);
  const raw = atob((b64 + padding).replace(/-/g, "+").replace(/_/g, "/"));
  return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}

Note that push requires a service worker registration and a user gesture for the permission prompt on most browsers.

How it fits together

payload map
  -> JSON.encode!/1                     (WebPush)
  -> RFC 8291 encryption                (WebPush.Encryption, pure :crypto)
  -> VAPID Authorization header         (WebPush.Vapid, pure :crypto)
  -> POST to subscription endpoint      (Finch, your pool)

WebPush.Encryption and WebPush.Vapid are pure functions and can be used on their own if you need a different transport.

License

MIT. See LICENSE.