# Web push


Web push works in OpenPush today, and it works through Firebase Cloud Messaging. Browsers subscribe with the Firebase JavaScript SDK and your own service worker; OpenPush stores the resulting registration token as a subscription with platform `web` and sends to it over the same FCM v1 path Android uses.

This page is deliberately blunt about what exists and what does not, because the gap between the two is where web integrations go wrong.

## Current state, honestly

**What exists**

- A `web` platform, targetable like `ios` and `android`.
- Storage for your web credentials: site URL, VAPID public key, and a pasted `firebaseConfig` object.
- An unauthenticated config endpoint, `GET /v1/apps/{app_id}/webpush-config`, that publishes exactly what a browser needs to subscribe.
- Delivery: any non-iOS subscription routes through FCM, so a browser's FCM web registration token receives the normal data-only payload.
- The full receipt ladder from a browser — `received`, `confirmed`, `clicked` — through `POST /v1/ingest`.
- A bundled browser test client shipped with the server, with a real service worker, for verifying an app end to end without writing any code.

**What does not exist**

- **No first-party JavaScript SDK.** There is no npm package, no CDN loader, no deferred command queue, no slide-down prompt, no web opt-in/opt-out lifecycle helper. You write the browser code.
- **No direct Web Push Protocol sender.** OpenPush does not sign VAPID JWTs, does not perform `aes128gcm` payload encryption, and never POSTs to a browser push service endpoint. Every web send goes through FCM.
- **The `vapid` credential kind is stored but never read by any sender.** It exists for the direct sender that does not exist yet. A VAPID private key in OpenPush sends nothing.
- **No `webpush` sub-config on outbound FCM messages.** Web devices receive the same data-only payload as Android, so your service worker draws every notification.

If your requirement is "push to browsers without Firebase", OpenPush cannot do that today.

## When to use

Use web push through OpenPush when you already have — or are willing to add — a Firebase web app and a service worker in your site. You get one audience, one segment language, one composer and one set of delivery stats across iOS, Android and web.

## Prerequisites

- A Firebase project with a **web app** registered, and Cloud Messaging enabled
- The project's Web Push certificate key pair (the VAPID pair) from Firebase's Cloud Messaging settings
- A site served over HTTPS with a service worker you control
- The service account JSON for that Firebase project, **uploaded under Android** in OpenPush — see the warning below
- Your OpenPush app id and its **SDK key**

> **Upload the sending credential under Android, not Web.**
>
> The sender reads the Android platform credential only. A Firebase service account uploaded under **Web** is stored, makes the web push config endpoint work, and shows Web as ready — and is never read by any send. Web delivery then silently falls back to a server-wide credential or fails outright. Put the service account under **Android**, and use the Web slot for the browser-facing values. Details in [platform-setup-fcm.md](platform-setup-fcm.md).

## Step 1 — store your web credentials

In the console, open your app's **Settings → Platforms → Web** and provide:

| Value | What it is |
|---|---|
| Site URL | Your site's origin |
| VAPID public key | The public half of your Firebase Web Push certificate pair — this is the `applicationServerKey` browsers need |
| `firebaseConfig` | The web app config object from the Firebase console; it is accepted as either a JavaScript object literal or JSON |

Platform credentials are uploaded through the console; there is no `/v1` route for them.

## Step 2 — read the config from the browser

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c/webpush-config
```

```json
{
  "app": "app_3f9c",
  "vapid_public": "BJ9…",
  "site_url": "https://example.com",
  "firebase": {
    "apiKey": "…",
    "projectId": "…",
    "messagingSenderId": "…",
    "appId": "…"
  },
  "sdk_key": "YOUR_OPENPUSH_SDK_KEY",
  "needs_link_code": false
}
```

| Field | Meaning |
|---|---|
| `app` | The app id |
| `vapid_public` | The **public** VAPID key, for `getToken`'s `vapidKey` |
| `site_url` | Your configured site URL |
| `firebase` | Your `firebaseConfig`, re-projected through an allowlist so only the fields a browser needs are published |
| `sdk_key` | The app's ingest-only SDK key |
| `needs_link_code` | Whether this app registers devices through a link code |

**This endpoint has no auth, deliberately.** It publishes only values that are already public in any web push integration — a public VAPID key, a Firebase web config, and an SDK key whose entire capability is ingest. The service account JSON and the VAPID **private** key are never returned. The response is assembled field by field rather than by serializing a row, so nothing can leak by accident.

If the app has no web credential, the endpoint returns `404` with a message pointing at Settings → Platforms → Web.

## Step 3 — subscribe the browser

Register your own service worker, initialize the Firebase JS SDK with the published config, and ask for a token:

```js
const cfg = await fetch("https://app.openpush.ai/v1/apps/app_3f9c/webpush-config")
  .then(r => r.json());

const reg = await navigator.serviceWorker.register("/sw.js");
const app = firebase.initializeApp(cfg.firebase);
const messaging = firebase.messaging(app);

const token = await messaging.getToken({
  vapidKey: cfg.vapid_public,
  serviceWorkerRegistration: reg,
});
```

The subscribe flow is Firebase's, not OpenPush's. OpenPush's only interest is the token that comes out of it.

## Step 4 — register the subscription with OpenPush

Post the token to the device-registration endpoint with the **SDK key**, with `platform` set to `web`:

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/subscriptions \
  -H "X-OP-SDK-Key: $OPENPUSH_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "token": "fN8…",
        "platform": "web",
        "external_id": "user-7",
        "language": "en",
        "timezone": "Europe/London",
        "tags": {"plan": "pro"}
      }'
```

```json
{"id":"sub_01hq…","app":"app_3f9c","status":1,"status_name":"Subscribed","created":true}
```

Registration is an idempotent upsert on the token, so calling it again after a token refresh updates the existing subscription rather than duplicating it. The full body — aliases, identity verification hash, status values, collection switches — is documented in [users-and-subscriptions.md](users-and-subscriptions.md) and [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md).

The SDK key is safe in browser code by design; it can register a device, post receipts, post events and register Live Activity tokens, and nothing else. The REST API key must never reach a browser.

## Step 5 — receive and report

Your service worker receives a data-only payload. It must draw the notification itself, and it should post the receipt ladder back:

```js
await fetch("https://app.openpush.ai/v1/ingest", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-OP-SDK-Key": SDK_KEY,
  },
  body: JSON.stringify({
    events: [
      { type: "received",  app: "app_3f9c", message_id: msgId, token: token },
      { type: "confirmed", app: "app_3f9c", message_id: msgId, token: token },
    ],
  }),
});
```

`type` is one of `received`, `confirmed`, `clicked`. Post `confirmed` when you actually call `showNotification`, and `clicked` from your `notificationclick` handler. Timestamps are first-write-wins, so a replayed receipt cannot move a stamp, and anything malformed or unknown is counted as dropped rather than failing the request.

Payload keys you will read in the worker: `op_message_id` (the id for receipts), `title`, `body`, `image_url` and `deep_link` when set, `op_actions` for buttons, and any custom `data` from the message.

## The bundled test client

OpenPush serves a single-page browser test device at `/static/testapp/`. It fetches `webpush-config`, registers its own service worker, subscribes through Firebase, renders background pushes, and posts the full received → confirmed → clicked ladder to `/v1/ingest`.

Use it to prove the server side works — credentials, registration, targeting, receipts — before you write any of your own browser code. It is a diagnostic tool, not a drop-in widget: it is not versioned as a product, not embeddable, and not a substitute for the JS SDK that does not exist yet.

## Limits

- Web delivery requires Firebase. There is no VAPID-direct sender.
- No first-party JavaScript SDK: no prompt UI, no opt-in lifecycle, no auto-registration, no token refresh handling. You own all of it.
- A stored VAPID private key is inert.
- Web pushes carry no `webpush` FCM sub-config, so nothing is rendered for you.
- The FCM data envelope is 4 KB; longer content is truncated deterministically and marked with `op_render_truncated`.
- Safari and other browsers that do not support Firebase Cloud Messaging for web are out of scope.
- Web subscriptions count toward the same audience, segments and frequency caps as mobile ones; there is no web-only targeting mode.

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `webpush-config` returns `404` | No web credential on the app | Add one under Settings → Platforms → Web |
| Config loads, browsers subscribe, nothing is ever delivered | The service account was uploaded under **Web** | Upload it under **Android**; the Web slot is not read by the sender |
| `getToken` throws about the application server key | Wrong or truncated VAPID public key | Copy the public key from the Firebase Web Push certificate pair exactly |
| Token registers but the subscription has platform `android` | `platform` omitted at registration — it defaults to `android` | Send `"platform": "web"` |
| Notifications never appear, though receipts are posted | Your service worker is not calling `showNotification` | Payloads are data-only; the worker must render |
| Provider Accepted is high, Confirmed Receipt is zero | The worker posts `received` but not `confirmed` | Post `confirmed` at the moment you show the notification |
| Receipts return `accepted: 0` | Wrong app id in the event, unknown token, or a stamp already written | Check the `app` and `token` fields; replays are dropped by design |
| A registered browser stops receiving after a while | FCM reported the token unregistered | The subscription is marked `-10`; re-subscribe and re-register |
| You added a VAPID private key and expected direct sending | There is no direct sender | Route through FCM |

## FAQ

**Is there an OpenPush JavaScript SDK?**
Not today. Web integration means your own service worker plus the Firebase JS SDK, with two OpenPush HTTP calls: register the token, post receipts.

**Can I use web push without Firebase?**
No. Every web send leaves OpenPush through FCM.

**Is it safe to expose the SDK key in browser JavaScript?**
Yes — that is what it is for. It can register devices and post receipts and events. Admin capability lives behind the REST key, which must stay on your server.

**Why is the config endpoint unauthenticated?**
Everything in it is already public in any web push integration, and the response is built from an explicit allowlist rather than a database row.

**Do web subscriptions support tags, external IDs and segments?**
Yes. A web subscription is an ordinary subscription; the whole identity and targeting model applies.

**Can I send a rich notification with buttons to a browser?**
The action list arrives in the payload as `op_actions`, but your service worker has to turn it into notification actions.

## Related

- [platform-setup-fcm.md](platform-setup-fcm.md)
- [users-and-subscriptions.md](users-and-subscriptions.md)
- [sending-messages.md](sending-messages.md)
- [segments.md](segments.md)
- [security-and-limits.md](security-and-limits.md)
- [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md)
- [../api-handbook/06-events-ingest.md](../api-handbook/06-events-ingest.md)
