Push Notifications

Status: Fully implemented in v3.0.0 (core push system) and v3.1.0 (per-app config + Console UI).

Overview

Keymaster brokers push notifications for your app. Your backend sends notifications via Keymaster's API; Keymaster delivers to FCM (Android, iOS, Web) and Web Push (Safari). Your app never touches Firebase or APNs directly.

Architecture

Your Backend
    ↓ POST /push/send (client_credentials JWT)
Keymaster Push Service
    ↓ FCM / Web Push
User's Device
    ↓ delivery receipt
Keymaster → Your Webhook URL

Setup

1. Enable Notifications

Push notifications are enabled per-app. You can enable via the Console UI (App Detail → Notifications tab → Enable) or programmatically:

PATCH /push/config/{app_id}
Authorization: Bearer {admin_jwt}

{
  "enabled": true,
  "webhook_url": "https://yourapp.com/hooks/push-receipts",
  "daily_quota": 10000
}

On first enable, Keymaster auto-provisions: - A VAPID keypair for Web Push (Safari) - A webhook secret (auto-generated, returned in the response)

To check current push status and device count:

GET /push/config/{app_id}
Authorization: Bearer {admin_jwt}

→ {
    "enabled": true,
    "webhook_url": "https://yourapp.com/hooks/push-receipts",
    "webhook_secret": "whsec_...",
    "daily_quota": 10000,
    "device_count": 42,
    "notifications_today": 128
  }

To list registered devices for an app:

GET /push/devices/{app_id}
Authorization: Bearer {admin_jwt}

→ {
    "devices": [
      {
        "device_id": "uuid",
        "user_id": "uuid",
        "platform": "ios",
        "last_seen_at": "2026-03-19T08:00:00Z"
      }
    ],
    "total": 42
  }

2. Register Devices (Client-Side)

Prerequisite: The app must have push notifications enabled (notifications.enabled in config). Device registration will return 403 if push is not enabled for the app.

After the user authenticates in your app, request push permission and register the token:

// Web (using Firebase)
import { getMessaging, getToken } from 'firebase/messaging';

const messaging = getMessaging();
const fcmToken = await getToken(messaging, { vapidKey: 'your-vapid-key' });

// Register with Keymaster
await fetch('https://keymaster.cloud-monitor.com/push/devices/register', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${userAccessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    app_id: 'your-app-id',
    platform: 'web',
    push_token: fcmToken,
  }),
});
// iOS (after getting APNs token via Firebase)
func registerDevice(fcmToken: String, accessToken: String) {
    var request = URLRequest(url: URL(string: "https://keymaster.cloud-monitor.com/push/devices/register")!)
    request.httpMethod = "POST"
    request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONSerialization.data(withJSONObject: [
        "app_id": "your-app-id",
        "platform": "ios",
        "push_token": fcmToken,
    ])
    URLSession.shared.dataTask(with: request).resume()
}

3. Send Notifications (Server-Side)

Authenticate via client credentials, then call the push API:

from keymaster_client import KeymasterClient  # see server-to-server.md

km = KeymasterClient(
    base_url="https://keymaster.cloud-monitor.com",
    client_id="your-app-id",
    client_secret="your-secret",
)

# Single notification
km.send_push(
    user_id="user-uuid",
    title="Your shift starts in 30 min",
    body="Open GymOps to view details.",
    data={"route": "/schedule"},
)

API Endpoints

Register Device

POST /push/devices/register
Authorization: Bearer {user_jwt}

{
  "app_id": "uuid",
  "platform": "web" | "ios" | "android",
  "push_token": "fcm-token-or-web-push-subscription"
}

→ { "device_id": "uuid" }

Rate limited: 10 registrations per user per hour.

Unregister Device

App-initiated (server-side):

DELETE /push/devices/{device_id}
Authorization: Bearer {client_credentials_jwt scope=push:send}

User-initiated (Account page):

DELETE /push/devices/{device_id}
Authorization: Bearer {user_jwt}

Send (Single)

POST /push/send
Authorization: Bearer {client_credentials_jwt scope=push:send}

{
  "user_id": "uuid",
  "title": "Notification title",
  "body": "Notification body text",
  "data": { "route": "/some-page" },
  "ttl": 3600
}

→ { "notification_id": "uuid" }

Send Bulk (Same Message to Many)

POST /push/send/bulk
Authorization: Bearer {client_credentials_jwt scope=push:send}

{
  "user_ids": ["uuid1", "uuid2", ...],
  "title": "Gym closes early today",
  "body": "Closing at 6pm",
  "data": {}
}

→ { "notification_ids": ["uuid1", "uuid2", ...] }

Max user_ids: 500 (configurable via PUSH_MAX_BATCH_SIZE).

Send Batch (Different Messages)

POST /push/send/batch
Authorization: Bearer {client_credentials_jwt scope=push:send}

{
  "notifications": [
    { "user_id": "uuid1", "title": "Shift approved", "body": "..." },
    { "user_id": "uuid2", "title": "Time off denied", "body": "..." }
  ]
}

Max notifications: 500.

Delivery Receipts

After FCM/APNs responds, Keymaster POSTs to your app's webhook URL:

POST https://yourapp.com/hooks/push-receipts
X-Keymaster-Signature: sha256=...
Content-Type: application/json

{
  "notification_id": "uuid",
  "user_id": "uuid",
  "device_id": "uuid",
  "app_id": "uuid",
  "status": "delivered" | "failed" | "invalid_token",
  "error": null,
  "timestamp": "2026-03-17T12:00:00Z"
}

On invalid_token, Keymaster auto-removes the device registration.

Quotas

User Device Management

Users can view and manage their registered devices from the Account page:

GET /account/devices
Authorization: Bearer {account_jwt}

Returns all devices grouped by app, with platform and last_seen_at. Users can revoke individual devices.

Validation Rules