Install Campaign on your site
Two steps, in order: add one static file for web push, then load the SDK itself. Neither requires a build step — this works on any site, any tech stack.
Add the push service worker
One file, one rule, regardless of tech stack: it must be reachable at your own site's origin, at a dedicated subdirectory — never our CDN/npm origin (browsers block cross-origin service workers), and never forced to site root, so it never conflicts with anything else you run there.
https://yoursite.com/campaign-push/campaign-sw.jsGet the file
Two ways to get it — pick one, both register the same way.
Self-contained, no edits needed, and it never changes going forward. You own re-copying it if we ever ship an update.
Download campaign-sw.jsA one-line file that pulls the current logic from our CDN at runtime, so future updates reach you without re-copying anything. If config.axilrate.com is not reachable from your network, check with your system administrator for any firewall rules.
curl -sI https://config.axilrate.com/sdk/campaign-sw.js
See HTTP/2 200 as the first line? You're good — go with the stub. Anything else (timeout, connection error, a non-200 status) means it's blocked somewhere on the way out; use Option A instead, or take that output to your system administrator.
curl -o public/campaign-push/campaign-sw.js --create-dirs \ https://axilrate.com/developers/campaign-sw.js # or, for the stub: curl -o public/campaign-push/campaign-sw.js --create-dirs \ https://axilrate.com/developers/campaign-sw-stub.js
Place it for your stack
Find the folder that maps to your site's root URL (/), and add a campaign-push/ subfolder inside it.
Everything under public/ is served at site root automatically — no config needed.
Same convention as Next.js — Vite copies public/ straight to the build output root.
Same public/ convention as Vite.
Angular's src/assets/ is served at /assets/…, not root — dropping it there won't work. Add an explicit entry to angular.json instead (below).
Whatever folder the server points at as its document root — e.g. public_html/campaign-push/….
The server's document root (via FTP/hosting file manager) — not the media library or theme uploads folder.
Add this to angular.json's "assets" array so the build places the file at the real root path instead of nesting it under /assets/:
{
"glob": "**/*",
"input": "src/campaign-push",
"output": "campaign-push"
}What the service worker does
Beyond displaying the notification, the file reports three delivery events back to your ingest endpoint — so message status in Campaign automatically reflects what actually happened on the device, not just what was handed off to the push service.
| Browser event | Status reported | When |
|---|---|---|
| push | delivered | Notification reached the device and was displayed. |
| notificationclick | clicked | Visitor tapped or clicked the notification. The target URL is then opened (or focused if already open). |
| notificationclose | discarded | Notification was dismissed without clicking. |
Tracking calls are fire-and-forget — a blocked or slow network call never delays the notification display or the click navigation. The campaign_id is read from the push payload's data.tracking block; if that's absent it falls back to a ?campaignid=… query param on the notification URL.
The file also handles subscription rotation automatically: if the browser refreshes the push subscription in the background, the service worker re-subscribes and notifies any open page tabs via a CAMPAIGN_SUBSCRIPTION_CHANGED message so the SDK can re-register without the visitor doing anything.
Full source — v1.2.0
The file is self-contained and has no dependencies. You can audit the exact code that runs in your visitors' browsers before placing it.
/**
* Campaign Web Push Service Worker
* Version: 1.2.0
*/
'use strict';
function parseCampaignIdFromUrl(url) {
try {
return new URL(url, self.location.origin).searchParams.get('campaignid') || undefined;
} catch { return undefined; }
}
function reportWebPushStatus(url, tracking, eventName) {
if (!tracking || !tracking.ingest_url || !tracking.write_key) return Promise.resolve();
const campaignId = tracking.campaign_id || parseCampaignIdFromUrl(url);
if (!campaignId || !tracking.anonymous_id) return Promise.resolve();
return fetch(tracking.ingest_url.replace(/\/$/, '') + '/v1/webpush/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-write-key': tracking.write_key },
body: JSON.stringify({
client_id: tracking.client_id,
campaign_id: campaignId,
campaign_schedule_sequence_id: tracking.campaign_schedule_sequence_id,
end_user_sequence_id: tracking.end_user_sequence_id,
anonymous_id: tracking.anonymous_id,
event: eventName,
}),
}).catch(() => {});
}
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload;
try { payload = event.data.json(); }
catch { payload = { title: event.data.text(), body: '' }; }
const url = payload.data?.url || '/';
const options = {
body: payload.body || '',
icon: payload.icon || '/icons/icon-192.png',
badge: payload.badge || '/icons/badge-72.png',
data: { url, tracking: payload.data?.tracking || null },
vibrate: [200, 100, 200],
requireInteraction: false,
};
event.waitUntil(Promise.all([
self.registration.showNotification(payload.title || 'Notification', options),
reportWebPushStatus(url, payload.data?.tracking, 'delivered'),
]));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
const tracking = event.notification.data?.tracking;
event.waitUntil(Promise.all([
reportWebPushStatus(url, tracking, 'clicked'),
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => {
for (const w of wins) {
if (w.url === url && 'focus' in w) return w.focus();
}
if (clients.openWindow) return clients.openWindow(url);
}),
]));
});
self.addEventListener('notificationclose', (event) => {
const url = event.notification.data?.url || '/';
const tracking = event.notification.data?.tracking;
event.waitUntil(reportWebPushStatus(url, tracking, 'discarded'));
});
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: event.oldSubscription?.options?.applicationServerKey,
}).then((sub) =>
clients.matchAll({ type: 'window' }).then((wins) => {
for (const w of wins)
w.postMessage({ type: 'CAMPAIGN_SUBSCRIPTION_CHANGED', subscription: sub.toJSON() });
})
)
);
});
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(clients.claim()));Verify
- 1Deploy the site as usual — no separate step for this file.
- 2Open
https://yoursite.com/campaign-push/campaign-sw.jsdirectly in a browser tab. - 3You should see the raw JavaScript source. A 404, or your site's normal HTML page, means it landed in the wrong folder for that stack — recheck the cards above.
This exact path is already the SDK's default — once the file is reachable there, web push registration needs no extra configuration.
Load the SDK
No build step, no npm install — paste this in <head>, before </head>. Safe to publish publicly: nothing in it is a secret, the same way a Google Analytics or Segment snippet isn't — it's designed to sit in your page's own source once added.
<script> (function (w, d, s) { var q = []; function stub() { q.push(arguments); } stub.q = q; w.campaign = w.campaign || stub; })(window, document, 'script'); </script> <script async src="https://config.axilrate.com/sdk/campaign.min.js" data-write-key="cpk_..." ></script>
cpk_... with your write key — this is the one value that's specific to your site.
What each piece does
The first <script> block is a tiny synchronous stub that queues any campaign(...) calls made before the real script finishes loading, so nothing is lost if your own code calls it higher up the page. The second <script> loads the SDK itself, async so it never blocks page render — data-write-key is how it identifies which site it belongs to.
What happens automatically
Page views and clicks are captured immediately — no further code needed for basic analytics. Everything else is opt-in, either as more data-* attributes on the same <script> tag, or campaign(...) calls elsewhere on the page:
campaign('identify', 'user_123', { email: '[email protected]' });
| Attribute | Purpose |
|---|---|
| data-write-key | Required — identifies your site. |
| data-capture | Controls data capture — on (default), off, or logged-in-only. See below. |
| data-support-widget="false" | Disable the auto-mounted chat widget. Independent of data-capture — see below. |
| data-user-id-cookie | Auto-identify a returning logged-in visitor from a cookie your login flow already sets. |
| data-webpush-prompt-delay | Ms before the web push "soft ask" prompt auto-shows (default 5000). Auto-show follows data-capture — see below. |
| data-webpush-prompt-audience | all (default) or logged-in — who the push prompt is eligible to show to. See below. |
Web push prompt audience
data-webpush-prompt-audience controls who the soft-ask prompt is eligible to show to.
| Value | Behavior |
|---|---|
| all (default) | Shown to every eligible visitor, logged in or not. |
| logged-in | Only shown once campaign('identify', ...) has run for that visitor. |
<script async src="https://config.axilrate.com/sdk/campaign.min.js" data-write-key="cpk_..." data-webpush-prompt-audience="logged-in" ></script>
Either way, clicking Allow before identify() has run won't complete registration — the backend needs a user id for push registration. This flag only changes who sees the prompt, not whether registration itself works.
Controlling data capture
data-capture is a switch you set once in this same script tag — not a remote toggle in a backend or portal. It controls data capture only: track()/identify() calls, auto-captured page views and clicks, EndUser_Sequence_Id resolution, and whether the web push soft-ask prompt is ever eligible to auto-show. It does not touch the Customer Support chat widget — that widget has its own independent switch, data-support-widget, and keeps working (or not) exactly as configured regardless of data-capture.
| Value | Behavior |
|---|---|
| on (default) | Anonymous visitors are captured from first page load — same as most analytics tools. |
| off | Full kill switch: track(), identify(), and identity resolution all no-op. The push prompt never becomes eligible (it requires a known user, which off never sets). |
| logged-in-only | Nothing is captured — no anonymous page views, no identity resolution, no push prompt — until campaign('identify', ...) runs for that visitor. identify() itself is never gated; it's the only thing that unlocks the rest. Suppressed activity isn't queued and sent later — capture starts from the point of identification forward. |
<script async src="https://config.axilrate.com/sdk/campaign.min.js" data-write-key="cpk_..." data-capture="logged-in-only" ></script>
Leaving data-capture off the tag entirely is the same as data-capture="on" — today's behavior, unchanged.