Developer Setup

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.

1

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.js
1a

Get the file

Two ways to get it — pick one, both register the same way.

Option A — Static file (recommended)

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.js
Option B — Auto-updating stub

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

Download stub
test reachability from your network
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.

or fetch with curl — saves to the right place for Next.js / Vite
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
1b

Place it for your stack

Find the folder that maps to your site's root URL (/), and add a campaign-push/ subfolder inside it.

Next.jspublic/campaign-push/campaign-sw.js

Everything under public/ is served at site root automatically — no config needed.

Vite (React or Vue)public/campaign-push/campaign-sw.js

Same convention as Next.js — Vite copies public/ straight to the build output root.

Vue CLI (non-Vite)public/campaign-push/campaign-sw.js

Same public/ convention as Vite.

Angular CLIsrc/campaign-push/campaign-sw.js

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

Static HTML / Apache / Nginx<web-root>/campaign-push/campaign-sw.js

Whatever folder the server points at as its document root — e.g. public_html/campaign-push/….

WordPress / CMS-hosted site<docroot>/campaign-push/campaign-sw.js

The server's document root (via FTP/hosting file manager) — not the media library or theme uploads folder.

ANGULAR

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/:

angular.json
{
  "glob": "**/*",
  "input": "src/campaign-push",
  "output": "campaign-push"
}
1c

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 eventStatus reportedWhen
pushdeliveredNotification reached the device and was displayed.
notificationclickclickedVisitor tapped or clicked the notification. The target URL is then opened (or focused if already open).
notificationclosediscardedNotification was dismissed without clicking.
BEST EFFORT

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.

1d

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-sw.js · v1.2.0↓ download
/**
 * 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()));
1e

Verify

  1. 1Deploy the site as usual — no separate step for this file.
  2. 2Open https://yoursite.com/campaign-push/campaign-sw.js directly in a browser tab.
  3. 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.
DEFAULT

This exact path is already the SDK's default — once the file is reachable there, web push registration needs no extra configuration.

2

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.

index.html — before </head>
<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>
REPLACE

cpk_... with your write key — this is the one value that's specific to your site.

2a

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.

2b

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:

anywhere on the page
campaign('identify', 'user_123', { email: '[email protected]' });
AttributePurpose
data-write-keyRequired — identifies your site.
data-captureControls 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-cookieAuto-identify a returning logged-in visitor from a cookie your login flow already sets.
data-webpush-prompt-delayMs before the web push "soft ask" prompt auto-shows (default 5000). Auto-show follows data-capture — see below.
data-webpush-prompt-audienceall (default) or logged-in — who the push prompt is eligible to show to. See below.
2d

Web push prompt audience

data-webpush-prompt-audience controls who the soft-ask prompt is eligible to show to.

ValueBehavior
all (default)Shown to every eligible visitor, logged in or not.
logged-inOnly shown once campaign('identify', ...) has run for that visitor.
index.html — before </head>
<script async
  src="https://config.axilrate.com/sdk/campaign.min.js"
  data-write-key="cpk_..."
  data-webpush-prompt-audience="logged-in"
></script>
NOTE

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.

2c

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.

ValueBehavior
on (default)Anonymous visitors are captured from first page load — same as most analytics tools.
offFull 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-onlyNothing 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.
index.html — before </head>
<script async
  src="https://config.axilrate.com/sdk/campaign.min.js"
  data-write-key="cpk_..."
  data-capture="logged-in-only"
></script>
DEFAULT

Leaving data-capture off the tag entirely is the same as data-capture="on" — today's behavior, unchanged.

Campaign SDK — Site Installationcampaign-sw.js · campaign.min.jsFor any clarifications reach us at [email protected] with subject developer query