Web
Install, initialize, and verify your first event on Web.
Modern browsers (Chrome 90+ · Firefox 90+ · Safari 14+ · Edge 90+) · TypeScript-first · Tree-shakeable ESM + CJS bundles · Drop-in <script> tag · Zero runtime dependencies
Install
Option A — npm (bundled apps)
yarn add @loyalytics/swan-web-sdk
# or
npm install @loyalytics/swan-web-sdkimport { Swan, SwanEvents } from '@loyalytics/swan-web-sdk';ESM and CJS entry points both ship; modern bundlers (Vite, webpack, Rollup, esbuild, Next.js) pick the right one automatically.
Option B — <script> tag (no build step)
Drop the SDK onto any HTML page. Swan and SwanEvents become available on window:
<script src="https://cdn.swan.cx/web-sdk/v1.1.1/swan.min.js"></script>
<script>
Swan.initialize('your-app-id', { production: true });
Swan.identify('user-123', { email: 'jane@example.com' });
SwanEvents.productViewed({ sku: 'ABC-100', price: 49.95 });
</script>Each version path on cdn.swan.cx is immutable and served with a long-lived cache header (max-age=31536000, immutable), so production traffic gets a single hot-cache entry per release. Pin to an exact version — bumping the SDK is a one-line edit of the version segment in the URL.
If your CSP doesn't allow cdn.swan.cx or you'd rather keep the SDK fully inside your origin, download dist/swan.min.js from the npm package and serve it yourself:
<script src="/assets/swan-web-sdk-1.1.1.min.js"></script>Initialize
The minimal initialize call:
import { Swan } from '@loyalytics/swan-web-sdk';
Swan.initialize('your-app-id', { production: true });
Swan.addInitializedListener(() => {
console.log('Swan ready:', Swan.getSwanIdentifier());
});initialize returns synchronously. Subscribe to addInitializedListener to be notified when device registration completes in the background.
Configuration options
Swan.initialize(appId, config) accepts a typed SwanConfig object. All fields are optional with sensible defaults.
Swan.initialize('your-app-id', {
// ─── Environment ────────────────────────────────────────────────
production: true, // Default: true. Set to false for dev/staging.
debug: false, // Default: false. Set to true for verbose SDK logs.
// ─── Web Push subsystem gate ────────────────────────────────────
pushNotifications: {
enabled: true, // Default: true (opt-out). Set to false to
// disable the Web Push subsystem entirely —
// `Swan.subscribeToPush()` returns false
// immediately without registering the SW
// or prompting.
},
// ─── Location-tagging gate ──────────────────────────────────────
location: {
enabled: false, // Default: false (opt-in). Set to true if
// your host app will call
// `Swan.updateLocation(lat, lng)` with
// coordinates. `Swan.isLocationEnabled()`
// reflects this flag.
},
// ─── Advanced / escape hatches ──────────────────────────────────
// Tenant VAPID public key — normally NOT needed. The SDK reads it
// from the device-register response. Pass this only if your backend
// hasn't yet served the field, or for test rigs.
vapidPublicKey: undefined,
// Service Worker path — defaults to '/swan-service-worker.js'.
// Override only if you host the SW at a different path.
serviceWorkerUrl: '/swan-service-worker.js',
});The shape mirrors the iOS and Android SDKs' SwanConfig blocks so cross-platform integrators see the same surface.
Identify + track
Same wire shape as the other platforms:
Swan.identify('user-123', { email: 'jane@example.com' });
Swan.track('productViewed', { sku: 'ABC-100', price: 49.95 });
SwanEvents.productViewed({ sku: 'ABC-100', price: 49.95 });Push setup (Web Push)
The Web SDK uses the standard Web Push API. The tenant VAPID public key is served by the Swan backend on device-register — your host app does not need to configure it.
Serving the Service Worker
The SDK ships a bundled Service Worker that the SDK registers on subscribeToPush(). You need to serve it at /swan-service-worker.js (or a custom path you pass via SwanConfig.serviceWorkerUrl). Pick the recipe that matches your build:
Next.js / static-asset frameworks — copy the bundled SW into your public/ directory at build time. Add this to package.json scripts:
{
"scripts": {
"prebuild": "cp node_modules/@loyalytics/swan-web-sdk/dist/sw.js public/swan-service-worker.js"
}
}Vite — same recipe, into public/ so Vite serves it at the root.
webpack — use CopyWebpackPlugin to emit dist/sw.js to your output directory, or run the cp step in a pre-build hook.
Plain HTML / no build step — copy the file once and check it into your static assets:
cp node_modules/@loyalytics/swan-web-sdk/dist/sw.js public/swan-service-worker.jsThe Service Worker MUST be served from the root of your origin (or scoped via the Service-Worker-Allowed response header) so it can intercept push events for the whole site.
Subscribe on user interaction
const subscribed = await Swan.subscribeToPush();
// → true on success; false if permission denied / browser doesn't support pushHandle taps in your host page:
Swan.addNotificationOpenedListener(({ route, keyValuePairs }) => {
if (route) router.push(route);
});Subscription idempotency contract
Swan.subscribeToPush() is idempotent and safe to call on every page load, but doing so is wasteful — the SDK re-POSTs the same subscription to the backend each time. The recommended pattern is to gate on isPushSubscribed():
if (await Swan.isPushSubscribed()) {
// User already opted in. Backend has the subscription from the
// original subscribeToPush() call.
} else {
// Show "Enable notifications" CTA. Call Swan.subscribeToPush()
// on user click — that's when the browser shows the permission prompt.
}If you have reason to believe the backend's stored copy of the subscription is stale (e.g., a backend restore from snapshot) even though the browser still has a live subscription, call Swan.resyncSubscription(). It re-POSTs the current subscription without re-prompting or re-subscribing. Returns true on success, false if no subscription exists or the POST failed.
Per-browser web-push coverage
| Browser | Web Push subscribe | Web Push delivery | Notes |
|---|---|---|---|
| Chrome 90+ (desktop + Android) | ✅ | ✅ | FCM-backed |
| Edge 90+ | ✅ | ✅ | Same pipeline as Chrome |
| Firefox 90+ (desktop + Android) | ✅ | ✅ | Mozilla autopush |
| Safari macOS 13+ | ✅ | ✅ | APNs-based Web Push |
| iOS / iPadOS Safari 16.4+ (PWA only) | ⚠️ | ⚠️ | Only when the site is installed as a PWA (Add to Home Screen). Standalone Safari tabs cannot receive push |
| iOS / iPadOS Safari < 16.4 | ❌ | ❌ | Not supported. Swan.subscribeToPush() returns false |
| Samsung Internet 14+ | ✅ | ✅ | Chromium-based |
| In-app WebViews (Instagram / Facebook / TikTok) | ❌ | ❌ | Push not supported by spec |
Android WebViews (any embedded WebView component) | ❌ | ❌ | Push not supported. Use the native Android SDK in the host app instead |
Environment requirements
- HTTPS required. Web Push only works on origins served over HTTPS.
http://localhostis treated as secure by browsers for development, but every other host MUST use TLS orSwan.subscribeToPush()will fail. - Incognito / Private Browsing is unsupported.
pushManager.subscribe()either throws or returns a subscription the gateway immediately invalidates depending on the browser. The SDK degrades gracefully —Swan.subscribeToPush()returnsfalse— but don't expect Web Push to function in Private Browsing. - Service Workers required. Web Push depends on a Service Worker registered on the host origin. See Serving the Service Worker above.
Notification rendering features
Web Push notifications support a subset of the Web Notifications API per browser. The SDK passes through whatever fields the backend sends; rendering is the browser's responsibility.
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| Title + body | ✅ | ✅ | ✅ | ✅ |
| Icon | ✅ | ✅ | ✅ | ❌ |
| Banner image | ✅ | ✅ | ❌ | ❌ |
| Click → deep link | ✅ | ✅ | ✅ | ✅ |
| Action buttons (CTAs) | ⚠️ desktop only | ⚠️ desktop only | ❌ | ❌ |
requireInteraction flag | ✅ desktop | ✅ desktop | ✅ desktop only | ❌ |
| Sound override | ❌ | ❌ | ❌ | ❌ |
| Carousel (swipeable images) | ❌ | ❌ | ❌ | ❌ |
Fields the browser doesn't support are silently ignored — the notification still renders, just without that feature. If you need richer rendering on a specific browser, host apps can intercept via the bundled Service Worker before display.