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.
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>"
}
targetKindisapns_token(iOS, direct to Apple),fcm_token(Android, or iOS through Firebase), orweb_push_subscriptionwithsubscriptionin place oftarget: the objectPushSubscription.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
externalInstallationIdupdates it; signing in as someone else moves it to that contact. - Permission turned off: send
{ "pushAppId", "externalInstallationId", "permission": "denied", "lastSeenAt" }, withlastSeenAtfrom 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, withexternalInstallationId,platform,permission,active,lastSeenAtandinvalidatedAt. 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):
{ "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:
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:
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
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), 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
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
showNotificationinevent.waitUntil. - On iOS, images need a notification service extension, and buttons need a
UNNotificationCategorythe app registers. - On Android, create the notification channels that
overrides.android.channelIdnames; 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.