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 : String — mailto: / https contact for the push service.options : Hash? — Optional delivery hints.Options
ttl : Int — Push service TTL header in seconds (default 60).urgency : String — very-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