🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@circlehq/push-web

Package Overview
Dependencies
Maintainers
2
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@circlehq/push-web

CircleHQ Push Web SDK — register browser push tokens, identify users, and report notification events.

latest
npmnpm
Version
1.2.0
Version published
Maintainers
2
Created
Source

@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.

npm

Table of contents

Prerequisites

You only need one value from your Circle dashboard:

CredentialWhere 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 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', // 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.

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                 # auto-detects framework
npx circle-push install-sw --dest public   # explicit destination
npx circle-push --help

Frameworks

The auto-installer detects your framework from package.json and copies the SW to the right folder:

FrameworkDetected viaDestination
Next.jsnextpublic/
Vite (React/Vue/Svelte)vitepublic/
Vue CLI / Nuxtvue, nuxtpublic/
Create React Appreact-scriptspublic/
Remix@remix-run/reactpublic/
SvelteKit@sveltejs/kitstatic/
Angular@angular/coresrc/ (plus a one-line angular.json paste)
anything elsepublic/

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).

OptionTypeRequiredDefaultDescription
apiKeystringCircle 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
apiBaseUrlstringhttps://api.circlehq.coOverride the Circle API base URL
serviceWorkerPathstring/firebase-messaging-sw.jsPath where the SW file is served
serviceWorkerScopestring/Service worker scope
debugbooleanfalseVerbose console logging (tokens/emails are redacted)
autoRequestPermissionbooleantrueAuto-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:

  • 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();
// 'granted' | 'denied' | 'default' | 'unsupported'

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') {
  // 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();
EventPayload typeWhen it fires
permissionChangePermissionStateBrowser permission changes
tokenRefresh{ oldToken: string | null, newToken: string }FCM token rotates
notificationReceivedNotificationPayloadNotification arrives while tab is foreground
notificationClicked{ messageId?, campaignId?, link? }User taps a notification
errorCirclePushErrorInternal 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/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.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);
  // 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:

ScenarioWhat happens
link URL is already open in a tabThat tab is focused
link URL is not openA new tab is opened to the URL
No link in the notificationNo 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, // 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

BrowserMinimum versionNotes
Chrome / Edge / Brave / Opera91+✅ Full support
Firefox90+✅ Full support
Safari (macOS)16.4+✅ Full support
Safari (iOS)16.4+⚠️ PWA only — user must "Add to Home Screen"
Samsung Internet14+✅ 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

Keywords

circle

FAQs

Package last updated on 17 Jun 2026

Did you know?

Socket

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.

Install

Related posts