@circlehq/push-web
v1.2.0
Published
CircleHQ Push Web SDK — register browser push tokens, identify users, and report notification events.
Downloads
277
Readme
@circle/push-web
Production-grade JavaScript/TypeScript SDK for Circle Push notifications on the web. Works in any web app — React, Next.js, Vue, Angular, Svelte, vanilla JS.
Table of contents
- Prerequisites
- Installation
- How it works
- Quick start
- Environment variables
- Service worker setup
- API reference
- Framework guides
- Handling notification clicks
- Asking for permission manually
- Unregistering on logout
- Debugging
- Browser support
- Content Security Policy
- Troubleshooting
- Security & privacy
- License
Prerequisites
You only need one value from your Circle dashboard:
| Credential | Where to find it |
|---|---|
| Circle API key (cir_live_xxx) | Dashboard → Settings → API Keys |
The VAPID key and Firebase configuration are BYOC — you upload them once on the Circle dashboard and the SDK fetches them at runtime via your apiKey. They never live in your app's source code or the SDK bundle.
Your site must be served over HTTPS (or localhost for development). Push notifications do not work on plain HTTP origins.
Installation
npm install @circle/push-web
# or
yarn add @circle/push-web
# or
pnpm add @circle/push-web How it works
Your app Circle Push SDK Circle backend
───────── ─────────────────── ───────────────
init() ──▶ GET /api/v1/push/config?platform=web ──▶ Your Firebase config + VAPID key
Register service worker
Init Firebase app + FCM
identify() ──▶ Request browser permission
Get FCM push token
POST /api/v1/push/devices/register ──▶ Store device
◀── { deviceId, contactId }
on('notificationClicked') ◀── SW intercepts tap, relays to page
unregister() ──▶ DELETE /api/v1/push/devices/unregister ──▶ Remove device
Clear local stateThe SDK exposes a singleton (CirclePush). All public methods are mutex-guarded, so concurrent calls are automatically serialised.
Quick start
Step 1 — Install the package. The service worker is installed automatically:
npm install @circlehq/push-webThat's it — a postinstall hook copies firebase-messaging-sw.js into your project's
static folder (auto-detected per framework — see Frameworks).
If your install runs with --ignore-scripts, run this once instead:
npx circle-push install-swStep 2 — Initialise the SDK as early as possible (app root, layout component, etc.):
import CirclePush from '@circle/push-web';
await CirclePush.init({
apiKey: 'cir_live_xxx', // your Circle External API key — the only required field
});The SDK fetches your Firebase config and VAPID key from Circle's backend at init time — upload them once on the Circle dashboard, never in your code.
Step 3 — Identify the user after they log in:
const device = await CirclePush.identify({
email: '[email protected]', // required if no phone
phone: '+254712345678', // required if no email
firstName: 'Jane', // optional
lastName: 'Doe', // optional
});
console.log('Registered device:', device.deviceId);That's all — Circle can now deliver push notifications to this user's browser.
Environment variables
The API key is the only consumer-supplied value. Store it in an env var:
Vite / CRA (.env):
VITE_CIRCLE_API_KEY=cir_live_xxxawait CirclePush.init({
apiKey: import.meta.env.VITE_CIRCLE_API_KEY,
});Next.js (.env.local):
NEXT_PUBLIC_CIRCLE_API_KEY=cir_live_xxxawait CirclePush.init({
apiKey: process.env.NEXT_PUBLIC_CIRCLE_API_KEY!,
});The VAPID key and Firebase config are BYOC, uploaded once on the Circle dashboard and fetched by the SDK at runtime — do not put them in consumer env files.
Service worker setup
The service worker handles background notifications (received while the tab is not focused). It must be:
- Placed at your site root (e.g.,
/firebase-messaging-sw.js) - Served over HTTPS (or
localhost) - Same origin as your app — it cannot be hosted on a CDN
Automatic install (default)
When you npm install @circlehq/push-web, a postinstall hook copies the SW
into your project's static folder. No configuration required.
To opt out (e.g., if you commit the SW manually), set:
CIRCLE_PUSH_SKIP_POSTINSTALL=1 npm installManual fallback (--ignore-scripts users)
npx circle-push install-sw # auto-detects framework
npx circle-push install-sw --dest public # explicit destination
npx circle-push --helpFrameworks
The auto-installer detects your framework from package.json and copies the SW
to the right folder:
| Framework | Detected via | Destination |
|---|---|---|
| Next.js | next | public/ |
| Vite (React/Vue/Svelte) | vite | public/ |
| Vue CLI / Nuxt | vue, nuxt | public/ |
| Create React App | react-scripts | public/ |
| Remix | @remix-run/react | public/ |
| SvelteKit | @sveltejs/kit | static/ |
| Angular | @angular/core | src/ (plus a one-line angular.json paste) |
| anything else | — | public/ |
For Angular, also add this to your angular.json assets array:
{ "glob": "firebase-messaging-sw.js", "input": "src", "output": "/" }Sub-path deployments
If your app is mounted under a sub-path (e.g., Next.js basePath: '/dashboard'),
the SW URL is no longer /firebase-messaging-sw.js. Override it via
serviceWorkerPath:
await CirclePush.init({
apiKey: 'cir_live_xxx',
serviceWorkerPath: '/dashboard/firebase-messaging-sw.js',
serviceWorkerScope: '/dashboard/',
});Custom path / scope
If your app is not served from /, pass the path and scope explicitly:
await CirclePush.init({
// ...
serviceWorkerPath: '/push/firebase-messaging-sw.js',
serviceWorkerScope: '/push/',
});The file must then be accessible at that path on your server.
API reference
init(config) → Promise<void>
Must be called before any other method. Safe to call on every page load — subsequent calls with the same config resolve immediately (idempotent).
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| apiKey | string | ✅ | — | Circle External API key. The Firebase config and VAPID key are fetched automatically from your Circle dashboard's BYOC settings — there's nothing else to configure |
| apiBaseUrl | string | | https://api.circlehq.co | Override the Circle API base URL |
| serviceWorkerPath | string | | /firebase-messaging-sw.js | Path where the SW file is served |
| serviceWorkerScope | string | | / | Service worker scope |
| debug | boolean | | false | Verbose console logging (tokens/emails are redacted) |
| autoRequestPermission | boolean | | true | Auto-prompt for permission when identify() is called |
Calling init() twice with different config throws config/already_initialized.
identify(identity) → Promise<RegisteredDevice>
Links the current browser to a user in Circle. Requires at least one of email or phone.
const device = await CirclePush.identify({
email: '[email protected]',
phone: '+254712345678',
firstName: 'Jane',
lastName: 'Doe',
});
// device: { deviceId, contactId, pushToken, platform, isValid }What this does internally:
- Requests browser notification permission (if
autoRequestPermission: trueand permission isdefault) - Obtains an FCM push token from Firebase
POST /api/v1/push/devices/registerwith the token and identity- Stores the
deviceIdand token inlocalStorage
Re-identifying with a different identity automatically unregisters the previous device first.
Re-identifying with the same identity is throttled to once per 60 seconds.
requestPermission() → Promise<PermissionState>
Manually trigger the browser permission prompt. Use this when you want to show a custom soft-ask UI before the native prompt.
const state = await CirclePush.requestPermission();
// 'granted' | 'denied' | 'default' | 'unsupported'
if (state === 'granted') {
await CirclePush.identify({ email: '[email protected]' });
}getPermissionState() → PermissionState
Synchronous, non-prompting read of the current permission status.
const state = CirclePush.getPermissionState();
if (state === 'denied') {
// show a UI hint to enable from browser settings
}getToken() → Promise<string | null>
Returns the current FCM push token without prompting or re-registering. Returns null if the user has not been identified yet.
const token = await CirclePush.getToken();refresh() → Promise<RegisteredDevice | null>
Force re-acquires the FCM token and re-registers with Circle. Useful after long sessions or if you suspect the token has rotated.
await CirclePush.refresh();unregister() → Promise<void>
Unregisters the device, deletes the FCM token, and clears all local state. Call this on user logout.
await CirclePush.unregister();on(event, handler) → () => void
Subscribe to SDK events. Returns an unsubscribe function.
const off = CirclePush.on('notificationClicked', ({ link, campaignId }) => {
if (link) window.location.href = link;
});
// Stop listening:
off();| Event | Payload type | When it fires |
|---|---|---|
| permissionChange | PermissionState | Browser permission changes |
| tokenRefresh | { oldToken: string \| null, newToken: string } | FCM token rotates |
| notificationReceived | NotificationPayload | Notification arrives while tab is foreground |
| notificationClicked | { messageId?, campaignId?, link? } | User taps a notification |
| error | CirclePushError | Internal error occurs |
setDebug(enabled: boolean)
Toggle verbose logging at runtime without re-initialising.
CirclePush.setDebug(true);version
Read-only string of the SDK version.
console.log(CirclePush.version); // e.g. "0.1.0"Framework guides
React (Vite/CRA)
// src/hooks/useCirclePush.ts
import { useEffect } from 'react';
import CirclePush from '@circle/push-web';
export function useCirclePush() {
useEffect(() => {
CirclePush.init({
apiKey: import.meta.env.VITE_CIRCLE_API_KEY,
}).catch(console.error);
}, []);
}// src/App.tsx
import { useCirclePush } from './hooks/useCirclePush';
export default function App() {
useCirclePush(); // initialise once at the root
// ...
}After the user logs in:
async function onLogin(user: User) {
await CirclePush.identify({ email: user.email, firstName: user.name });
}Next.js 14+ (App Router)
// app/_components/CirclePushProvider.tsx
'use client';
import { useEffect } from 'react';
import CirclePush from '@circle/push-web';
export function CirclePushProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
CirclePush.init({
apiKey: process.env.NEXT_PUBLIC_CIRCLE_API_KEY!,
}).catch(console.error);
}, []);
return <>{children}</>;
}// app/layout.tsx
import { CirclePushProvider } from './_components/CirclePushProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<CirclePushProvider>{children}</CirclePushProvider>
</body>
</html>
);
}Place the service worker at public/firebase-messaging-sw.js.
Vue 3
// src/plugins/circlePush.ts
import type { App } from 'vue';
import CirclePush from '@circle/push-web';
export default {
install(_app: App) {
CirclePush.init({
apiKey: import.meta.env.VITE_CIRCLE_API_KEY,
}).catch(console.error);
},
};// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import circlePushPlugin from './plugins/circlePush';
createApp(App).use(circlePushPlugin).mount('#app');Identify after auth:
// composable or Pinia action
import CirclePush from '@circle/push-web';
async function onUserLoggedIn(user: User) {
await CirclePush.identify({ email: user.email });
}Angular 17+
// src/app/app.config.ts
import { ApplicationConfig, provideAppInitializer } from '@angular/core';
import CirclePush from '@circle/push-web';
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(() =>
CirclePush.init({
apiKey: 'cir_live_xxx', // or inject from environment.ts
})
),
],
};Identify after authentication (e.g., in an AuthService):
// src/app/auth.service.ts
import { Injectable } from '@angular/core';
import CirclePush from '@circle/push-web';
@Injectable({ providedIn: 'root' })
export class AuthService {
async afterLogin(user: User) {
await CirclePush.identify({ email: user.email, firstName: user.displayName });
}
async logout() {
await CirclePush.unregister();
}
}Svelte / SvelteKit
// src/lib/circlePush.ts
import CirclePush from '@circle/push-web';
import { browser } from '$app/environment';
export async function initCirclePush() {
if (!browser) return; // SSR guard
await CirclePush.init({
apiKey: import.meta.env.VITE_CIRCLE_API_KEY,
});
}<!-- src/routes/+layout.svelte -->
<script>
import { onMount } from 'svelte';
import { initCirclePush } from '$lib/circlePush';
onMount(() => { initCirclePush(); });
</script>
<slot />Vanilla JS / CDN
No build tool or npm needed. Load the UMD bundle directly from a CDN:
unpkg:
<script src="https://unpkg.com/@circlehq/[email protected]/dist/circle-push.umd.js"></script>jsDelivr:
<script src="https://cdn.jsdelivr.net/npm/@circlehq/[email protected]/dist/circle-push.umd.js"></script>The UMD build exposes a global CirclePush object. Copy the service worker file to your site root first (download it from the same CDN URL: .../dist/service-worker/firebase-messaging-sw.js), then:
<!DOCTYPE html>
<html>
<head>
<!-- firebase is a required peer -->
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js"></script>
<!-- Circle Push SDK -->
<script src="https://unpkg.com/@circlehq/[email protected]/dist/circle-push.umd.js"></script>
</head>
<body>
<script>
(async () => {
await CirclePush.init({ apiKey: 'cir_live_xxx' });
// After the user logs in:
await CirclePush.identify({ email: '[email protected]' });
CirclePush.on('notificationClicked', function(payload) {
if (payload.link) window.location.href = payload.link;
});
})();
</script>
</body>
</html>Service worker: Download
firebase-messaging-sw.jsfrom the same CDN and host it at your site root (it must be same-origin):https://unpkg.com/@circlehq/[email protected]/dist/service-worker/firebase-messaging-sw.js
Handling notification clicks
Subscribe to notificationClicked to react when a user taps a notification:
CirclePush.on('notificationClicked', ({ link, campaignId, messageId }) => {
console.log('Campaign:', campaignId, '| Message:', messageId);
// NOTE: do NOT navigate here — the service worker already opens/focuses
// the link URL automatically. Use this handler for analytics or UI updates only.
});How link navigation works automatically:
| Scenario | What happens |
|---|---|
| link URL is already open in a tab | That tab is focused |
| link URL is not open | A new tab is opened to the URL |
| No link in the notification | No navigation; only the notificationClicked event fires |
The link value is read from (in priority order):
- FCM's native
fcmOptions.linkfield data.deepLinkin the notification payloaddata.linkin the notification payload
Asking for permission manually
By default (autoRequestPermission: true), the SDK asks for permission automatically during identify(). To control the timing yourself:
await CirclePush.init({
// ...
autoRequestPermission: false, // disable auto-prompt
});
// Show your own soft-ask UI first, then:
const onUserAcceptedSoftAsk = async () => {
const state = await CirclePush.requestPermission();
if (state === 'granted') {
await CirclePush.identify({ email: currentUser.email });
} else {
console.log('Permission not granted:', state);
}
};Unregistering on logout
Always unregister on logout to stop delivering notifications to a signed-out user:
async function logout() {
await CirclePush.unregister(); // deletes token, clears localStorage
await signOut(auth); // your auth provider
}Debugging
Enable verbose logging during development:
await CirclePush.init({ /* ... */, debug: true });
// or at runtime:
CirclePush.setDebug(true);Emails, phone numbers, and push tokens are always redacted in logs, even with debug: true.
Browser support
| Browser | Minimum version | Notes |
|---|---|---|
| Chrome / Edge / Brave / Opera | 91+ | ✅ Full support |
| Firefox | 90+ | ✅ Full support |
| Safari (macOS) | 16.4+ | ✅ Full support |
| Safari (iOS) | 16.4+ | ⚠️ PWA only — user must "Add to Home Screen" |
| Samsung Internet | 14+ | ✅ Full support |
| In-app browsers (Facebook, Instagram, etc.) | — | ❌ Returns unsupported, silent no-op |
The SDK detects unsupported environments at init() and silently no-ops — your app does not need to branch on browser type.
Content Security Policy
If your app uses a strict CSP, add these directives:
script-src 'self' https://www.gstatic.com;
connect-src 'self' https://api.circlehq.co https://fcm.googleapis.com https://*.googleapis.com;
worker-src 'self';Troubleshooting
Permission stays default after calling requestPermission()
The user dismissed the native prompt without choosing. Browsers throttle repeated prompts — wait for a user gesture (button click) before calling requestPermission() again.
config/already_initialized error
You called init() twice with different config. Call init() once at the app root with consistent config across the session.
Service worker fails to register
Confirm firebase-messaging-sw.js is reachable at https://yourdomain.com/firebase-messaging-sw.js. It must be same-origin — it cannot be served from a CDN subdomain.
FCM token never arrives / tokenRefresh never fires
- Verify your site runs on HTTPS (or
localhost) - Check
Notification.permissionin the browser console - Confirm Firebase credentials and a VAPID key are uploaded under Push → Web on the Circle dashboard
config/invalid_push_config error
Circle's backend didn't return a usable Firebase config or VAPID key for your org. Check that you've uploaded your Firebase service account and a Web Push VAPID key on the Circle dashboard.
iOS Safari shows no notifications
Web Push on iOS requires the user to first "Add to Home Screen". The SDK reports unsupported until then.
Notifications arrive in devtools but not visible on screen
Check OS-level notification permissions — the browser may be silenced in system settings.
Security & privacy
- No secrets in the bundle. The API key is a publishable external key, not a server secret.
- BYOC. Your Firebase service account and VAPID key are uploaded once to Circle's dashboard, stored encrypted, and fetched by the SDK at
init()time over HTTPS — they never live in this bundle or your app's source code. - No PII in logs. Tokens, emails, and phone numbers are always redacted.
- HTTPS only. The SDK throws
config/insecure_contexton plain HTTP (non-localhost). - No cookies. State is stored in
localStorage, scoped to your origin. - No telemetry. The SDK never sends usage data anywhere other than the Circle API you configure.
unregister()wipes all SDK state fromlocalStorage.
License
MIT
