> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sagepilot.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Web Push integration

> Install, identify, subscribe, test and operate Sagepilot Web Push on an authenticated website.

This guide takes a new website integration from channel configuration to a real journey or campaign notification. The recommended flow keeps customer identity and private keys on your backend while the browser add-on owns the Web Push lifecycle.

<Note>
  Shopify Online Store themes use Sagepilot's Shopify app embed and Theme App Extension instead of installing this npm add-on directly. Follow [Shopify Web Push setup](/sdks/web/push-notifications-shopify). Use this guide for websites and custom/headless storefronts whose application code and public assets you control.
</Note>

## What you will build

```text theme={null}
Authenticated website backend
  └─ signs a five-minute customer identity proof
       └─ browser calls push.identify(proof)
            └─ customer clicks Enable notifications
                 └─ push.subscribe() registers the browser endpoint
                      └─ Sagepilot Journey/Campaign sends Web Push
                           └─ service worker displays and records engagement
```

The browser never receives the VAPID private key, the channel identity signing secret or Sagepilot send authority. It receives only the public VAPID key through the authenticated Push session.

## Prerequisites

* A Sagepilot workspace and an active Push Notifications channel.
* A stable customer ID from your website's authenticated session. Do not generate a new ID on every login or page load.
* An HTTPS website. Browser-recognized loopback hosts such as `localhost` can be used for local development.
* A backend route that can authenticate the current website customer and sign a short-lived proof.
* A supported browser. Check capability with `isWebPushSupported()` instead of relying on a hard-coded browser list.

If your site has a strict Content Security Policy, allow same-origin workers and connections to your Sagepilot host, normally `https://app.sagepilot.ai`.

If the same customer also uses your mobile app, use the same canonical customer identity for the app and website. Sagepilot stores the mobile FCM/APNs token and browser Web Push subscription as separate endpoints under that customer.

## 1. Configure the Sagepilot channel

In Sagepilot, create or edit a **Push Notifications** channel:

1. Under **General**, turn on **Web Push** and keep the channel active.
2. Under **Web Push**, add one VAPID public key, its matching private key and a contact email.
3. Under **SDK & delivery**, generate an **Identity signing secret**. Copy it now; the saved secret cannot be retrieved later.
4. Choose the anonymous-customer policy and notification image policy your product needs.
5. Click **Save Changes**.

You need these integration values:

```dotenv theme={null}
# Safe to expose in browser configuration. Apply your framework's public prefix.
PUBLIC_SAGEPILOT_PUSH_KEY=workspace_id:push_channel_id

# Backend only. Use the Push channel ID from the key above.
SAGEPILOT_PUSH_CHANNEL_ID=push_channel_id
SAGEPILOT_PUSH_IDENTITY_SECRET=copy_the_generated_identity_secret
```

Ask your Sagepilot workspace administrator for the `workspace_id:push_channel_id` integration key if your application team does not manage the channel.

<Warning>
  `SAGEPILOT_PUSH_IDENTITY_SECRET` and the VAPID private key are different secrets. Both stay server-side. Do not put either value in a browser-exposed variable such as `NEXT_PUBLIC_*` or `VITE_*`.
</Warning>

To generate a new VAPID pair when your organization does not already manage one, you can use the standard `web-push` utility:

```bash theme={null}
npx web-push generate-vapid-keys
```

Store the pair in your secret manager and enter it in the channel. The browser add-on obtains only the public key from Sagepilot.

## 2. Install the packages

Install the Push add-on. The [Website Widget installation](/sdks/web/installation) remains a separately loaded Sagepilot script when your site also uses chat.

```bash theme={null}
npm install @sagepilot-ai/web-push-addon
```

### Deploy the service worker

Copy the worker shipped by the add-on into your framework's public/static directory so it is served at this exact same-origin URL:

```text theme={null}
/sagepilot-push/sagepilot-push-worker.js
```

For Next.js, Vite or Create React App projects whose static directory is `public/`:

```bash theme={null}
mkdir -p public/sagepilot-push
cp node_modules/@sagepilot-ai/web-push-addon/sagepilot-push-worker.js \
  public/sagepilot-push/sagepilot-push-worker.js
```

Automate the copy in your build or `postinstall` script so package upgrades deploy the matching worker. Verify the production URL returns JavaScript without authentication or a redirect:

```bash theme={null}
curl -I https://www.example.com/sagepilot-push/sagepilot-push-worker.js
```

For a portable build step, save this as `scripts/copy-sagepilot-push-worker.mjs`:

```js theme={null}
import { copyFile, mkdir } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const require = createRequire(import.meta.url);
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..");

/** Copies the worker from the installed add-on into the public web root. */
async function copySagepilotPushWorker() {
  const packageJson = require.resolve(
    "@sagepilot-ai/web-push-addon/package.json",
  );
  const source = join(dirname(packageJson), "sagepilot-push-worker.js");
  const targetDirectory = join(projectRoot, "public", "sagepilot-push");

  await mkdir(targetDirectory, { recursive: true });
  await copyFile(
    source,
    join(targetDirectory, "sagepilot-push-worker.js"),
  );
}

await copySagepilotPushWorker();
```

Then run it after install and before every production build:

```json theme={null}
{
  "scripts": {
    "copy:sagepilot-push-worker": "node scripts/copy-sagepilot-push-worker.mjs",
    "postinstall": "npm run copy:sagepilot-push-worker"
  }
}
```

The add-on registers this worker automatically with the isolated `/sagepilot-push/` scope. It does not replace an existing root PWA service worker. Do not manually register the Sagepilot worker or change its filename or scope.

## 3. Generate a customer identity proof on your backend

<Card title="Next.js App Router example" icon="code" href="/sdks/web/push-notifications-nextjs">
  Use the complete Next.js example for authentication, the proof route, browser initialization, consent, logout and failed-revocation retry.
</Card>

Install a maintained JWT library in your server application. This example uses `jose`:

```bash theme={null}
npm install jose
```

Create an HS256 proof only after your normal authentication middleware has established the current customer:

```ts theme={null}
import { SignJWT } from "jose";

type AuthenticatedCustomer = {
  id: string;
  email?: string;
  emailVerified?: boolean;
};

/** Creates a short-lived Sagepilot Push proof for the authenticated customer. */
export async function createSagepilotPushProof(
  customer: AuthenticatedCustomer,
): Promise<string> {
  const channelId = process.env.SAGEPILOT_PUSH_CHANNEL_ID;
  const signingSecret = process.env.SAGEPILOT_PUSH_IDENTITY_SECRET;
  if (!channelId || !signingSecret) {
    throw new Error("Sagepilot Push identity is not configured");
  }

  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({
    channel_id: channelId,
    client_customer_id: customer.id,
    ...(customer.email && customer.emailVerified
      ? { email: customer.email }
      : {}),
  })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setAudience("sagepilot-brand-push")
    .setIssuedAt(now)
    .setExpirationTime(now + 300)
    .sign(new TextEncoder().encode(signingSecret));
}
```

Expose it through an authenticated, same-origin backend route:

```ts theme={null}
/** Returns a no-store Push proof for the customer in the host session. */
export async function pushProofHandler(request: Request): Promise<Response> {
  const customer = await requireAuthenticatedCustomer(request);
  const identityToken = await createSagepilotPushProof(customer);

  return Response.json(
    { identityToken },
    { headers: { "Cache-Control": "no-store" } },
  );
}
```

Adapt `requireAuthenticatedCustomer()` to your framework. The route must derive `customer.id` from the authenticated server session, not from an untrusted customer ID in the request body.

The proof contract is:

| Claim | Requirement |
| - | - |
| `aud` | Must be `sagepilot-brand-push`. |
| `channel_id` | Must match the Push channel used by the browser key. |
| `client_customer_id` | Stable customer ID from your system, up to 255 characters. Use this for cross-platform identity. |
| `email` / `phone` | Optional with `client_customer_id`. Include only values your backend has verified. |
| `iat` / `exp` | Integer Unix timestamps. The proof lifetime must not exceed five minutes. |

You may use an existing Sagepilot `customer_id` instead of `client_customer_id` when your backend already stores that UUID. Do not send both identities unless you intentionally validate that they refer to the same customer.

## 4. Identify the customer in the browser

Create the Push instance in browser code after your host application restores its authenticated session. For server-rendered frameworks, keep this code in a client-only module or component.

```ts theme={null}
import {
  isWebPushSupported,
  SagepilotWebPush,
  SagepilotWebPushError,
  type SagepilotWebPushRegistrationResult,
} from "@sagepilot-ai/web-push-addon";

const SAGEPILOT_PUSH_KEY = "workspace_id:push_channel_id";
const PUSH_REVOCATION_PENDING_KEY =
  `sagepilot:web-push:revocation-pending:${SAGEPILOT_PUSH_KEY}`;

let push: SagepilotWebPush | null = null;

/** Creates one consistently configured Web Push client. */
function createSagepilotPush(): SagepilotWebPush {
  return new SagepilotWebPush({
    key: SAGEPILOT_PUSH_KEY,
    onError: (error) => console.error("[Sagepilot Web Push]", error),
  });
}

/** Completes a server revocation that could not finish during an earlier sign-out. */
async function retryPendingSagepilotPushLogout(): Promise<void> {
  if (localStorage.getItem(PUSH_REVOCATION_PENDING_KEY) !== "true") return;

  const cleanupPush = createSagepilotPush();
  try {
    await cleanupPush.logout();
    localStorage.removeItem(PUSH_REVOCATION_PENDING_KEY);
  } finally {
    cleanupPush.destroy();
  }
}

/** Identifies the signed-in website customer without opening a permission prompt. */
export async function connectSagepilotPush(): Promise<SagepilotWebPushRegistrationResult> {
  if (!isWebPushSupported()) {
    throw new Error("Web Push is unavailable in this browser or context");
  }

  await retryPendingSagepilotPushLogout();
  push ??= createSagepilotPush();

  const response = await fetch("/api/sagepilot/push-proof", {
    method: "POST",
    credentials: "same-origin",
    headers: { Accept: "application/json" },
  });
  if (!response.ok) throw new Error("Unable to authorize Web Push");

  const { identityToken } = await response.json() as { identityToken: string };
  await push.identify(identityToken);
  return push.sync();
}

/** Returns the initialized Push instance after connectSagepilotPush(). */
export function getSagepilotPush(): SagepilotWebPush {
  if (!push) throw new Error("Sagepilot Web Push is not connected");
  return push;
}
```

Call `connectSagepilotPush()` after login and on a full page reload while the customer remains signed in. `identify()` never prompts for notification permission. It also repairs a previously granted subscription, completes browser subscription rotation and flushes retained observations.

`sync()` reports the current state:

| Status | Meaning |
| - | - |
| `registered` | Browser permission is granted and the endpoint is registered. `endpointId` contains its Sagepilot ID. |
| `permission_required` | The customer has not decided. Show your own enable-notifications UI. |
| `permission_denied` | Browser permission is blocked. Explain how to change site settings; the SDK cannot override it. |
| `not_subscribed` | Permission is granted, but the customer previously removed this subscription. Require another explicit subscribe action. |

## 5. Request permission from a clear customer action

Call `subscribe()` from the click handler of an explicit button. Do not call it during page load, login or chat initialization.

```ts theme={null}
const enableButton = document.querySelector<HTMLButtonElement>(
  "[data-enable-marketing-notifications]",
);

/** Requests browser permission and records the disclosed marketing consent. */
async function enableMarketingNotifications(): Promise<void> {
  const result = await getSagepilotPush().subscribe({
    marketingConsent: true,
  });

  if (result.status === "registered") {
    console.log("Sagepilot endpoint registered", result.endpointId);
    return;
  }

  if (result.status === "permission_denied") {
    showNotificationPermissionHelp();
  }
}

enableButton?.addEventListener("click", () => {
  void enableMarketingNotifications().catch((error: unknown) => {
    if (error instanceof SagepilotWebPushError) {
      console.error(error.code, error.status, error.message);
    }
  });
});
```

Use `{ marketingConsent: true }` only when the button copy clearly tells the customer that they are opting into marketing notifications. For support-only notifications, call `subscribe()` without that option and persist the appropriate support preference separately.

Browser permission and marketing consent are independent:

* Browser permission allows this origin to display notifications.
* Sagepilot preferences determine which notification categories the customer permits.
* A channel must also be active and the endpoint must be eligible for the send.

Read or update explicit preferences after identification:

```ts theme={null}
const preferences = await getSagepilotPush().getPreferences();
console.log(preferences.web);

// Enable support notifications without changing marketing consent.
await getSagepilotPush().setPreferences({
  web: {
    enabled: true,
    support_enabled: true,
  },
});
```

## 6. Use chat and Push together

Chat and Push remain separate even when your website uses both. Identify them from the same host-authenticated customer:

```js theme={null}
/** Connects Sagepilot chat and Push for the current host customer. */
export async function connectSagepilot(customer) {
  await window.sagepilotWidgetReady;
  const chatIdentity = await window.ChatWidget.identify({
    user_id: customer.id,
    email: customer.email,
    name: customer.name,
    // Required when Website Widget identity verification is enabled.
    user_hash: customer.sagepilotUserHash,
  });
  if (!chatIdentity.success) {
    throw new Error(chatIdentity.error || "Sagepilot chat identity failed");
  }

  await connectSagepilotPush();
}
```

The chat channel and Push channel use different keys. The Push proof's `client_customer_id` should equal the stable `user_id` used for chat. Generate `sagepilotUserHash` on your server with the Website Widget identity secret and return it with the authenticated customer. Never expose that secret or generate the hash in browser code. If Website Widget identity verification is disabled, omit `user_hash`. The Widget hash and Push proof are separate credentials; neither replaces the other.

## 7. Handle logout, opt-out and teardown

Use the method that matches the customer's action:

```js theme={null}
/** Removes the local browser subscription while server revocation is pending. */
async function removeLocalSagepilotPushSubscription() {
  const registration = await navigator.serviceWorker.getRegistration(
    "/sagepilot-push/",
  );
  const expectedScope = new URL("/sagepilot-push/", window.location.origin).href;
  if (registration?.scope !== expectedScope) return;

  const subscription = await registration.pushManager.getSubscription();
  if (subscription && !(await subscription.unsubscribe())) {
    throw new Error("Browser refused to remove the Web Push subscription");
  }
}

/** Revokes this browser before the host customer session is cleared. */
export async function logoutFromWebsite() {
  const currentPush = push ?? (
    isWebPushSupported() ? createSagepilotPush() : null
  );

  try {
    if (currentPush) {
      await currentPush.logout();
      localStorage.removeItem(PUSH_REVOCATION_PENDING_KEY);
    }
  } catch (error) {
    // Keep the add-on's durable binding so the next app load can retry the API revocation.
    localStorage.setItem(PUSH_REVOCATION_PENDING_KEY, "true");
    console.error("Sagepilot Web Push revocation failed", error);

    try {
      // Stop this signed-out browser receiving Push while server cleanup is pending.
      await removeLocalSagepilotPushSubscription();
    } catch (localError) {
      console.error("Local Web Push removal failed", localError);
    }
  } finally {
    currentPush?.destroy();
    push = null;

    try {
      window.ChatWidget?.logout?.();
    } finally {
      await clearHostApplicationSession();
    }
  }
}
```

| Method | Use it when | Effect |
| - | - | - |
| `unsubscribe()` | The customer opts out of Web Push. | Disables the customer's web preference, revokes this browser endpoint and prevents automatic repair. |
| `logout()` | The signed-in customer logs out or the browser changes account. | Revokes this browser endpoint and clears the in-memory Push session without changing the saved category preference. |
| `destroy()` | Your SPA permanently disposes the instance. | Removes listeners and in-memory state only. It does not revoke an endpoint. |

Attempt and await `logout()` before clearing the host session, but never abandon the website's sign-out flow if Push was not connected or the Sagepilot API is temporarily unavailable. A failed `logout()` retains the add-on's delete-only binding. The fallback above removes the local browser subscription immediately, and `retryPendingSagepilotPushLogout()` retries server revocation before another customer can be identified. Report both failures to your monitoring. After a successful Push logout, the customer must explicitly subscribe again on the next login because logout removed the browser subscription.

## 8. Deep links, images and analytics

Use a same-origin HTTPS deep link that your website can load. The worker restricts notification navigation to the website's own origin. An external-origin destination falls back to the website origin.

When a notification is sent, the worker records signed observations automatically:

| Observation | Meaning |
| - | - |
| `received` | The service worker received the push payload. It does not prove visual exposure. |
| `presentation_requested` | The browser accepted `showNotification()`. It still does not prove a human saw it. |
| `presentation_failed` | The browser could not present the notification. |
| `opened` | The customer clicked the notification. |
| `destination_opened` | The same-origin destination page actually loaded and the add-on acknowledged it. |
| `destination_failed` | The worker could not focus or open the destination. |
| `action` / `dismissed` | The customer chose a notification action or explicitly dismissed it when the browser reports that callback. |

For `destination_opened`, instantiate and identify the Push add-on on the landing page. The worker places only an opaque click marker in the URL; the add-on removes it after capture. Signed event data never enters the URL.

The worker retains observations when no page is open. The next identified page uploads them automatically. During testing, you can wait for an immediate upload:

```ts theme={null}
await getSagepilotPush().flush();
```

Static notification images use the channel's image policy and a publicly accessible HTTPS image URL. If an image fails, notification text remains available.

## 9. Test end to end

<Steps>
  <Step title="Verify the worker asset">
    Open `/sagepilot-push/sagepilot-push-worker.js` on the deployed origin and confirm it returns `200` JavaScript without a login page or redirect.
  </Step>

  <Step title="Identify a real test customer">
    Sign in through your website. Confirm `connectSagepilotPush()` returns `permission_required`, `not_subscribed` or `registered` without opening a prompt.
  </Step>

  <Step title="Subscribe explicitly">
    Click your notification opt-in button, allow the browser prompt and confirm the result is `registered` with a non-null `endpointId`.
  </Step>

  <Step title="Send through the real product flow">
    Create a test list or segment containing only this customer, then send a Journey or Campaign through the Push channel. The current channel **Test Send** form targets Android/FCM, so do not use it as the Web Push verification path.
  </Step>

  <Step title="Test browser states">
    Confirm notification behavior with the page focused, in the background and closed. Browser and operating-system settings can affect presentation.
  </Step>

  <Step title="Verify engagement">
    Click the notification, confirm the intended same-origin page opens, call `flush()` during development and inspect received, opened and destination-opened metrics separately.
  </Step>

  <Step title="Verify cross-platform fan-out">
    Register the same customer in your mobile app and browser, then run one controlled Journey or Campaign. Each eligible active endpoint should receive its platform notification.
  </Step>

  <Step title="Verify revocation">
    Call `unsubscribe()` or `logout()`, send again and confirm the revoked web endpoint is no longer eligible.
  </Step>
</Steps>

<Note>
  A UI result such as “queued” proves only that the send entered Sagepilot's delivery pipeline. It does not prove provider acceptance, service-worker receipt, browser presentation or a customer click. Use the separate analytics stages to locate a failure.
</Note>

## Troubleshooting

| Symptom | What to check |
| - | - |
| `isWebPushSupported()` is `false` | Confirm HTTPS, service-worker support, `PushManager`, Notifications and browser storage are available. Test the real browser instead of assuming support from its name. |
| `worker_failed` | Confirm the packaged worker is publicly served at `/sagepilot-push/sagepilot-push-worker.js` with a JavaScript content type and no redirect. |
| `permission_required` | This is expected before the customer decides. Call `subscribe()` from the opt-in action. |
| `permission_denied` | The browser has blocked this origin. The SDK cannot prompt again until the customer changes the site's notification permission. |
| `Web Push session scope mismatch` | Confirm the browser Push key and proof use the same channel, the key's workspace is correct and the channel has a complete VAPID configuration. |
| `401`, `403` or identity verification failure | Confirm the proof is signed with the current channel identity secret, has `aud=sagepilot-brand-push`, matches the channel and has not exceeded its five-minute lifetime. |
| `registered` but no notification | Confirm the journey/campaign targets the same canonical customer, Web marketing consent is enabled, the channel is active and analytics show whether the provider accepted and the worker received the send. Also check browser and OS notification settings. |
| Notification opens the home page | Use a same-origin deep link. External origins are intentionally rejected by the worker. |
| Opened exists but destination-opened is missing | Initialize and identify the add-on on the destination page, then allow it to flush retained observations. |
| Endpoint changes unexpectedly | Browser subscription rotation is normal. An identified page re-registers the current endpoint automatically. Do not cache or send directly to browser endpoint URLs yourself. |

For method signatures and error fields, see the [Web SDK API reference](/sdks/web/api-reference).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.