ESC
Type to search...
S
Soli Docs

VAPID / Web Push Functions

Send Web Push notifications natively — no web-push Node module required. Soli ships P-256 ECDH/ECDSA, HKDF, and AES-128-GCM as built-ins so you can sign a VAPID JWT and POST an encrypted payload to a browser push endpoint in a few lines of controller code.

Push API Functions

vapid_generate_keys()

Generate a fresh P-256 ECDH key pair to identify this application server to push services (RFC 8292). Run this once, save both values in .env, and reuse them across deploys — subscriptions are bound to the public key and rotate only when the key rotates.

Returns

Hash{"public_key": "<base64url>", "private_key": "<base64url>"}. The public key is the 65-byte uncompressed point; the private key is the 32-byte scalar. Both are base64url without padding.
let keys = vapid_generate_keys()
println(keys["public_key"])
# BO1Ya8U... (87 chars)
println(keys["private_key"])
# OZk1q... (43 chars)
vapid_sign(private_key, audience, subject, expiry_seconds?)

Sign an ES256 VAPID JWT for the Authorization: vapid t=<jwt>, k=<public_key> header (RFC 8292 ยง2). vapid_send calls this internally — you only need it when you're talking to a push service yourself.

Parameters

private_key : String — The base64url private key from vapid_generate_keys.
audience : String — The scheme://host[:port] origin of the push endpoint (e.g. https://fcm.googleapis.com). Must be an absolute http(s) URL — bare hosts are rejected.
subject : String — A mailto: URI or HTTPS URL the push service can use to contact you if your sends misbehave.
expiry_seconds : Int? — JWT lifetime. Defaults to 12 h; max 24 h per RFC 8292.

Returns

String — the three-segment JWT header.payload.signature.
let token = vapid_sign(
  getenv("VAPID_PRIVATE_KEY"),
  "https://fcm.googleapis.com",
  "mailto:[email protected]"
)
# eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOi...
vapid_encrypt(payload, subscription, public_key, private_key)

Encrypt a Web Push payload per RFC 8291 (aes128gcm). The function generates a fresh ephemeral P-256 keypair internally — your VAPID identity keys are never reused for ECDH. The public_key / private_key parameters are accepted for API symmetry with vapid_send and are not part of the encryption itself.

Parameters

payload : String — The cleartext body to deliver (typically a JSON string the service worker decodes).
subscription : Hash — Browser-issued subscription with endpoint and keys.p256dh / keys.auth (the shape of PushSubscription.toJSON()).
public_key : String — VAPID public key (unused by encryption; kept for symmetry).
private_key : String — VAPID private key (unused by encryption; kept for symmetry).

Returns

Hash{"ciphertext": "<base64url>", "salt": "<base64url>", "server_public_key": "<base64url>"}. ciphertext is the full RFC 8291 record body (salt ‖ rs ‖ idlen ‖ ephemeral pub ‖ AES-GCM ciphertext) ready to POST verbatim.
let result = vapid_encrypt(
  "{\"title\":\"Hello\"}",
  subscription,
  getenv("VAPID_PUBLIC_KEY"),
  getenv("VAPID_PRIVATE_KEY")
)
println(len(result["ciphertext"]))   # ~100+ chars
println(len(result["salt"]))         # 22 (16 bytes base64url no-pad)
println(len(result["server_public_key"]))  # 87 (65 bytes base64url)
vapid_send(subscription, payload, private_key, public_key, subject, options?)

End-to-end Web Push delivery: signs the VAPID JWT, encrypts the payload, and POSTs the result to the subscription's endpoint. This is the function you call from a controller when a user-visible event needs to fan out to subscribers.

Parameters

subscription : Hash — The browser-issued subscription ({endpoint, keys: {p256dh, auth}}).
payload : String — The body the service worker will receive.
private_key : String — VAPID private key (base64url).
public_key : String — VAPID public key (base64url) — sent as k= in the Authorization header.
subject : Stringmailto: / https contact for the push service.
options : Hash? — Optional delivery hints.

Options

ttl : Int — Push service TTL header in seconds (default 60).
urgency : Stringvery-low, low, normal, or high (RFC 8030).
topic : String — Replaces a queued message with the same topic on the same subscription.
expiry_seconds : Int — VAPID JWT lifetime (default 12 h).

Returns

Hash{"status": Int, "body": String}. A successful delivery is HTTP 201; a missing subscription returns 404 or 410 (delete it from your store).
let result = vapid_send(
  subscription,
  "{\"title\":\"New message\",\"body\":\"From Alice\"}",
  getenv("VAPID_PRIVATE_KEY"),
  getenv("VAPID_PUBLIC_KEY"),
  "mailto:[email protected]",
  { "ttl": 3600, "urgency": "normal" }
)
if result["status"] == 410
  # Subscription is gone — remove it from the database.
  PushSubscription.delete(subscription["id"])
end

Common Patterns

Provision keys once, then send from a controller

# Run this once at setup time and store the result in .env:
#   VAPID_PUBLIC_KEY=...
#   VAPID_PRIVATE_KEY=...
let keys = vapid_generate_keys()
println("VAPID_PUBLIC_KEY=" + keys["public_key"])
println("VAPID_PRIVATE_KEY=" + keys["private_key"])
fn notify
  let subscription = PushSubscription.find(req["json"]["sub_id"])
  let result = vapid_send(
    {
      "endpoint": subscription["endpoint"],
      "keys": { "p256dh": subscription["p256dh"], "auth": subscription["auth"] }
    },
    json_stringify({ "title": "Hi", "body": req["json"]["text"] }),
    getenv("VAPID_PRIVATE_KEY"),
    getenv("VAPID_PUBLIC_KEY"),
    "mailto:[email protected]"
  )
  return { "status": result["status"] }
end