@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
You only need one value from your Circle dashboard:
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
yarn add @circle/push-web
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 state
The 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-web
That'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-sw
Step 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',
});
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: 'user@example.com',
phone: '+254712345678',
firstName: 'Jane',
lastName: 'Doe',
});
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_xxx
await CirclePush.init({
apiKey: import.meta.env.VITE_CIRCLE_API_KEY,
});
Next.js (.env.local):
NEXT_PUBLIC_CIRCLE_API_KEY=cir_live_xxx
await 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 install
Manual fallback (--ignore-scripts users)
npx circle-push install-sw
npx circle-push install-sw --dest public
npx circle-push --help
Frameworks
The auto-installer detects your framework from package.json and copies the SW
to the right folder:
| 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).
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: 'user@example.com',
phone: '+254712345678',
firstName: 'Jane',
lastName: 'Doe',
});
What this does internally:
- Requests browser notification permission (if
autoRequestPermission: true and permission is default)
- Obtains an FCM push token from Firebase
POST /api/v1/push/devices/register with the token and identity
- Stores the
deviceId and token in localStorage
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();
if (state === 'granted') {
await CirclePush.identify({ email: 'user@example.com' });
}
getPermissionState() → PermissionState
Synchronous, non-prompting read of the current permission status.
const state = CirclePush.getPermissionState();
if (state === 'denied') {
}
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;
});
off();
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);
Framework guides
React (Vite/CRA)
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);
}, []);
}
import { useCirclePush } from './hooks/useCirclePush';
export default function App() {
useCirclePush();
}
After the user logs in:
async function onLogin(user: User) {
await CirclePush.identify({ email: user.email, firstName: user.name });
}
Next.js 14+ (App Router)
'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}</>;
}
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
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);
},
};
import { createApp } from 'vue';
import App from './App.vue';
import circlePushPlugin from './plugins/circlePush';
createApp(App).use(circlePushPlugin).mount('#app');
Identify after auth:
import CirclePush from '@circle/push-web';
async function onUserLoggedIn(user: User) {
await CirclePush.identify({ email: user.email });
}
Angular 17+
import { ApplicationConfig, provideAppInitializer } from '@angular/core';
import CirclePush from '@circle/push-web';
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(() =>
CirclePush.init({
apiKey: 'cir_live_xxx',
})
),
],
};
Identify after authentication (e.g., in an AuthService):
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
import CirclePush from '@circle/push-web';
import { browser } from '$app/environment';
export async function initCirclePush() {
if (!browser) return;
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/push-web@1.0.0/dist/circle-push.umd.js"></script>
jsDelivr:
<script src="https://cdn.jsdelivr.net/npm/@circlehq/push-web@1.0.0/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>
<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>
<script src="https://unpkg.com/@circlehq/push-web@1.0.0/dist/circle-push.umd.js"></script>
</head>
<body>
<script>
(async () => {
await CirclePush.init({ apiKey: 'cir_live_xxx' });
await CirclePush.identify({ email: 'user@example.com' });
CirclePush.on('notificationClicked', function(payload) {
if (payload.link) window.location.href = payload.link;
});
})();
</script>
</body>
</html>
Service worker: Download firebase-messaging-sw.js from the same CDN and host it at your site root (it must be same-origin):
https://unpkg.com/@circlehq/push-web@1.0.0/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);
});
How link navigation works automatically:
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.link field
data.deepLink in the notification payload
data.link in 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,
});
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();
await signOut(auth);
}
Debugging
Enable verbose logging during development:
await CirclePush.init({ , debug: true });
CirclePush.setDebug(true);
Emails, phone numbers, and push tokens are always redacted in logs, even with debug: true.
Browser support
| 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.permission in 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_context on 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 from localStorage.
License
MIT