# Keymaster SDK Documentation (v2.3.1) # Base URL: https://keymaster.cloud-monitor.com # Generated for LLM consumption ======================================================================== FILE: index.md ======================================================================== # Keymaster Developer Documentation **Version:** 1.0.0 **Base URL:** `https://keymaster.cloud-monitor.com` Keymaster is an identity broker for the Cloud Monitor platform. It provides shared user authentication, cross-app SSO, and centralized user management for all connected applications. ## Quick Links - [Quick Start Guide](quickstart.md) — Get your app authenticating in 5 minutes - [Authentication Guide](authentication.md) — OAuth2 flows, SSO, provider setup - [Token Lifecycle](token-lifecycle.md) — JWT access tokens, refresh rotation, session management - [Server-to-Server](server-to-server.md) — Client credentials grant for backend APIs - [Push Notifications](push-notifications.md) — Device registration, sending, delivery receipts - [Webhooks](webhooks.md) — Outbound event delivery, HMAC signatures, retry behavior - [API Reference](api-reference/) — Complete endpoint documentation ## Architecture Overview ``` Your App (client) ↓ redirect Keymaster Login (branded per app) ↓ OAuth2 authorization code Your App Callback ↓ access_token + refresh_token Your App Backend ↓ verify JWT (via JWKS) or POST /token/verify Protected Resources ``` ### Key Concepts | Concept | Description | |---------|-------------| | **Tenant** | Organization that owns apps. Has its own admin tier and password policy. | | **App** | A registered client application. Gets `client_id` + `client_secret`, configurable auth methods, branding. | | **User** | Global identity (email-based). Can be enrolled in multiple apps across tenants. | | **SSO Session** | Platform-wide "logged in at Keymaster" session (`km_sso` cookie). Log in once, access all enrolled apps. | | **Access Token** | RS256 JWT, short-lived (default 15 min). Carries user_id, email, roles, app_id. | | **Refresh Token** | Opaque string, long-lived (default 30 days). Rotated on every use. | | **Roles** | `user` (base), `manager` (invite/revoke), `admin` (full app control). Custom roles supported. | ### Well-Known Endpoints | Endpoint | Description | |----------|-------------| | `GET /.well-known/openid-configuration` | OIDC discovery document | | `GET /.well-known/jwks.json` | Public keys for JWT verification | ======================================================================== FILE: quickstart.md ======================================================================== # Quick Start Guide Get your app authenticating with Keymaster in 5 minutes. ## Prerequisites - An app registered in the Keymaster Console (you'll need the `app_id` and `client_secret`) - At least one redirect URI configured - At least one auth provider enabled (password, Google, etc.) ## Step 1: Redirect to Keymaster Login When a user clicks "Sign In" in your app, redirect them to: ``` https://keymaster.cloud-monitor.com/login ?app_id=YOUR_APP_ID &redirect_uri=https://yourapp.com/auth/callback ``` Keymaster shows a branded login page with your app's logo, name, and enabled auth methods. ## Step 2: Handle the Callback After successful authentication, Keymaster redirects back to your `redirect_uri` with tokens: ``` https://yourapp.com/auth/callback ?access_token=eyJhbGciOiJSUzI1NiIs... &refresh_token=a1b2c3d4e5f6... ``` ## Step 3: Verify the Access Token The access token is an RS256-signed JWT. Verify it using Keymaster's public keys: ```python # Python (using PyJWT) import jwt from jwt import PyJWKClient jwks_client = PyJWKClient("https://keymaster.cloud-monitor.com/.well-known/jwks.json") signing_key = jwks_client.get_signing_key_from_jwt(access_token) payload = jwt.decode( access_token, signing_key.key, algorithms=["RS256"], issuer="https://keymaster.cloud-monitor.com", audience=YOUR_APP_ID, ) # payload contains: # { # "sub": "user-uuid", # "email": "user@example.com", # "roles": ["user", "admin"], # "name": "Jane Doe", # "aud": "your-app-id", # "iss": "https://keymaster.cloud-monitor.com", # "iat": 1710547200, # "exp": 1710548100 # } ``` ```javascript // Node.js (using jose) import { createRemoteJWKSet, jwtVerify } from 'jose'; const JWKS = createRemoteJWKSet( new URL('https://keymaster.cloud-monitor.com/.well-known/jwks.json') ); const { payload } = await jwtVerify(accessToken, JWKS, { issuer: 'https://keymaster.cloud-monitor.com', audience: YOUR_APP_ID, }); ``` ## Step 4: Store Tokens and Refresh Store both tokens in a **durable session store** (database, not memory). The access token expires in 15 minutes. Before it expires, refresh it: ```python import httpx resp = httpx.post("https://keymaster.cloud-monitor.com/token/refresh", json={ "refresh_token": stored_refresh_token, "app_id": YOUR_APP_ID, }) data = resp.json() # { # "access_token": "new-jwt...", # "refresh_token": "new-refresh-token...", # "expires_in": 900 # } # IMPORTANT: Update BOTH tokens in your session store. # The old refresh token is now revoked (rotation). ``` The refresh token is valid for 30 days and rotates on every use. As long as the user is active within the 30-day window, their session never expires. ## Step 5: Logout When the user logs out: ```python # 1. Revoke the refresh token (best-effort) httpx.post("https://keymaster.cloud-monitor.com/token/revoke", json={ "refresh_token": stored_refresh_token, }) # 2. Destroy local session session.delete() # 3. Redirect to Keymaster's branded logout page redirect("https://keymaster.cloud-monitor.com/sso/logout" "?app_id=YOUR_APP_ID" "&post_logout_redirect_uri=https://yourapp.com") ``` ## What's Next? - [Authentication Guide](authentication.md) — Detailed OAuth2 flows, SSO behavior, provider setup - [Token Lifecycle](token-lifecycle.md) — Refresh rotation, replay detection, session management - [Server-to-Server](server-to-server.md) — Client credentials for backend API calls - [API Reference](api-reference/) — Complete endpoint documentation ======================================================================== FILE: authentication.md ======================================================================== # Authentication Guide ## Overview Keymaster supports multiple authentication methods, all unified behind a single login page per app. Users choose their preferred method; Keymaster handles the rest. ## Supported Auth Methods | Provider | Type | Setup Required | |----------|------|----------------| | `password` | Email + password (argon2id hashed) | None — built-in | | `magic_link` | Passwordless email link (15-min expiry) | Email infrastructure | | `google` | Google OAuth 2.0 | Google Cloud Console credentials | | `github` | GitHub OAuth | GitHub Developer Settings credentials | | `microsoft` | Microsoft Entra ID | Azure App Registration | | `apple` | Sign in with Apple | Apple Developer Program | Apps choose which providers to enable via the Console. The login page only shows enabled providers. ## OAuth2 Authorization Code Flow This is the primary flow for web applications. ### 1. Initiate Login Redirect the user to: ``` GET https://keymaster.cloud-monitor.com/login ?app_id={uuid} &redirect_uri={your_callback_url} ``` Optional parameters: - `prompt=login` — Force login screen even if SSO session exists (skip SSO fast path) ### 2. User Authenticates Keymaster shows the branded login page. The user picks their auth method: - Password → email + password form - Magic Link → email input, receives link via email - OAuth → redirects to provider (Google, GitHub, etc.), returns to Keymaster ### 3. SSO Fast Path If the user has an active `km_sso` session (logged in at Keymaster within the last 8 hours), Keymaster skips the login screen and immediately redirects back with tokens — **true cross-app SSO**. This only happens if: - User has a valid `km_sso` cookie - User is enrolled in the target app - `prompt=login` is NOT set ### 4. Callback with Tokens On success, Keymaster redirects to your `redirect_uri`: ``` {redirect_uri}?access_token={jwt}&refresh_token={opaque_token} ``` ### 5. Error Handling On failure, Keymaster redirects to the login page with an error parameter: | Error | Meaning | |-------|---------| | `oauth_denied` | User cancelled the OAuth consent screen | | `oauth_failed` | Provider error (network, invalid response) | | `not_enrolled` | User exists but isn't enrolled in this app | | `pending_approval` | User's access request is pending admin approval | | `suspended` | User's access to this app has been suspended | | `provider_not_allowed` | Auth method not enabled for this app | ## Registration Policies Each app has a registration policy that controls how users gain access: | Policy | Behavior | |--------|----------| | `open` | Anyone can sign up. Users are auto-enrolled on first login. | | `invite` | Requires an invite code. Users without enrollment see the invite code page. | | `approval` | Anyone can request access. Admin must approve before access is granted. | ## Cross-App SSO Keymaster's SSO works via the `km_sso` httponly cookie on the Keymaster domain. **Flow:** 1. User logs into App A → Keymaster sets `km_sso` cookie (8-hour lifetime) 2. User visits App B → App B redirects to Keymaster login 3. Keymaster sees valid `km_sso` → checks user is enrolled in App B 4. If enrolled → issues tokens and redirects back immediately (no login screen) 5. If not enrolled → shows login page with "You don't have access" message **SSO does NOT auto-enroll users.** Each app controls its own enrollment via registration policy. ## Password Policy - Minimum length: 16 characters (platform default, configurable per tenant) - Hashing: Argon2id - No complexity requirements enforced (length is the primary defense) - Password change revokes all SSO sessions (forces re-authentication everywhere) ## Two-Factor Authentication (TOTP) - Apps can require 2FA via the "Require 2FA" toggle in Console - Users set up TOTP via the Account page (any authenticator app) - 8-character backup codes provided (10 codes, single-use) - When 2FA is required but not set up, login returns a structured error with a link to the Account page ## Magic Links Passwordless email login: 1. User enters email on login page 2. Keymaster sends a signed link (15-min expiry, single-use) 3. User clicks link → Keymaster verifies, creates session, redirects with tokens 4. Link is consumed — cannot be reused Magic links are ideal for infrequent users or kiosk environments where typing passwords is impractical. ## OAuth Provider Notes ### Google - Uses OpenID Connect (email + profile scopes) - Email verified by Google is automatically marked verified in Keymaster - `prompt=select_account` forces account picker ### GitHub - Uses GitHub's OAuth2 flow - Email may require a separate API call if user's GitHub email is private ### Microsoft - Uses Microsoft Entra ID (formerly Azure AD) - Supports both personal and work/school accounts - `prompt=select_account` forces account picker ### Apple - Uses Sign in with Apple (JWT-based) - Apple only sends the user's name on first login — Keymaster stores it - Requires Apple Developer Program membership ======================================================================== FILE: token-lifecycle.md ======================================================================== # Token Lifecycle ## Token Types Keymaster issues three types of tokens: | Token | Format | Lifetime | Storage | |-------|--------|----------|---------| | **Access Token** | RS256 JWT | 15 min (configurable per app) | Client-side or session store | | **Refresh Token** | Opaque string (64 hex chars) | 30 days (configurable per app) | Server-side session store only | | **SSO Session** | Opaque string (httponly cookie) | 8 hours | `km_sso` cookie on Keymaster domain | ## Access Token (JWT) ### Claims ```json { "sub": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com", "name": "Jane Doe", "aud": "a56e4998-e65d-4817-b69d-009ab7dee28f", "iss": "https://keymaster.cloud-monitor.com", "iat": 1710547200, "exp": 1710548100, "roles": ["user", "admin"] } ``` | Claim | Description | |-------|-------------| | `sub` | User UUID (stable, unique per user across all apps) | | `email` | User's email address | | `name` | Display name (may be null) | | `aud` | App UUID this token was issued for | | `iss` | Keymaster base URL | | `iat` / `exp` | Issued-at and expiration timestamps | | `roles` | Array of roles for this user in this app | ### Verification Always verify access tokens locally using JWKS: ``` GET https://keymaster.cloud-monitor.com/.well-known/jwks.json ``` Check: `alg=RS256`, `iss` matches Keymaster URL, `aud` matches your app_id, `exp` is in the future. **Do NOT call Keymaster on every request.** Cache the JWKS keys (refresh hourly or when `kid` doesn't match). ### Alternative: Server-Side Verification If you can't verify JWTs locally: ``` POST /token/verify { "token": "eyJhbG...", "audience": "your-app-id" } → { "valid": true, "claims": { ... } } → { "valid": false, "error": "Token has expired" } ``` ## Refresh Token Rotation Refresh tokens are **rotated on every use**. When you exchange a refresh token, the old one is revoked and a new pair (access + refresh) is issued. ``` POST /token/refresh { "refresh_token": "a1b2c3d4...", "app_id": "your-app-id" } → { "access_token": "new-jwt...", "refresh_token": "new-refresh-token...", "token_type": "Bearer", "expires_in": 900 } ``` **Critical:** You must store the new refresh token. The old one is now invalid. ### Replay Detection If a revoked refresh token is reused (potential token theft), Keymaster revokes **ALL** refresh tokens for that user+app combination as a security precaution. This forces the user to re-authenticate. ## Correct App Integration Pattern **Store both tokens in a durable session store (database, not memory):** ```python # On each authenticated request: # 1. Look up session in DB # 2. Decode access JWT exp claim locally (no network call) # 3. If expired (or within 30 seconds of expiry): # → POST /token/refresh → update both tokens in DB # 4. If refresh returns 401: # → Token revoked/expired → redirect to login # 5. If network error: # → Degrade gracefully (user was previously authenticated) ``` **DO NOT:** - Store tokens in memory (lost on server restart) - Use the access token as a one-shot identity check and then ignore it - Forget to update the refresh token after rotation - Set a short cookie expiry that doesn't match the refresh token lifetime **DO:** - Set cookie `max_age` to 30 days (match refresh token lifetime) - Refresh proactively (30-second buffer before expiry) - Handle refresh failure gracefully (redirect to login, don't crash) ## Token Revocation ### Revoking a Refresh Token (Logout) ``` POST /token/revoke { "refresh_token": "a1b2c3d4..." } → { "status": "ok" } ``` Always revoke on logout. This prevents the token from being used even if it was intercepted. ### Password Change When a user changes their password, Keymaster automatically revokes **all SSO sessions** for that user. They must re-authenticate everywhere. Refresh tokens for individual apps are NOT automatically revoked — the app will continue working until the refresh token expires or the user explicitly logs out. ## Service Tokens (Client Credentials) For server-to-server authentication, apps use the client credentials grant: ``` POST /auth/token grant_type=client_credentials client_id={app_id} client_secret={secret} scope=push:send ``` Service tokens are distinct from user tokens: - `token_type: "service"` claim (prevents type confusion) - `sub` is the app_id, not a user_id - 15-minute expiry, no refresh — just re-authenticate - Scoped to specific capabilities (e.g., `push:send`) See [Server-to-Server Guide](server-to-server.md) for details. ## Token Lifetimes at a Glance | Scenario | What expires | What to do | |----------|-------------|------------| | Access JWT expires (15 min) | Access token | Call `/token/refresh` | | Refresh token expires (30 days) | Refresh token | Redirect to Keymaster login | | User idle for 30+ days | Both tokens | Redirect to Keymaster login | | SSO session expires (8 hours) | `km_sso` cookie | User sees login screen on next app switch | | Password changed | All SSO sessions | User re-authenticates everywhere | | Refresh token replayed | ALL tokens for user+app | User re-authenticates | ======================================================================== FILE: server-to-server.md ======================================================================== # Server-to-Server Authentication ## Overview For backend API calls (push notifications, user management, etc.), apps authenticate as themselves using the **OAuth2 client credentials** grant. This produces a scoped JWT — no user context needed. ## Client Credentials Flow ### 1. Request a Service Token ``` POST /auth/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id=a56e4998-e65d-4817-b69d-009ab7dee28f &client_secret=your_client_secret_here &scope=push:send ``` ### 2. Receive a Scoped JWT ```json { "access_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "bearer", "expires_in": 900, "scope": "push:send" } ``` ### 3. Use the Token ``` POST /push/send Authorization: Bearer eyJhbGciOiJSUzI1NiIs... { "user_id": "...", "title": "Your shift starts in 30 min", "body": "Open GymOps to view details." } ``` ## Service Token Claims ```json { "sub": "a56e4998-e65d-4817-b69d-009ab7dee28f", "iss": "https://keymaster.cloud-monitor.com", "scope": "push:send", "token_type": "service", "iat": 1710547200, "exp": 1710548100 } ``` | Claim | Description | |-------|-------------| | `sub` | App UUID (NOT a user — this is the app itself) | | `scope` | Space-delimited scopes granted | | `token_type` | Always `"service"` — distinguishes from user JWTs | ## Available Scopes | Scope | Grants access to | |-------|-----------------| | `push:send` | Push notification endpoints (`/push/send`, `/push/send/bulk`, `/push/send/batch`) | More scopes will be added as features are built. ## Security - **15-minute TTL** — Service tokens expire quickly. Cache and reuse, then re-authenticate. - **No refresh tokens** — Client credentials tokens don't have refresh tokens. Just request a new one. - **Rate limited** — 10 attempts per 15 minutes per client_id. Cleared on successful auth. - **Scope enforcement** — Endpoints verify the required scope is present in the token. - **Type separation** — `token_type: "service"` prevents service tokens from being used as user tokens (and vice versa). ## Implementation Pattern ```python import httpx import time class KeymasterClient: """Keymaster server-to-server client with automatic token management.""" def __init__(self, base_url: str, client_id: str, client_secret: str): self.base_url = base_url self.client_id = client_id self.client_secret = client_secret self._token = None self._token_expires = 0 def _get_token(self, scope: str = "push:send") -> str: """Get a valid service token, refreshing if expired.""" if self._token and time.time() < self._token_expires - 30: return self._token resp = httpx.post(f"{self.base_url}/auth/token", data={ "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret, "scope": scope, }) resp.raise_for_status() data = resp.json() self._token = data["access_token"] self._token_expires = time.time() + data["expires_in"] return self._token def send_push(self, user_id: str, title: str, body: str, data: dict = None): """Send a push notification to a user.""" token = self._get_token("push:send") resp = httpx.post( f"{self.base_url}/push/send", headers={"Authorization": f"Bearer {token}"}, json={"user_id": user_id, "title": title, "body": body, "data": data or {}}, ) resp.raise_for_status() return resp.json() ``` ```javascript // Node.js equivalent class KeymasterClient { constructor(baseUrl, clientId, clientSecret) { this.baseUrl = baseUrl; this.clientId = clientId; this.clientSecret = clientSecret; this.token = null; this.tokenExpires = 0; } async getToken(scope = 'push:send') { if (this.token && Date.now() / 1000 < this.tokenExpires - 30) { return this.token; } const resp = await fetch(`${this.baseUrl}/auth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: this.clientId, client_secret: this.clientSecret, scope, }), }); const data = await resp.json(); this.token = data.access_token; this.tokenExpires = Date.now() / 1000 + data.expires_in; return this.token; } async sendPush(userId, title, body, data = {}) { const token = await this.getToken('push:send'); const resp = await fetch(`${this.baseUrl}/push/send`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ user_id: userId, title, body, data }), }); return resp.json(); } } ``` ## Error Responses | Status | Error | Meaning | |--------|-------|---------| | 400 | `unsupported_grant_type` | Only `client_credentials` is supported | | 400 | `invalid_scope: xyz` | Requested scope is not recognized | | 401 | `invalid_client` | Client ID not found, secret wrong, or app inactive | | 429 | `too_many_requests` | Rate limited — check `Retry-After` header | ======================================================================== FILE: push-notifications.md ======================================================================== # Push Notifications ## 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 In the Console: App Detail → Notifications tab → Enable. Keymaster provisions: - A VAPID keypair for Web Push (Safari) - A webhook secret for delivery receipts ### 2. Register Devices (Client-Side) After the user authenticates in your app, request push permission and register the token: ```javascript // 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, }), }); ``` ```swift // 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: ```python 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: ```json 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 - Default: 10,000 notifications per day per app - Configurable by platform admin - Counter resets at midnight UTC - 429 response with `Retry-After` when exceeded ## 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 - `data` field: max 4KB (FCM limit) - `title`: required, max 256 characters - `body`: required, max 4096 characters - Send endpoints verify the target `user_id` is enrolled in the calling app - Push tokens are encrypted at rest in the database ======================================================================== FILE: webhooks.md ======================================================================== # Webhooks ## Overview Keymaster sends outbound webhooks to notify your app of identity events in real time. Webhooks are HMAC-SHA256 signed, retried on failure, and logged for debugging. ## Supported Events | Event | Triggered when | |-------|---------------| | `user.signup` | New user creates an account | | `user.login` | User successfully authenticates | | `user.logout` | User's SSO session is revoked | | `user.role_changed` | User's roles are updated for this app | | `user.suspended` | User's access is suspended | | `user.deleted` | User is removed from the app | | `ping` | Manual test from Console | ## Setup ### 1. Register a Webhook Endpoint Via Console: App Detail → Webhooks → Add Endpoint Via API: ``` POST /admin/apps/{app_id}/webhooks Authorization: Bearer {admin_jwt} { "url": "https://yourapp.com/hooks/keymaster", "events": ["user.signup", "user.login", "user.role_changed"] } ``` Response includes the HMAC secret (shown once): ```json { "id": "webhook-uuid", "secret": "a1b2c3d4...64hexchars", "url": "https://yourapp.com/hooks/keymaster", "events": ["user.signup", "user.login", "user.role_changed"] } ``` ### 2. Receive Webhook Deliveries Keymaster POSTs to your URL with these headers: ``` POST https://yourapp.com/hooks/keymaster Content-Type: application/json X-Keymaster-Event: user.login X-Keymaster-Delivery: delivery-uuid X-Keymaster-Signature: sha256=a1b2c3... X-Keymaster-Timestamp: 1710547200 ``` ### 3. Payload Format ```json { "event": "user.login", "timestamp": "2026-03-17T12:00:00Z", "app_id": "a56e4998-...", "data": { "user_id": "550e8400-...", "email": "user@example.com", "name": "Jane Doe" } } ``` ## Signature Verification **Always verify the signature.** This confirms the webhook came from Keymaster, not an attacker. ```python import hmac import hashlib def verify_keymaster_webhook(body: bytes, signature_header: str, secret: str) -> bool: """Verify HMAC-SHA256 signature from Keymaster webhook.""" expected = hmac.new( secret.encode(), body, hashlib.sha256, ).hexdigest() received = signature_header.replace("sha256=", "") return hmac.compare_digest(expected, received) # In your webhook handler: @app.post("/hooks/keymaster") async def handle_webhook(request): body = await request.body() signature = request.headers.get("X-Keymaster-Signature", "") if not verify_keymaster_webhook(body, signature, WEBHOOK_SECRET): return Response(status_code=401) payload = json.loads(body) event = payload["event"] # Handle the event... ``` ```javascript // Node.js const crypto = require('crypto'); function verifyKeymasterWebhook(body, signatureHeader, secret) { const expected = crypto .createHmac('sha256', secret) .update(body) .digest('hex'); const received = signatureHeader.replace('sha256=', ''); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(received), ); } ``` ## Retry Behavior | Attempt | Delay | Behavior | |---------|-------|----------| | 1st | Immediate | First delivery attempt | | 2nd | 1 second | After first failure | | 3rd | 2 seconds | After second failure | | (fail) | — | Delivery marked as failed, logged in Console | Keymaster considers 2xx responses as success. Any non-2xx or timeout (10s) triggers a retry. ## Delivery Log View delivery history in the Console: App Detail → Webhooks → Click endpoint → Deliveries. Each delivery shows: - Event type - Payload - HTTP status code received - Response body (first 1KB) - Number of attempts - Delivered-at timestamp ## Testing Send a test `ping` event from the Console: ``` POST /admin/apps/{app_id}/webhooks/{webhook_id}/test Authorization: Bearer {admin_jwt} ``` Or click **Test** next to the endpoint in the Console. ## Security Notes - **SSRF protection:** Webhook URLs are validated against private/reserved IP ranges. URLs resolving to `127.0.0.1`, `10.x`, `172.16-31.x`, `192.168.x`, `169.254.x` are blocked. - **No redirects:** Keymaster does not follow HTTP redirects on webhook delivery. Your URL must return a direct response. - **Secret rotation:** Delete the endpoint and recreate it to get a new secret. - **URL validation:** Both on registration and at dispatch time (defense-in-depth). ## Managing Webhooks via API | Endpoint | Method | Description | |----------|--------|-------------| | `/admin/apps/{app_id}/webhooks` | POST | Register a new webhook | | `/admin/apps/{app_id}/webhooks` | GET | List all webhooks for an app | | `/admin/apps/{app_id}/webhooks/{id}` | DELETE | Remove a webhook | | `/admin/apps/{app_id}/webhooks/{id}/test` | POST | Send a test ping | | `/admin/apps/{app_id}/webhooks/{id}/deliveries` | GET | List delivery history | ======================================================================== FILE: changelog.md ======================================================================== # Changelog ## v2.1.0 (2026-03-17) ### Console — Tiered Impersonation - **Tenant admin → app admin impersonation** — tenant admins can enter any app in their tenant as app admin (Console-only, audited, session-scoped) - Platform admin → tenant admin impersonation (introduced in v2.0.0) now stacks with app impersonation - Amber banner with exit link for both impersonation tiers - Audit events: `app_impersonation_started`, `app_impersonation_ended` ### Console — SSO-Based Authentication - Console login now uses SSO session (`km_sso`) directly — no app enrollment required to access the Console - Console JWT issued with audience `keymaster-console` (decoupled from any specific app) - Platform Keymaster Console app serves as auth gateway only (open policy) - Per-tenant Console apps serve as role containers (tenant admin = `admin` role on tenant Console app) ### Console — Multi-Tenant UX - Tenant picker for users who are admin on multiple tenants - "Switch Tenant" sidebar link only shown when user has 2+ tenants - Active tenant stored in session cookie (`km_active_tenant`) ### Fixes - `get_user_tenant_ids` now impersonation-aware — all routes work correctly during impersonation - Platform admins auto-enrolled in Console apps on login regardless of registration policy --- ## v2.0.0 (2026-03-17) ### Per-Tenant Console Isolation - Each tenant now gets its own Console app (`{Tenant Name} Console`), auto-created on tenant bootstrap - Tenant admin access is determined by `admin` role on the per-tenant Console app (replaces `UserTenantRole`) - Platform admins access tenant contexts via impersonation, not enrollment - Tenant creation now requires an admin email — no orphan tenants ### Platform Admin Impersonation - Platform admins can impersonate any tenant admin (Console-only, no SSO tokens issued) - Session-scoped via `km_impersonate` cookie (1-hour TTL) - All impersonated actions logged with `impersonated_by` in audit trail - Visible to tenant admins in their audit log ### SDK Developer Docs Portal - Full SDK documentation at `/docs/` (also accessible from Console sidebar) - LLM-digestible endpoints: `/llms.txt`, `/llms-full.txt` - OpenAPI/Swagger moved to `/api/docs` - `build-docs.sh` script for regenerating docs on feature changes - Version display in Console sidebar footer ### Breaking Changes - `UserTenantRole` table deprecated — tenant admin access now via per-tenant Console app enrollment - Console JWT audience changed from app ID to `keymaster-console` --- ## v1.0.1 (2026-03-17) ### Fixes - Docs routing: handle `.md` extensions in URLs - Swagger/OpenAPI moved to `/api/docs` (was `/docs`) - Landing page updated with SDK docs link --- ## v1.0.0 (2026-03-17) **Initial public release.** ### Authentication - Password + Google + GitHub + Microsoft + Apple OAuth (shared provider registration) - Cross-app SSO via `km_sso` cookie (8-hour sessions) - Magic links (passwordless email login, 15-min expiry) - TOTP 2FA with encrypted secrets + backup codes - OAuth invite-required flow (policy-aware routing for invite-only apps) ### Tokens - RS256 JWT access tokens (configurable TTL, default 15 min) - Refresh token rotation with replay detection (30-day rolling window) - Client credentials grant (`POST /auth/token`, `grant_type=client_credentials`) - Service tokens with scope enforcement (`push:send`) ### User Management - Three-tier admin: platform admin → tenant admin → app admin - App roles: `user`, `manager`, `admin` (standardized) - Manager role: invite/revoke users only, no config access - Invite system with email delivery, per-app branding, expiration - Password policy: 16-char minimum, configurable per tenant ### Console - Full admin Console with dark/light theme - App configuration: branding, auth methods, token TTLs, redirect URIs - User management: invite, roles, suspend, rate limit viewer - Tenant admin dashboard with admin management - Audit log viewer with filtering and pagination - Session management (view/revoke active SSO sessions) - In-app help system (role-scoped, searchable) ### Infrastructure - App logo upload (base64 encoded, served via public endpoint for emails) - Outbound webhooks (HMAC-SHA256 signed, retry with backoff, SSRF protection) - Redis session store (OAuth state, SSO cache, rate limiting) - Health check endpoint (`GET /health`) - Email infrastructure (Postfix DKIM, branded templates) - Auto-apply migrations on startup - OIDC discovery + JWKS endpoints ### Security - Argon2id password hashing - TOTP secret encryption (AES-256-GCM) - Push token encryption at rest - Webhook URL SSRF validation (blocks private/reserved IP ranges) - Rate limiting (Redis + in-memory fallback, auto-clear on success) - Refresh token replay detection with automatic revocation ======================================================================== FILE: api-reference/index.md ======================================================================== # API Reference Complete endpoint documentation for the Keymaster API. ## Base URL ``` https://keymaster.cloud-monitor.com ``` ## Authentication Endpoints use one of three auth methods: | Method | Header | Used by | |--------|--------|---------| | **User JWT** | `Authorization: Bearer {access_token}` | User-facing endpoints (device registration, account) | | **Service JWT** | `Authorization: Bearer {client_credentials_jwt}` | Server-to-server (push, future APIs) | | **Admin JWT** | `Authorization: Bearer {console_jwt}` | Admin/management endpoints | ## Endpoint Groups - [Authentication](auth.md) — Login, signup, invite accept, password reset, magic links, TOTP - [Tokens](tokens.md) — Refresh, revoke, verify, client credentials, JWKS, OIDC discovery - [Admin](admin.md) — App management, user management, invites, tenant management - [Push](push.md) — Device registration, send notifications, delivery receipts - [Webhooks](webhooks.md) — Register, list, delete, test, delivery log ## Common Response Patterns ### Success ```json { "status": "ok" } ``` ### Error ```json { "detail": "Human-readable error message" } ``` ### Structured Error (for client-side handling) ```json { "detail": { "error": "error_code", "message": "Human-readable explanation", "setup_url": "/account" } } ``` ### Rate Limited ``` HTTP 429 Too Many Requests Retry-After: 583 { "error": "rate_limited", "retry_after": 583 } ``` ======================================================================== FILE: api-reference/auth.md ======================================================================== # Authentication Endpoints ## POST /auth/login Authenticate with email + password. **Request:** ```json { "email": "user@example.com", "password": "their_password", "app_id": "uuid", "device_id": "optional-device-identifier" } ``` **Success Response (200):** ```json { "access_token": "eyJhbG...", "refresh_token": "a1b2c3...", "token_type": "Bearer", "expires_in": 900 } ``` **TOTP Required (200):** ```json { "status": "totp_required", "totp_session": "short-lived-jwt" } ``` **Errors:** | Status | Detail | Meaning | |--------|--------|---------| | 400 | Invalid app_id | App not found or inactive | | 400 | Password login not enabled | App doesn't allow password auth | | 401 | Invalid email or password | Bad credentials | | 403 | You do not have access | Not enrolled, invite required | | 403 | Account suspended | User suspended for this app | | 403 | `{"error": "2fa_required", ...}` | App requires 2FA, user hasn't set it up | | 429 | rate_limited | Too many attempts (5/15min per email, 20/15min per IP) | --- ## POST /auth/totp/verify Complete TOTP challenge after password login. **Request:** ```json { "totp_session": "jwt-from-login-response", "code": "123456" } ``` **Success Response (200):** Same as login success (access + refresh tokens). **Errors:** | Status | Detail | |--------|--------| | 400 | Invalid or expired TOTP session | | 401 | Invalid TOTP code | | 429 | Too many TOTP attempts | --- ## POST /auth/signup Create a new account and enroll (open/approval apps only). **Request:** ```json { "email": "user@example.com", "password": "their_password", "display_name": "Jane Doe", "app_id": "uuid" } ``` **Success Response (200):** Access + refresh tokens. **Errors:** | Status | Detail | |--------|--------| | 400 | This app requires an invite code | | 400 | Account already exists | | 400 | Password must be at least N characters | --- ## POST /auth/accept-invite Accept an invite code. Handles both existing users and new registrations. **Request:** ```json { "app_id": "uuid", "invite_code": "ABCD1234", "email": "user@example.com", "display_name": "Jane Doe", "password": "optional_for_new_users" } ``` For existing users (invite was sent to their email): only `app_id` and `invite_code` are required. **Success Response (200):** Access + refresh tokens. --- ## POST /auth/forgot-password Request a password reset email. **Request:** ```json { "email": "user@example.com", "app_id": "uuid" } ``` **Response (200):** Always succeeds (doesn't reveal whether account exists). ```json { "detail": "If an account exists with that email, a reset link has been sent." } ``` --- ## POST /auth/reset-password Reset password using a token from the reset email. **Request:** ```json { "token": "reset-token-from-email", "password": "new_secure_password" } ``` **Success Response (200):** ```json { "status": "ok" } ``` Revokes all SSO sessions and unused reset tokens for the user. --- ## POST /auth/magic-link/request Request a magic link email for passwordless login. **Request:** ```json { "email": "user@example.com", "app_id": "uuid" } ``` **Response (200):** ```json { "status": "sent" } ``` --- ## GET /auth/magic-link/verify Verify a magic link token (user clicks the link in their email). **Query Params:** `?token={magic_link_token}` **Success:** Redirects to the app's redirect_uri with access + refresh tokens. **Failure:** Redirects to login with `error=invalid_magic_link`. --- ## OAuth Provider Endpoints ### GET /oauth/{provider}/start Initiate OAuth flow. Redirects to provider consent screen. **Providers:** `google`, `github`, `microsoft`, `apple` **Query Params:** - `app_id` (required) — UUID of the app - `redirect_uri` (optional) — Must match a registered redirect URI ### GET /oauth/{provider}/callback OAuth callback handler. **Do not call directly** — Keymaster handles this. ### POST /oauth/invite-accept Accept an invite code using a pending OAuth identity (when an OAuth user hits an invite-only app). **Request:** ```json { "oauth_token": "pending-oauth-session-token", "app_id": "uuid", "invite_code": "ABCD1234", "display_name": "Jane Doe" } ``` **Success Response (200):** ```json { "redirect": "https://yourapp.com/auth/callback?access_token=...&refresh_token=..." } ``` ======================================================================== FILE: api-reference/tokens.md ======================================================================== # Token Endpoints ## POST /token/refresh Exchange a valid refresh token for a new access + refresh token pair. The old refresh token is revoked (rotation). **Request:** ```json { "refresh_token": "a1b2c3d4...", "app_id": "uuid" } ``` **Success Response (200):** ```json { "access_token": "eyJhbG...", "refresh_token": "new-token...", "token_type": "Bearer", "expires_in": 900 } ``` **Errors:** | Status | Detail | Meaning | |--------|--------|---------| | 401 | Invalid or expired refresh token | Token not found, expired, or already used | | 401 | Token does not belong to this app | app_id mismatch | | 403 | Account suspended | User suspended for this app | **Replay Detection:** If a revoked token is reused, ALL refresh tokens for that user+app are revoked. --- ## POST /token/revoke Revoke a refresh token (logout). **Request:** ```json { "refresh_token": "a1b2c3d4..." } ``` **Response (200):** ```json { "status": "ok" } ``` Always returns 200, even if the token doesn't exist (prevents information leakage). --- ## POST /token/verify Verify an access token and return its claims. For apps that can't do local JWT verification. **Request:** ```json { "token": "eyJhbG...", "audience": "your-app-id" } ``` **Valid Response:** ```json { "valid": true, "claims": { "sub": "user-uuid", "email": "user@example.com", "roles": ["user", "admin"], "aud": "app-uuid", "iss": "https://keymaster.cloud-monitor.com", "exp": 1710548100 } } ``` **Invalid Response:** ```json { "valid": false, "error": "Token has expired" } ``` **Note:** Prefer local JWKS verification for production. This endpoint is for debugging and apps that can't handle JWTs locally. --- ## POST /auth/token OAuth2 token endpoint. Currently supports `client_credentials` grant. **Request (form-encoded):** ``` grant_type=client_credentials &client_id=app-uuid &client_secret=your-secret &scope=push:send ``` **Success Response (200):** ```json { "access_token": "eyJhbG...", "token_type": "bearer", "expires_in": 900, "scope": "push:send" } ``` **Errors:** | Status | Detail | |--------|--------| | 400 | `unsupported_grant_type` | | 400 | `invalid_scope: xyz` | | 401 | `invalid_client` | | 429 | `too_many_requests` (10 attempts/15min per client_id) | --- ## GET /.well-known/jwks.json Public keys for JWT verification. Cache these and refresh hourly or when `kid` doesn't match. **Response:** ```json { "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "a1b2c3d4e5f6", "n": "base64url-encoded-modulus...", "e": "AQAB" } ] } ``` --- ## GET /.well-known/openid-configuration OIDC discovery document. **Response:** ```json { "issuer": "https://keymaster.cloud-monitor.com", "authorization_endpoint": "https://keymaster.cloud-monitor.com/login", "token_endpoint": "https://keymaster.cloud-monitor.com/auth/token", "userinfo_endpoint": "https://keymaster.cloud-monitor.com/userinfo", "jwks_uri": "https://keymaster.cloud-monitor.com/.well-known/jwks.json", "revocation_endpoint": "https://keymaster.cloud-monitor.com/token/revoke", "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "scopes_supported": ["openid", "profile", "email", "roles", "push:send"], "token_endpoint_auth_methods_supported": ["client_secret_post"], "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"] } ``` ======================================================================== FILE: api-reference/admin.md ======================================================================== # Admin Endpoints All admin endpoints require a valid admin JWT (platform admin, tenant admin, or app admin depending on the operation). ## Apps ### POST /admin/apps Create a new app. **Auth:** Platform admin or tenant admin **Request:** ```json { "name": "My App", "tenant_id": "tenant-uuid", "bundle_id": "com.example.myapp", "registration_policy": "invite", "config": { "allowed_providers": ["password", "google"], "redirect_uris": ["https://myapp.com/auth/callback"], "available_roles": ["user", "manager", "admin"], "auto_assign_roles": ["user"], "token_lifetime_minutes": 15, "refresh_lifetime_days": 30, "branding": { "primary_color": "#9392c7", "logo_url": null, "logo_height": 80 } } } ``` **Response (201):** The created app object including `id` and `client_secret` (shown once). ### PUT /admin/apps/{app_id} Update app configuration. **Auth:** Platform admin, tenant admin, or app admin (managers excluded) **Request:** Partial update — only include fields to change. ```json { "name": "Updated Name", "config": { "allowed_providers": ["password", "google", "magic_link"], "branding": { "primary_color": "#d1001f" } } } ``` **Note:** Uploaded `logo_data` is preserved automatically when saving config without it. ### POST /admin/apps/{app_id}/logo Upload an app logo. **Auth:** Platform admin, tenant admin, or app admin **Request:** Multipart form data with `file` field (PNG, JPEG, SVG, max 2MB, resized to 256px). ### POST /admin/apps/{app_id}/regenerate-secret Generate a new client secret. Old secret is invalidated immediately. **Auth:** Platform admin, tenant admin, or app admin **Response:** ```json { "client_secret": "new-secret-shown-once" } ``` --- ## Users ### GET /admin/users List all users (platform admin only). **Query Params:** `?page=1&limit=50&search=email` ### POST /admin/users Create a user. **Auth:** Platform admin **Request:** ```json { "email": "user@example.com", "display_name": "Jane Doe", "password": "optional" } ``` ### PUT /admin/users/{user_id}/roles Assign roles for a user in an app. **Auth:** Platform admin, tenant admin, or app admin (managers excluded) **Request:** ```json { "app_id": "uuid", "roles": ["user", "admin"] } ``` The `user` role is always included (enforced by `ensure_base_role()`). ### POST /admin/users/{user_id}/suspend Suspend a user's access to an app. **Auth:** Platform admin, tenant admin, or app admin **Request:** ```json { "app_id": "uuid" } ``` ### DELETE /admin/users/{user_id} Remove a user's enrollment from an app. **Auth:** Platform admin, tenant admin, or app admin **Request:** ```json { "app_id": "uuid" } ``` ### PUT /admin/users/{user_id}/password Change a user's password (admin override). **Auth:** Platform admin only **Request:** ```json { "new_password": "new_secure_password" } ``` Password minimum length is enforced per the user's strictest tenant policy. ### GET /admin/users/{user_id}/rate-limits Check rate limit status for a user. **Auth:** Platform admin only **Response:** ```json { "user_id": "uuid", "email": "user@example.com", "rate_limits": [ { "label": "Login (email)", "key": "rl:login:user@example.com", "count": 4, "ttl": 583 } ] } ``` ### POST /admin/users/{user_id}/rate-limits/clear Clear all rate limits for a user. **Auth:** Platform admin only --- ## Invites ### POST /admin/apps/{app_id}/invites Create and optionally send an invite. **Auth:** Platform admin, tenant admin, app admin, or app manager (managers restricted to `user` role only) **Request:** ```json { "app_id": "uuid", "roles": ["user"], "send_to_email": "invitee@example.com", "max_uses": 1, "expires_days": 7 } ``` **Response:** ```json { "code": "ABCD1234", "app_id": "uuid", "roles": ["user"], "expires_at": "2026-03-24T00:00:00Z", "email_sent": true } ``` --- ## Tenant Admin Management ### POST /admin/tenants/{tenant_id}/admins Invite a new tenant admin. **Auth:** Platform admin or existing tenant admin for this tenant **Request:** ```json { "email": "newadmin@example.com" } ``` ### DELETE /admin/tenants/{tenant_id}/admins/{user_id} Remove a tenant admin. Cannot remove yourself. **Auth:** Platform admin or existing tenant admin for this tenant ======================================================================== FILE: api-reference/push.md ======================================================================== # Push Notification Endpoints All send endpoints require a service JWT with `scope=push:send` (from client credentials grant). Device registration uses the user's access JWT. ## POST /push/devices/register Register a device for push notifications. **Auth:** User JWT **Request:** ```json { "app_id": "uuid", "platform": "web" | "ios" | "android", "push_token": "fcm-token-or-web-push-subscription" } ``` **Response (201):** ```json { "device_id": "uuid" } ``` Deduplicates on `(app_id, token_hash)`. Re-registering updates `last_seen_at`. **Rate limit:** 10 per user per hour. --- ## DELETE /push/devices/{device_id} Unregister a device. **Auth:** User JWT (own devices) OR service JWT with `push:send` scope (app's devices) **Response (200):** ```json { "status": "ok" } ``` --- ## POST /push/send Send a notification to a single user (all their registered devices for this app). **Auth:** Service JWT with `push:send` scope **Request:** ```json { "user_id": "uuid", "title": "Your shift starts in 30 min", "body": "Open GymOps to view details.", "data": { "route": "/schedule" }, "ttl": 3600 } ``` **Response (200):** ```json { "notification_id": "uuid" } ``` **Validation:** - `user_id` must be enrolled in the calling app - `data` max 4KB - `title` max 256 chars - `body` max 4096 chars --- ## POST /push/send/bulk Send the same notification to multiple users. **Auth:** Service JWT with `push:send` scope **Request:** ```json { "user_ids": ["uuid1", "uuid2", "uuid3"], "title": "Gym closes early today", "body": "Closing at 6pm.", "data": {} } ``` **Response (200):** ```json { "notification_ids": ["uuid1", "uuid2", "uuid3"] } ``` Max `user_ids`: 500. --- ## POST /push/send/batch Send different notifications in one call. **Auth:** Service JWT with `push:send` scope **Request:** ```json { "notifications": [ { "user_id": "uuid1", "title": "Shift approved", "body": "Your request was approved." }, { "user_id": "uuid2", "title": "Time off denied", "body": "Please contact your manager." } ] } ``` Max notifications: 500. --- ## Errors | Status | Detail | Meaning | |--------|--------|---------| | 400 | User not enrolled | Target user_id not in the calling app | | 400 | Data payload too large | `data` field exceeds 4KB | | 401 | Invalid token | Missing or invalid service JWT | | 403 | Missing required scope | JWT doesn't have `push:send` | | 404 | Device not found | Device ID doesn't exist or belongs to another app | | 429 | Daily quota exceeded | App has hit its notification limit |