# Push notifications

Setting up iOS, Android and web apps, registering devices from your server, reporting taps, iPhone web apps

Push reaches a contact's devices: an iOS app, an Android app, and browsers (including
iPhone Home Screen web apps). You set the apps up once in the dashboard; from then on
your server registers each device against its contact and sends, through the API.

There is no Kirora mobile SDK yet. Your app does three small things itself, described
below: ask for permission, hand the device's token to your server, and report taps.

## 1. Set up the apps (dashboard, once)

Settings › Push holds **push projects**. A project is one product in one environment
(`sandbox` or `production`) and holds at most one app per platform. Every send targets a
project and reaches the contact on all of its ready apps.

| App | What you upload | Where it comes from |
| --- | --- | --- |
| Web | Nothing: Kirora generates the app's key pair and shows its `vapidPublicKey` | — |
| iOS, direct to Apple | A `.p8` push key, with its Key ID, your Team ID and the app's Bundle ID | Apple Developer › Certificates, Identifiers & Profiles › Keys (download once) |
| iOS through Firebase | Your Firebase service-account file | Firebase console › Project settings › Service accounts |
| Android | Your Firebase service-account file | Firebase console › Project settings › Service accounts |

Each key is checked with Apple or Google before it is stored, and is never shown again.

**If your apps use Firebase for both platforms,** upload the same service-account file to
your iOS app and your Android app, and register Firebase tokens from both. Firebase
reaches iPhones through Apple, so your Apple push key must also be uploaded in the
Firebase console (Project settings › Cloud Messaging), and your iOS app needs Apple's
Push Notifications capability. An iOS app sends one way or the other: switching it
between Apple and Firebase means your devices register again.

## 2. Register devices (your server)

Your app or website gets a token from the platform and gives it to **your server**,
which registers it with a `contacts:write` key. Never put an API key in an app or a
web page: anyone could extract it.

```http
POST /v1/contacts/{contactId}/installations
Authorization: Bearer kirora_…
Content-Type: application/json

{
  "pushAppId": "…",
  "externalInstallationId": "your-stable-id-for-this-device",
  "permission": "granted",
  "targetKind": "fcm_token",
  "target": "<the token>"
}
```

- `targetKind` is `apns_token` (iOS, direct to Apple), `fcm_token` (Android, or iOS
  through Firebase), or `web_push_subscription` with `subscription` in place of
  `target`: the object `PushSubscription.toJSON()` returns, unchanged. A subscription
  whose keys were damaged in transit is refused with a 400 naming the key.
- **Register on every app start or site visit.** A device not seen for 60 days is
  treated as gone. Re-registering the same `externalInstallationId` updates it; signing
  in as someone else moves it to that contact.
- **Permission turned off:** send `{ "pushAppId", "externalInstallationId",
  "permission": "denied", "lastSeenAt" }`, with `lastSeenAt` from the latest
  registration's response, so a late report cannot disable a newer registration.
- **Sign-out:** `DELETE /v1/contacts/{contactId}/installations/{installationId}`.
- **See what is registered:** `GET /v1/contacts/{contactId}/installations`
  (`contacts:read`) lists the contact's devices and browsers, newest first, with
  `externalInstallationId`, `platform`, `permission`, `active`, `lastSeenAt` and
  `invalidatedAt`. Inactive ones are included; the token or subscription never is.

Marketing push also needs the contact's own consent: set `pushConsent: true` (with a
`consentStatement`) through `POST /v1/contacts/identify`. The OS permission alone does
not grant it.

## 3. Show notifications and report taps (your app or site)

Every push carries a signed `trackingUrl`. When someone taps the notification, POST to it
(no API key; the URL is the credential):

```json
{ "event": "clicked" }
```

Send `clicked` when the tap follows a link (`actionUrl` or a button) and `opened` when it
opens nothing. A click also counts as an open; only the first of each counts.

**Native apps** receive `actionUrl`, `imageUrl`, `buttons` (a JSON string of
`[{ title, url }]`), `trackingUrl` and your own `data` keys as string data beside the
alert. Open `actionUrl` (an https link or your app's own link, such as
`myapp://orders/1`) and report the tap.

**Websites** run a service worker that shows the notification and reports the tap. This
one does both:

```js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  const buttons = data.buttons || [];
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      image: data.imageUrl,
      icon: data.iconUrl,
      tag: data.tag,
      actions: buttons.map((button, index) => ({ action: String(index), title: button.title })),
      data,
    }),
  );
});

self.addEventListener('notificationclick', (event) => {
  const data = event.notification.data || {};
  event.notification.close();
  const button = event.action ? (data.buttons || [])[Number(event.action)] : null;
  const url = button ? button.url : data.actionUrl;
  const report = data.trackingUrl
    ? fetch(data.trackingUrl, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ event: url ? 'clicked' : 'opened' }),
        keepalive: true,
      }).catch(() => {})
    : null;
  event.waitUntil(Promise.all([report, url ? clients.openWindow(url) : null]));
});
```

On the page, ask for permission from a real click (Safari refuses a request made on
load), then subscribe with the app's key and send the result to your server:

```js
const registration = await navigator.serviceWorker.register('/sw.js');
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: vapidPublicKeyAsBytes, // the base64url key, decoded
});
await fetch('/your-server/push-subscription', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(subscription.toJSON()),
});
```

## iPhone and iPad web apps

iOS and iPadOS (16.4 and later) deliver web push only to a site added to the Home
Screen, never to a Safari tab. To support it:

- Serve a web app manifest with `"display": "standalone"`, linked from your pages.
- A Home Screen app keeps its own storage, separate from Safari's, and opens at the
  manifest's `start_url` (or, without one, the address it was added from). Anything the
  page needs to know at launch must come from that address or from your server.
- Ask people to add the site to their Home Screen (Share › Add to Home Screen), open it
  from there, and allow notifications from a tap inside it.

## Sending

```http
POST /v1/push/messages
Authorization: Bearer kirora_…   (push:send)
Idempotency-Key: …

{ "contactId": "…", "pushProjectId": "…", "content": { "title": "…", "body": "…", "actionUrl": "https://…" } }
```

It answers `202` with the message `queued`, and reaches every ready app in the project.
A contact with no active device is a `409 PUSH_DESTINATION_MISSING`. Campaigns and
journeys send push the same way, to a project.

**This is a marketing push.** The contact needs push consent (see above), a push
suppression refuses it, and it is recorded as a marketing message. Receipts, sign-in
codes and alerts are transactional: send them through `POST /v1/transactional/messages` with `push`
([transactional messages](/docs/api/transactional)), which go out at `high` urgency.

### Delivery options

All optional, in `content`:

| Field | Values | What it does |
| --- | --- | --- |
| `urgency` | `high`, `normal` | `high` wakes the device now; `normal` lets it wait for a good moment to save battery. Defaults to `normal` here and `high` for transactional push. Sent as APNs `apns-priority` (10 / 5), Android `priority`, and the Web Push `Urgency` header. |
| `collapseKey` | up to 64 of `A-Z a-z 0-9 _ -` | A newer notification with the same key replaces the older one: APNs `apns-collapse-id`, Android notification `tag`, the browser notification's `tag`. |
| `overrides.ios.interruptionLevel` | `passive`, `active`, `time-sensitive` | How iOS presents it. `passive` arrives without sound or lighting the screen; `time-sensitive` breaks through Focus and needs the app's Time Sensitive Notifications capability. |
| `overrides.ios.threadId` | up to 64 of `A-Z a-z 0-9 _ . -` | Groups notifications with the same thread in Notification Center (APNs `thread-id`). |
| `overrides.android.channelId` | the channel's id | The Android notification channel, which your app creates. |

`interruptionLevel` and `threadId` reach iOS devices whether the app sends direct to Apple
or through Firebase. Android and web have no equivalent of either through these
providers: Firebase notification messages cannot group, and a browser notification's
`tag` replaces rather than groups. `critical` interruption is not offered: Apple grants
that entitlement only to apps it approves. `badge` sets the icon's number; neither Apple
nor Firebase can increment it, so send the count you want shown.

### What each device answered

```http
GET /v1/push/messages/{id}/deliveries
Authorization: Bearer kirora_…   (messages:read)
```

One row per device the message went to, with its `installationId`, `platform` and
`status`: `accepted` (Apple, Google or the browser's push service took it, which does
not prove the device showed it), `invalid` (the device is gone and was retired),
`rejected`, `retryable` (will be tried again), `unknown` (the provider may have taken
it, so it is not resent), `skipped`, or `pending` / `started` while in flight.

The same endpoint answers for a push sent through `POST /v1/transactional/messages`, with
the message id from that send. `skipped` means the device was not attempted: its app's
credentials were refused for another device of the same app, or the message had already
ended.

## What your app must do itself

Kirora sends the push; showing it well is the app's job:

- Ask for permission at a moment the person understands, not on page load.
- In a service worker, wrap `showNotification` in `event.waitUntil`.
- On iOS, images need a notification service extension, and buttons need a
  `UNNotificationCategory` the app registers.
- On Android, create the notification channels that `overrides.android.channelId` names; a
  foreground app must show the push itself.

## What each platform does with a device that has gone

When a user uninstalls the app or removes a site's permission, the next push usually
comes back "gone" and Kirora retires the device. **Safari does not report it:** after a
browser unsubscribes, Apple keeps accepting pushes to it and drops them. Such a device
drops out after 60 days without a registration; registering on every visit keeps the
others current.

## Tested on

On real devices: Chrome and Safari on macOS, an iPhone Home Screen web app, an Android
app, and an iOS app through Firebase, with transactional and campaign pushes and taps
reported. Firefox accepts our pushes; Edge and iOS direct to Apple are still to test.