
Security News
The Code You Didn't Write Is Still Yours to Defend
AI agents are pulling packages into environments no scanner is watching, creating exposure before security teams can see it.
@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 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.
npm install @circle/push-web
# or
yarn add @circle/push-web
# or
pnpm add @circle/push-web
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.
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', // 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: '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 BYOC, uploaded once on the Circle dashboard and fetched by the SDK at runtime — 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)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
--ignore-scripts users)npx circle-push install-sw # auto-detects framework
npx circle-push install-sw --dest public # explicit destination
npx circle-push --help
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": "/" }
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/',
});
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. 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',
});
// 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 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):
fcmOptions.link fielddata.deepLink in the notification payloaddata.link in the notification payloadBy 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 consoleconfig/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.
init() time over HTTPS — they never live in this bundle or your app's source code.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.
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
AI agents are pulling packages into environments no scanner is watching, creating exposure before security teams can see it.

Security News
GitHub Actions checkout now blocks risky pull_request_target checkouts by default to help prevent pwn request supply chain attacks.

Product
Socket now supports Custom Roles and Repository Access Permissions so organizations can control who can access specific repositories and actions.