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.
- A same-origin backend bridge that adds the push-platform
Authorization+X-Source-Appheaders 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.
| What | Why you need it | Owner |
|---|---|---|
| Push-platform caller token | Goes in Authorization: Bearer <token> on every backend → notify call | Platform team |
allowed_source_apps entry for your X-Source-App value | notify rejects calls whose X-Source-App is not on your caller policy | Platform team |
allowed_routes covering the routes you call | Caller policy scopes which /api/v1/... routes your token may hit | Platform team |
| VAPID keypair configured | Without it, GET /webpush/client-config and delivery fail with PUSH_ENV_NOT_READY | Platform team |
NOTIFY_WEBPUSH_ALLOWED_TARGET_HOSTS covering your destination hosts | notify only signs notifications whose target_url host is allowlisted | Platform 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.
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 yourallowed_source_apps.
Suggested same-origin contract between browser and your bridge:
| Browser-facing route (yours) | Forwards to notify route |
|---|---|
GET /api/push/config | GET /api/v1/webpush/client-config |
POST /api/push/subscriptions | POST /api/v1/service/webpush/subscriptions |
POST /api/push/subscriptions/deactivate | POST /api/v1/service/webpush/subscriptions/deactivate |
POST /api/push/events/browser | POST /api/v1/service/webpush/events/browser |
POST /api/push/events/semantic | POST /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 },
}),
});
}
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"
}
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:
| Field | Type | Notes |
|---|---|---|
payload_version | string | Schema version ("v1"); matches the payload_version from client-config. Branch on it if you support multiple. |
delivery_id | string | Unique per delivery. Echo it back in every interaction event. |
interaction_token | string | Opaque, signed by notify. Echo verbatim in browser events. Do not parse or mint it. |
title | string | Notification title for showNotification. |
body | string | Notification body text. |
icon | string | URL of the notification icon. |
badge | string | URL of the monochrome badge icon. |
image | string | URL of a large hero image (optional content). |
target_url | string | Where a click should navigate. Host must be in NOTIFY_WEBPUSH_ALLOWED_TARGET_HOSTS. |
tag | string | Notification tag; same tag replaces an existing notification. |
require_interaction | bool | If true, the notification stays until the user acts on it. |
silent | bool | If true, no sound/vibration. |
renotify | bool | If true, re-alerts even when replacing a same-tag notification. |
actions | array | Action buttons, each { id, label, target_url, icon }. |
data | object | Free-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" }.
- The opaque
interaction_tokenfrom 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/openhandoff 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) | |
|---|---|---|
| Origin | Service worker on the device | Your product server |
| Identity | Carried by interaction_token | Explicit tenant_id + positive player_id |
| Event types | displayed, clicked, closed | opened, read |
| When | The OS/browser surfaced or the user touched the notification | Your 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 }
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).
| Code | HTTP | What it means for a caller |
|---|---|---|
VALIDATION_ERROR | 400 | Body failed validation (missing field, non-positive player_id, bad enum). Fix the request. |
UNAUTHORIZED | 401 | Bad interaction_token on a browser event. The token did not verify — do not retry; the binding is invalid. |
FORBIDDEN | 403 | Caller 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_READY | 503 | VAPID or interaction secret not configured on the platform side. Not your bug — escalate to the platform team. |
INTERNAL_ERROR | 500 | Unexpected 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.
| Browser | Desktop | Mobile | Notes |
|---|---|---|---|
| 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. |
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(never0outside smoke tests). - The opaque
interaction_tokenis echoed back verbatim in browser events — never minted, parsed, or modified. - Platform team has
NOTIFY_WEBPUSH_ALLOWED_TARGET_HOSTScovering your deliverytarget_urlhosts; your backend'sWEBPUSH_ALLOWED_TARGET_HOSTScovers cross-origin click handoff targets. - Platform team confirms
WEB_PUSH_DRY_RUN=falseon Notify sender in the target environment (not an integrator env var).
See also
- API Reference — full request/response schemas for every route.
- Configuration & Secrets — platform-side environment variables, VAPID, and caller policy.
- Reference: Dashboard — a working end-to-end implementation, including the cross-origin
/openclick handoff.