
Security News
Risky Biz Podcast: AI Agents Are Raising the Stakes for Software Supply Chain Security
Open source attacks are accelerating as AI coding agents pull in dependencies faster, with less human review.
@circlehq/push-web
Advanced tools
CircleHQ Push Web SDK — register browser push tokens, identify users, and report notification events.
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.
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 owned by Circle and are baked into the SDK bundle at build time — you do not need to supply them.
Your site must be served over HTTPS (or localhost for development). Push notifications do not work on plain HTTP origins.
npm install @circle/push-web firebase
# or
yarn add @circle/push-web firebase
# or
pnpm add @circle/push-web firebase
firebaseis a peer dependency. The SDK uses Firebase Cloud Messaging (FCM) under the hood to deliver tokens.
Your app Circle Push SDK Circle backend
───────── ─────────────────── ───────────────
init() ──▶ 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.
Step 1 — Copy the service worker to your site's public root so it is served at /firebase-messaging-sw.js:
# npm / Vite / CRA
cp node_modules/@circle/push-web/dist/service-worker/firebase-messaging-sw.js public/
# Next.js
cp node_modules/@circle/push-web/dist/service-worker/firebase-messaging-sw.js public/
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', // your Circle External API key — the only required field
});
The VAPID key and Firebase config are already baked into the bundle by Circle.
Step 3 — Identify the user after they log in:
const device = await CirclePush.identify({
email: 'user@example.com', // 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.
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 owned by Circle and are embedded in the bundle — do not put them in consumer env files.
The service worker handles background notifications (received while the tab is not focused). It must be:
/firebase-messaging-sw.js)localhost)package.json scriptAdd a postinstall script so the file stays up-to-date after every install:
{
"scripts": {
"postinstall": "cp node_modules/@circle/push-web/dist/service-worker/firebase-messaging-sw.js public/"
}
}
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.
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 |
vapidKey | string | (baked in) | Override the VAPID public key | |
firebaseConfig | FirebaseWebConfig | (baked in) | Override the Firebase project config | |
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',
});
// device: { deviceId, contactId, pushToken, platform, isValid }
What this does internally:
autoRequestPermission: true and permission is default)POST /api/v1/push/devices/register with the token and identitydeviceId and token in localStorageRe-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: 'user@example.com' });
}
getPermissionState() → PermissionStateSynchronous, 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) → () => voidSubscribe 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);
versionRead-only string of the SDK version.
console.log(CirclePush.version); // e.g. "0.1.0"
// 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 });
}
// 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.
// 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 });
}
// 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();
}
}
// 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 />
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>
<!-- 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/push-web@1.0.0/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: '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.jsfrom 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
Subscribe to notificationClicked to deep-link users when they tap a notification:
CirclePush.on('notificationClicked', ({ link, campaignId, messageId }) => {
console.log('Campaign:', campaignId, '| Message:', messageId);
if (link) window.location.href = link;
});
This event fires for both foreground taps (tab open) and background taps (relayed from the service worker after the tab regains focus).
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);
}
};
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
}
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 | 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.
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';
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
localhost)Notification.permission in the browser consoleiOS 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.
config/insecure_context on plain HTTP (non-localhost).localStorage, scoped to your origin.unregister() wipes all SDK state from localStorage.MIT
FAQs
CircleHQ Push Web SDK — register browser push tokens, identify users, and report notification events.
The npm package @circlehq/push-web receives a total of 22 weekly downloads. As such, @circlehq/push-web popularity was classified as not popular.
We found that @circlehq/push-web demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 2 open source maintainers collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Open source attacks are accelerating as AI coding agents pull in dependencies faster, with less human review.

Research
/Security News
Malicious Chrome and Firefox extensions posed as free VPNs while stealing clipboard data through later extension updates.

Research
/Security News
Miasma Mini Shai-Hulud hits @immobiliarelabs Backstage plugins, targeting GitLab and LDAP auth packages on npm.