Developer Setup

Install Campaign on your site

Load the SDK, and — only if you use web push — add one static file. Neither requires a build step; this works on any site, any tech stack. Tracking and the chat widget need nothing but the script tag.

1

Add the push service worker (only if you use web push)

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
SKIP?

This file is needed only for web push notifications. If you just want tracking and/or the chat widget, skip this step and go straight to step 2. The SDK can't supply this file for you — browsers only allow a service worker to be registered from your own origin — so if push is on and the file is missing, registration fails after the visitor has already clicked Allow.

1a

Get the file

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

Option A — Static file (recommended)v1.8.0

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 does three things automatically — delivery tracking, a recoverable notification inbox, and subscription rotation.

Browser eventStatus reportedInbox actionWhen
pushdeliveredRow written to IndexedDB inboxNotification reached the device and was displayed. Stored in the in-product inbox so visitors who miss the OS toast can find it later.
notificationclickclickedRow deleted from inboxVisitor tapped or clicked the notification — acted on immediately, so nothing left to surface in the inbox. Target URL is opened (or focused if already open).
notificationclosediscardedRow kept in inboxNotification was dismissed without clicking — deliberately kept in the inbox so the visitor can still act on it later. Auto-pruned after 7 days.
INBOX

The inbox database is named campaign_notification_inbox (IndexedDB, on your origin). The SDK's notification widget reads from the same database — no server round-trip needed. Campaigns can opt a specific send out of inbox storage by setting data.store_in_inbox: false in the push payload.

BEST EFFORT

Tracking calls and inbox writes are fire-and-forget — a blocked or slow network call never delays the notification display or click navigation. The campaign_id is read from the push payload's data.tracking block; if 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.8.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.8.0↓ download
/**
 * Campaign Web Push Service Worker — v1.3.0
 */
'use strict';

// ── Notification inbox (IndexedDB) ─────────────────────────────────────────
const INBOX_DB_NAME    = 'campaign_notification_inbox';
const INBOX_DB_VERSION = 1;
const INBOX_STORE_NAME = 'notifications';
const INBOX_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;

// Not a real notification — a sentinel row in the SAME store (same keyPath,
// no version bump needed) so the page can tell "has a genuinely new SW
// version activated since I last checked" without racing a live postMessage
// against page load. activate only fires once per real content update
// (skipWaiting()+clients.claim() below run there, not on every routine idle
// restart of an already-active worker), so this timestamp changing IS "the
// SW was updated." The page-side reader (notificationInbox.ts) filters this
// id out of listInboxNotifications() and reads it separately — see
// getServiceWorkerActivationTs()/webpush.ts's syncWebPushSubscription().
const SW_ACTIVATION_MARKER_ID = '__sw_activation__';

function openInboxDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(INBOX_DB_NAME, INBOX_DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(INBOX_STORE_NAME)) {
        const store = db.createObjectStore(INBOX_STORE_NAME, { keyPath: 'id' });
        store.createIndex('timestamp', 'timestamp');
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror  = () => reject(req.error);
  });
}

function saveInboxNotification(record) {
  return openInboxDb().then((db) =>
    new Promise((resolve) => {
      const tx = db.transaction(INBOX_STORE_NAME, 'readwrite');
      tx.objectStore(INBOX_STORE_NAME).put(record);
      tx.oncomplete = () => resolve();
      tx.onerror    = () => resolve();
    })
  ).catch(() => {});
}

function deleteInboxNotification(id) {
  if (!id) return Promise.resolve();
  return openInboxDb().then((db) =>
    new Promise((resolve) => {
      const tx = db.transaction(INBOX_STORE_NAME, 'readwrite');
      tx.objectStore(INBOX_STORE_NAME).delete(id);
      tx.oncomplete = () => resolve();
      tx.onerror    = () => resolve();
    })
  ).catch(() => {});
}

function pruneOldInboxNotifications() {
  const cutoff = Date.now() - INBOX_MAX_AGE_MS;
  return openInboxDb().then((db) =>
    new Promise((resolve) => {
      const tx        = db.transaction(INBOX_STORE_NAME, 'readwrite');
      const cursorReq = tx.objectStore(INBOX_STORE_NAME)
                          .index('timestamp')
                          .openCursor(IDBKeyRange.upperBound(cutoff));
      cursorReq.onsuccess = () => {
        const cursor = cursorReq.result;
        if (cursor) { cursor.delete(); cursor.continue(); }
      };
      tx.oncomplete = () => resolve();
      tx.onerror    = () => resolve();
    })
  ).catch(() => {});
}

function broadcastInboxChanged() {
  try {
    new BroadcastChannel('campaign_notification_inbox')
      .postMessage({ type: 'CAMPAIGN_INBOX_CHANGED' });
  } catch { /* BroadcastChannel unsupported — widget won't live-update across tabs */ }
}

// ── Delivery tracking ──────────────────────────────────────────────────────
function parseCampaignIdFromUrl(url) {
  try {
    return new URL(url, self.location.origin).searchParams.get('campaignid') || undefined;
  } catch { return undefined; }
}

// buttonInfo (optional): { action, title } for the specific action button that
// was clicked, when this report is for one — see notificationclick below.
function reportWebPushStatus(url, tracking, eventName, buttonInfo) {
  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,
      ...(buttonInfo ? { button_action: buttonInfo.action, button_title: buttonInfo.title } : {}),
    }),
  }).catch(() => {});
}

// ── Push received ──────────────────────────────────────────────────────────
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 title          = payload.title  || 'Notification';
  const body           = payload.body   || '';
  const tracking       = payload.data?.tracking || null;
  const storeInInbox   = payload.data?.store_in_inbox !== false;
  const notificationId = `${tracking?.campaign_id || 'n'}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
  // Already sanitized/capped server-side (Campaign_Comms's channels/webpush/index.ts
  // sanitizeActions()) to {action, title, icon?, url?} — url is our own
  // extension (not part of the Notification API), read back in
  // notificationclick below via event.action, not passed to showNotification()
  // itself (the browser ignores unknown keys there, but keeping it out of
  // options.actions avoids relying on that).
  const actions = Array.isArray(payload.actions) ? payload.actions : [];

  const options = {
    body,
    icon:    payload.icon  || '/icons/icon-192.png',
    badge:   payload.badge || '/icons/badge-72.png',
    // Chromium-only (Chrome/Edge/Opera) — Firefox/Safari ignore this option
    // and render without an image regardless. See Campaign_Comms's
    // channels/webpush/index.ts, which sends this under the same `image` key.
    ...(payload.image ? { image: payload.image } : {}),
    // Same Chromium-only caveat as image — Firefox/Safari render the
    // notification with no buttons. Only action/title/icon matter to the
    // browser here; url (per-action destination) rides along in data
    // instead so notificationclick can look it up by event.action.
    ...(actions.length > 0 ? { actions: actions.map((a) => ({ action: a.action, title: a.title, icon: a.icon })) } : {}),
    data:    { url, tracking, notificationId, actions },
    vibrate: [200, 100, 200],
    requireInteraction: false,
  };

  event.waitUntil(Promise.all([
    self.registration.showNotification(title, options),
    reportWebPushStatus(url, tracking, 'delivered'),
    storeInInbox
      ? saveInboxNotification({ id: notificationId, title, body, url, image: payload.image, tracking, timestamp: Date.now() })
          .then(broadcastInboxChanged)
      : Promise.resolve(),
    pruneOldInboxNotifications().then(broadcastInboxChanged),
  ]));
});

// ── Notification click ─────────────────────────────────────────────────────
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  // event.action is the clicked button's action id, or '' when the
  // notification body itself was clicked (not a button). A button with its
  // own url navigates there; one without (or a body click) falls back to
  // the main target url.
  const clickedAction  = (event.notification.data?.actions || []).find((a) => a.action === event.action);
  const url            = clickedAction?.url || event.notification.data?.url || '/';
  const tracking       = event.notification.data?.tracking;
  const notificationId = event.notification.data?.notificationId;

  event.waitUntil(Promise.all([
    // clickedAction is undefined for a body click (event.action === '') —
    // reportWebPushStatus only sends button_action/button_title when it's set.
    reportWebPushStatus(url, tracking, 'clicked', clickedAction ? { action: clickedAction.action, title: clickedAction.title } : undefined),
    deleteInboxNotification(notificationId).then(broadcastInboxChanged),
    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);
    }),
  ]));
});

// ── Notification dismissed ─────────────────────────────────────────────────
self.addEventListener('notificationclose', (event) => {
  const url      = event.notification.data?.url || '/';
  const tracking = event.notification.data?.tracking;
  // Inbox row intentionally kept — dismissed toast stays recoverable for 7 days.
  event.waitUntil(reportWebPushStatus(url, tracking, 'discarded'));
});

// ── Subscription rotation ──────────────────────────────────────────────────
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() });
      })
    )
  );
});

// ── Install & activate ─────────────────────────────────────────────────────
self.addEventListener('install',  () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(
  Promise.all([
    clients.claim(),
    pruneOldInboxNotifications().then(broadcastInboxChanged),
    saveInboxNotification({ id: SW_ACTIVATION_MARKER_ID, timestamp: Date.now() }),
  ])
));
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_..."
  data-require-analytics-consent="true"
></script>
<script>
  // call this when your visitor accepts cookies (and with 'denied' if they later withdraw)
  campaign('consent', { analytics: { status: 'granted', method: 'checkbox' } });
</script>
REPLACE

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

CONSENT

As written, nothing is tracked until campaign('consent', …) is called — so visitors are only tracked after they accept your cookie banner. If your site has no cookie banner, call it on page load, or remove the data-require-analytics-consent attribute to track everyone immediately. Details in 2f.

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="true"Opt in to the chat widget (default: off). It also needs the server-side Customer Support flag to be on for your key. Independent of data-capture — see "Flags at a glance".
data-require-analytics-consent="true"Wait for an explicit campaign('consent', …) grant before tracking is sent. Included in the snippet above; the SDK itself defaults to off — see 2f.
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="off"Never auto-show the web push prompt, without affecting tracking. campaign('webpush:prompt') still shows it on demand. Omit it and the prompt auto-shows.
data-webpush-prompt-audienceall (default) or logged-in — who the push prompt is eligible to show to. See below.
2c

Flags at a glance

Every switch that changes what visitors see or what gets tracked. Where it's controlled matters: most are attributes on the script tag (changing one means editing your site), but the chat widget is switched from the server side with no change to your site.

FeatureControlled byDefaultTurn it offChange reaches visitors
Trackingdata-capture on the script tagOn — every visitor, from first page loaddata-capture="off" (also stops identify and push), or "logged-in-only"On your next deploy
Cookie consentdata-require-analytics-consent + campaign('consent', …)On in the snippet above (tracking waits for campaign('consent', …)). The SDK itself defaults to offRemove data-require-analytics-consent to track everyone immediately (see 2f)On your next deploy
Web push promptScript-tag attributes only — no server-side switchAuto-shows about 5 s after load to every visitor whose browser supports push and hasn't decideddata-webpush-prompt="off", or data-webpush-prompt-audience="logged-in"On your next deploy
Chat widgetBoth must be on: data-support-widget="true" on the script tag and the server-side Customer Support flag for your write keyOff — no widget unless your code opts in, whatever the server flag saysRemove data-support-widget="true" (your code), or turn the server flag off — either is enoughCode: your next deploy. Server flag: up to about 5 minutes (cached); an open page keeps its state until reload
NOTE

The server-side Customer Support flag can be on for other reasons — for example to run WhatsApp support — without a widget ever appearing on your site, because the widget also needs data-support-widget="true" in your code. When either is off the widget is never mounted; it isn't drawn and then hidden.

2d

Web push prompt: who sees it, and turning it off

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.

To keep the prompt from ever showing while tracking stays on, add data-webpush-prompt="off". data-capture="off" also hides it, but it stops tracking too. With the prompt off, you can still show it at a moment you choose — for example right after an enquiry is submitted — with campaign('webpush:prompt').

index.html — before </head>
<script async
  src="https://config.axilrate.com/sdk/campaign.min.js"
  data-write-key="cpk_..."
  data-webpush-prompt="off"
></script>
2e

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 needs data-support-widget="true" in your code and the server-side Customer Support flag, and behaves exactly the same 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.

2f

Waiting for cookie consent

The snippet in step 2 already waits for consent. (Left to itself, the SDK starts tracking as soon as it loads — the attribute is what adds the wait.) You have two ways to hold tracking until your visitor accepts: don't load the script at all until they accept (simplest — nothing is fetched or sent before then), or load it in consent-required mode and report their choice:

index.html
<script async
  src="https://config.axilrate.com/sdk/campaign.min.js"
  data-write-key="cpk_..."
  data-require-analytics-consent="true"
></script>
<script>
  // call when the visitor accepts (and again with 'denied' if they later withdraw)
  campaign('consent', { analytics: { status: 'granted', method: 'checkbox' } });
</script>
NOTE

The SDK records what you pass as an audit trail; it can't verify that the consent is legally valid — that stays with you as the data controller.

2g

CMS and XML-based templates

The snippet above is HTML. Template systems that require well-formed XML (Blogger, some page builders) need two small changes: write async='async' (a bare async isn't valid XML) and wrap the inline stub in //<![CDATA[ … //]]>. Nothing else changes.

Campaign SDK — Site Installationcampaign-sw.js v1.8.0 · campaign.min.js (CDN)For any clarifications reach us at [email protected] with subject developer query