Skip to main content

Integration Guide

This guide takes a product team from zero to a working browser web-push integration: a browser frontend, a trusted same-origin backend bridge that holds the platform secret, and the notify push platform that signs and delivers notifications.

The golden rule: the browser never talks to notify directly. Your backend is the only thing that holds the push-platform caller token, and it is the only thing that knows who the user is. The browser handles the service worker and the push subscription; your backend translates between the two.

What you will build
  • A same-origin backend bridge that adds the push-platform Authorization + X-Source-App headers and resolves identity from the user's session.
  • A service worker (notify-sw.js) that displays notifications and reports interaction events.
  • A subscribe / unsubscribe flow wired through your bridge into notify.

End-to-end flow

Every arrow that reaches notify-api originates from your backend (BE), never from the browser. The browser only ever talks to your same-origin bridge.

Prerequisites

Before you write a line of code, the platform team must provision these for your caller. Send them this table.

WhatWhy you need itOwner
Push-platform caller tokenGoes in Authorization: Bearer <token> on every backend → notify callPlatform team
allowed_source_apps entry for your X-Source-App valuenotify rejects calls whose X-Source-App is not on your caller policyPlatform team
allowed_routes covering the routes you callCaller policy scopes which /api/v1/... routes your token may hitPlatform team
VAPID keypair configuredWithout it, GET /webpush/client-config and delivery fail with PUSH_ENV_NOT_READYPlatform team
NOTIFY_WEBPUSH_ALLOWED_TARGET_HOSTS covering your destination hostsnotify only signs notifications whose target_url host is allowlistedPlatform team

See Configuration & Secrets for platform-side environment variables and Push Service Environment Config for per-environment values your backend must set.

Identity: tenant_id and player_id

Your bridge is the only place that resolves who the user is. Pass tenant_id and player_id on every push-platform call that names a recipient (subscribe, deactivate, semantic events). Browser lifecycle events use the signed interaction_token instead — no player_id in that body.

Both fields come from trusted server-side session/config, never from browser input. player_id must be positive in product integrations (0 is dashboard smoke only). See Overview — Identity model.

warning

None of the values above ever ship to the browser. The caller token and X-Source-App live only on your server. If a browser can read your push-platform token, the integration is broken.

Step 1 — Backend bridge (server-side, holds the secret)

Your backend exposes a same-origin API to the browser and forwards to notify. On every call to notify it adds:

  • Authorization: Bearer <push-platform token> — the caller token from Prerequisites.
  • X-Source-App: <your-app> — a value the platform team added to your allowed_source_apps.

Suggested same-origin contract between browser and your bridge:

Browser-facing route (yours)Forwards to notify route
GET /api/push/configGET /api/v1/webpush/client-config
POST /api/push/subscriptionsPOST /api/v1/service/webpush/subscriptions
POST /api/push/subscriptions/deactivatePOST /api/v1/service/webpush/subscriptions/deactivate
POST /api/push/events/browserPOST /api/v1/service/webpush/events/browser
POST /api/push/events/semanticPOST /api/v1/service/webpush/events/semantic

The bridge's one non-negotiable job: resolve tenant_id and a positive numeric player_id from the trusted session, never from the request body. The browser sends only the push subscription; identity comes from your auth layer.

# Pseudo-code: bridge forwarding a subscription to notify

handle POST /api/push/subscriptions (request):
# 1. Identity comes from the session, NOT the request body.
session = authenticate(request) # your existing auth
tenant_id = session.tenant_id # trusted string
player_id = session.player_id # trusted positive int
assert player_id > 0 # product integrations require positive

# 2. The browser only supplies the push subscription.
sub = request.body # { endpoint, keys: { p256dh, auth } }

# 3. Build the notify request body and forward with the secret headers.
notify_body = {
tenant_id: tenant_id,
player_id: player_id,
endpoint: sub.endpoint,
keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
user_agent: request.header("User-Agent"),
platform: "web",
}

resp = http_post(
url = NOTIFY_BASE_URL + "/api/v1/service/webpush/subscriptions",
headers = {
"Authorization": "Bearer " + PUSH_PLATFORM_TOKEN,
"X-Source-App": SOURCE_APP,
"Content-Type": "application/json",
},
body = notify_body,
)

return resp # 201 serviceSubscriptionResponse on success

Step 2 — Fetch client config

The browser needs the VAPID public key to subscribe and the payload_version to validate delivered payloads. Your bridge fetches it from notify:

curl -X GET "$NOTIFY_BASE_URL/api/v1/webpush/client-config" \
-H "Authorization: Bearer $PUSH_PLATFORM_TOKEN" \
-H "X-Source-App: $SOURCE_APP"

Response:

{
"public_key": "BJxK...base64url-encoded-VAPID-public-key...9aQ",
"payload_version": "v1"
}

Your bridge passes public_key and payload_version through to the browser (via GET /api/push/config). The public_key is the application server key the browser uses in pushManager.subscribe.

Step 3 — Register the service worker & subscribe (browser)

In the page, register the service worker, then ask the push service to subscribe using the VAPID public key from Step 2.

// Browser page code.

// Decode the base64url VAPID key into the byte array the Push API wants.
function urlBase64ToArrayBuffer(base64Url) {
const padding = "=".repeat((4 - (base64Url.length % 4)) % 4);
const base64 = (base64Url + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out.buffer;
}

async function subscribeToPush() {
// Permission MUST be requested from a real user gesture (e.g. a click handler).
const permission = await Notification.requestPermission();
if (permission !== "granted") return;

const registration = await navigator.serviceWorker.register("/notify-sw.js");
await navigator.serviceWorker.ready;

// Pull the VAPID public key from your same-origin bridge.
const config = await fetch("/api/push/config").then((r) => r.json());

const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToArrayBuffer(config.public_key),
});

// toJSON() yields { endpoint, keys: { p256dh, auth }, expirationTime }.
const sub = subscription.toJSON();

// Send ONLY the subscription to your bridge. No identity — the bridge knows who you are.
await fetch("/api/push/subscriptions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: sub.endpoint,
keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
}),
});
}
warning

Notification.requestPermission() and pushManager.subscribe() must run inside a user gesture (a click handler). Browsers reject permission prompts that fire on page load.

Step 4 — Store the subscription

Your bridge maps the browser subscription onto notify's serviceCreateSubscriptionBody and POSTs it to POST /api/v1/service/webpush/subscriptions.

Request body (serviceCreateSubscriptionBody):

{
"tenant_id": "acme",
"player_id": 84217,
"endpoint": "https://fcm.googleapis.com/fcm/send/abcd-1234",
"keys": {
"p256dh": "BKx...base64url-client-public-key...",
"auth": "k9f...base64url-auth-secret..."
},
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
"platform": "web"
}

Success response — 201 serviceSubscriptionResponse:

{
"id": "sub_9f2c1ab47e",
"tenant_id": "acme",
"player_id": 84217,
"endpoint": "https://fcm.googleapis.com/fcm/send/abcd-1234",
"platform": "web",
"created_at": "2026-06-09T14:03:11Z"
}
note

player_id must be a positive integer in product integrations. player_id: 0 is reserved for dashboard smoke tests only. Your bridge must resolve both tenant_id and player_id from the authenticated session before forwarding to Notify.

Step 5 — The delivered payload contract

When notify-sender delivers a notification, the push service wakes your service worker with a push event. Read the payload with event.data.json(). This is the DeliveryContent contract:

FieldTypeNotes
payload_versionstringSchema version ("v1"); matches the payload_version from client-config. Branch on it if you support multiple.
delivery_idstringUnique per delivery. Echo it back in every interaction event.
interaction_tokenstringOpaque, signed by notify. Echo verbatim in browser events. Do not parse or mint it.
titlestringNotification title for showNotification.
bodystringNotification body text.
iconstringURL of the notification icon.
badgestringURL of the monochrome badge icon.
imagestringURL of a large hero image (optional content).
target_urlstringWhere a click should navigate. Host must be in NOTIFY_WEBPUSH_ALLOWED_TARGET_HOSTS.
tagstringNotification tag; same tag replaces an existing notification.
require_interactionboolIf true, the notification stays until the user acts on it.
silentboolIf true, no sound/vibration.
renotifyboolIf true, re-alerts even when replacing a same-tag notification.
actionsarrayAction buttons, each { id, label, target_url, icon }.
dataobjectFree-form passthrough object for your own routing/metadata.

Realistic example payload:

{
"payload_version": "v1",
"delivery_id": "dlv_7c1f93a4e0",
"interaction_token": "eyJhbGciOiJFUzI1NiJ9.opaque.signed-by-notify",
"title": "Your weekly bonus is ready",
"body": "Tap to claim 50 free spins before they expire.",
"icon": "https://cdn.acme.example/icons/bonus-192.png",
"badge": "https://cdn.acme.example/icons/badge-72.png",
"image": "https://cdn.acme.example/banners/weekly-bonus.png",
"target_url": "https://app.acme.example/bonuses/weekly",
"tag": "weekly-bonus",
"require_interaction": true,
"silent": false,
"renotify": false,
"actions": [
{
"id": "claim",
"label": "Claim now",
"target_url": "https://app.acme.example/bonuses/weekly?cta=claim",
"icon": "https://cdn.acme.example/icons/claim-32.png"
},
{
"id": "later",
"label": "Remind me later",
"target_url": "https://app.acme.example/bonuses/weekly?cta=later",
"icon": "https://cdn.acme.example/icons/later-32.png"
}
],
"data": { "campaign": "weekly-bonus", "segment": "vip" }
}

Step 6 — Service worker: display + interaction events

A minimal notify-sw.js. On push it displays the notification and reports a displayed event. On notificationclick it reports clicked and opens target_url. On notificationclose it reports closed.

// notify-sw.js

// Report a browser interaction event through the same-origin bridge.
function reportBrowserEvent(payload, eventType, extra) {
const body = {
event_id: crypto.randomUUID(),
delivery_id: payload.delivery_id,
notification_id: payload.data && payload.data.notification_id,
interaction_token: payload.interaction_token, // echo verbatim
event_type: eventType, // displayed | clicked | closed
endpoint: self.registration.pushManager ? undefined : undefined, // resolved below from the subscription
occurred_at: new Date().toISOString(),
...extra,
};
return self.registration.pushManager.getSubscription().then((sub) => {
body.endpoint = sub ? sub.endpoint : null;
return fetch("/api/push/events/browser", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
});
}

self.addEventListener("push", (event) => {
const payload = event.data.json(); // DeliveryContent
event.waitUntil(
self.registration
.showNotification(payload.title, {
body: payload.body,
icon: payload.icon,
badge: payload.badge,
image: payload.image,
tag: payload.tag,
requireInteraction: payload.require_interaction,
silent: payload.silent,
renotify: payload.renotify,
actions: (payload.actions || []).map((a) => ({
action: a.id,
title: a.label,
icon: a.icon,
})),
data: payload, // keep the whole payload for click handling
})
.then(() => reportBrowserEvent(payload, "displayed")),
);
});

self.addEventListener("notificationclick", (event) => {
const payload = event.notification.data;
// event.action is "" for the body, or the action id for an action button.
const action = event.action || undefined;
const chosen = action && (payload.actions || []).find((a) => a.id === action);
const url = chosen ? chosen.target_url : payload.target_url;

event.notification.close();
event.waitUntil(
reportBrowserEvent(
payload,
"clicked",
action ? { action_id: action } : {},
).then(() => clients.openWindow(url)),
);
});

self.addEventListener("notificationclose", (event) => {
const payload = event.notification.data;
event.waitUntil(reportBrowserEvent(payload, "closed"));
});

The exact browserPushEventBody your bridge forwards to POST /api/v1/service/webpush/events/browser:

{
"event_id": "evt_2b9d4f1a77",
"delivery_id": "dlv_7c1f93a4e0",
"notification_id": "ntf_5510ab",
"interaction_token": "eyJhbGciOiJFUzI1NiJ9.opaque.signed-by-notify",
"event_type": "clicked",
"endpoint": "https://fcm.googleapis.com/fcm/send/abcd-1234",
"action_id": "claim",
"occurred_at": "2026-06-09T14:05:42Z",
"metadata": { "screen": "lock" }
}

A successful event returns 202 { "status": "accepted" }.

note
  • The opaque interaction_token from the delivered payload must be echoed back verbatim — it carries the tenant + player + notification binding, which is why browser events do not pass identity. Never mint, parse, or modify it.
  • clients.openWindow(target_url) works for same-origin targets. For cross-origin click targets, record the click via a backend /open handoff so the event still lands even when navigation leaves your origin. See Reference: Dashboard for the working pattern.

Step 7 — Semantic events (optional)

Browser events are telemetry from the device. Semantic events are the opposite: when your product server knows a user opened or read a message (for example, they opened the message in your in-app inbox), report it directly — no browser involved.

POST a semanticPushEventBody to POST /api/v1/service/webpush/events/semantic:

{
"event_id": "evt_a0c7e21944",
"delivery_id": "dlv_7c1f93a4e0",
"notification_id": "ntf_5510ab",
"event_type": "read",
"tenant_id": "acme",
"player_id": 84217,
"occurred_at": "2026-06-09T14:30:00Z",
"metadata": { "source": "inbox" }
}
Browser event (/events/browser)Semantic event (/events/semantic)
OriginService worker on the deviceYour product server
IdentityCarried by interaction_tokenExplicit tenant_id + positive player_id
Event typesdisplayed, clicked, closedopened, read
WhenThe OS/browser surfaced or the user touched the notificationYour app confirmed the user engaged server-side

A successful semantic event also returns 202 { "status": "accepted" }.

Step 8 — Unsubscribe

To stop delivery to a device, deactivate by its exact endpoint. POST to POST /api/v1/service/webpush/subscriptions/deactivate:

{
"tenant_id": "acme",
"player_id": 84217,
"endpoint": "https://fcm.googleapis.com/fcm/send/abcd-1234",
"deactivated": "(response field, see below)"
}

Request body is { tenant_id, player_id, endpoint } — all three required; they must match the subscription being deactivated. Response:

{ "deactivated": true }
warning

There is no user-wide unsubscribe. The contract is endpoint-scoped — one deactivate call removes exactly one endpoint. If a user has subscribed on three browsers, that is three endpoints and three deactivate calls. Your backend must track which endpoints belong to which user so it can deactivate them all when the user opts out.

Error handling

All errors share the shape { "error": { "code": "...", "message": "...", "details": ... } } (details optional).

CodeHTTPWhat it means for a caller
VALIDATION_ERROR400Body failed validation (missing field, non-positive player_id, bad enum). Fix the request.
UNAUTHORIZED401Bad interaction_token on a browser event. The token did not verify — do not retry; the binding is invalid.
FORBIDDEN403Caller policy rejected the request: disallowed route, missing/wrong X-Source-App, or tenant not in tenant_ids. Check caller policy with the platform team.
PUSH_ENV_NOT_READY503VAPID or interaction secret not configured on the platform side. Not your bug — escalate to the platform team.
INTERNAL_ERROR500Unexpected server error. Safe to retry with backoff.

Browser support

Web push depends on the Push API, the Notifications API, and service workers. Support is broad on Chromium and Firefox; Safari is the special case — feature-detect ('serviceWorker' in navigator && 'PushManager' in window) and degrade gracefully where it is missing.

BrowserDesktopMobileNotes
Chrome / Edge / Opera (Chromium)✅ (Android)Full support.
Firefox✅ (Android)Full support.
Safari (macOS 16+)Web push for websites since Ventura; user must allow notifications.
Safari (iOS / iPadOS 16.4+)⚠️Only when the site is installed to the Home Screen as a PWA. No web push in a normal Safari tab.
Any browser, private/incognito⚠️⚠️Service worker / push may be disabled; treat as unsupported.
iOS requires an installed PWA

On iPhone and iPad there is no web push from a regular Safari tab — the user must add the site to the Home Screen first, then grant permission from inside the installed app. Build your opt-in UI to detect this case and prompt the user to install, rather than silently failing.

Checklist before go-live

  • Notification permission is requested from a real user gesture, not on page load.
  • The push-platform caller token lives only on your backend — the browser never sees it.
  • Every subscription and semantic event uses a positive player_id (never 0 outside smoke tests).
  • The opaque interaction_token is echoed back verbatim in browser events — never minted, parsed, or modified.
  • Platform team has NOTIFY_WEBPUSH_ALLOWED_TARGET_HOSTS covering your delivery target_url hosts; your backend's WEBPUSH_ALLOWED_TARGET_HOSTS covers cross-origin click handoff targets.
  • Platform team confirms WEB_PUSH_DRY_RUN=false on Notify sender in the target environment (not an integrator env var).

See also