# Lakolosy > Make any phone ring. One API call shows a full-screen incoming ring on iOS (VoIP push and CallKit) and Android (FCM and a full-screen notification), even when the phone is locked or the app is closed. It also sends normal push notifications to iOS, Android and browsers. - API: `https://api.lakolosy.marindrano.com/v1` - Studio (dashboard): `https://lakolosy.marindrano.com/studio` - These docs: `https://lakolosy.marindrano.com/docs`, plain text at `https://lakolosy.marindrano.com/llms.txt` - SDKs: `@lakolosy/expo` (the app), `@lakolosy/node` (your server), `@lakolosy/web` (browsers). They live in the Lakolosy monorepo under `packages/` and are not on npm yet: install them from a path, for example `pnpm add @lakolosy/node@file:../lakolosy/packages/node`. They ship TypeScript sources, so use a bundler or a runtime that reads TypeScript (Metro, Vite, Workers, Bun, tsx). ## How it fits together | Thing | What it is | |---|---| | App | One of your products. Has an App ID (`app_...`), keys and its own push credentials. | | Secret key | `lk_live_...`. Server only. Sends rings and notifications, manages users. Shown once. | | Publishable key | `lk_pub_...`. Safe to ship in an app or a website. Only registers devices. | | User | Your own user id, called `externalId` (at most 128 characters). Lakolosy never needs your user table. | | Device | A phone or a browser. The SDK registers it and links it to a user with `login(externalId)`. | | Ring | A full-screen incoming call to every device of a user. Ends as accepted, declined, missed or cancelled. | | Notification | A normal push to users, devices, or every device of the app. | The flow: the app calls `Lakolosy.login(userId)`, your server calls `lakolosy.ring({ to: userId, ... })`, the phone rings, the app gets a `ringAccepted` event and joins the call. ## Setup in the Studio 1. Create an app. Copy the secret key (shown once) and the publishable key from **API keys**. 2. **Integrations**, connect what you need: - **Apple APNs**: the `.p8` key, Key ID, Team ID and the app's bundle ID. One key works for the Simulator, dev builds and the App Store: each device reports its own APNs environment. - **Firebase FCM**: the service account JSON of the Firebase project of the Android app. - **Cloudflare RealtimeKit** (optional, for audio and video calls): account ID, RealtimeKit app ID, and an API token with the Realtime Admin permission. - **Web push** (optional): generate VAPID keys, see [Web push](#web-push). 3. **App settings**: ring timeout (5 to 120 seconds, default 30), identity verification, iOS alerts for non-call rings. 4. **Test console**: send a test ring or push to any user, and watch each delivery. ## Expo app `@lakolosy/expo`. Needs a development build (no Expo Go). Peer dependencies: `expo-notifications`, `expo-device`, `expo-application`, `expo-secure-store`. For calls, also `@cloudflare/realtimekit-react-native` and `@cloudflare/react-native-webrtc`. ```ts // app.config.ts plugins: [ ["expo-notifications", { defaultChannel: "lakolosy_default" }], ["@lakolosy/expo", { microphonePermission: "$(PRODUCT_NAME) uses the microphone during calls.", cameraPermission: "$(PRODUCT_NAME) uses the camera during video calls.", // apiUrl, apsEnvironment: "production", notificationImages: false (see Images) }], ], android: { googleServicesFile: "./google-services.json" }, ``` The plugin adds the iOS background modes (voip, remote-notification, audio), the `aps-environment` entitlement, the Android call permissions, CallKit and Android Telecom ringing, and the iOS image extension. Run `npx expo prebuild --clean` after adding it. ```ts import { Lakolosy, useLakolosy } from "@lakolosy/expo"; // Once, at module scope (entry file or root layout), never inside a component. Lakolosy.initialize({ appId: "app_...", publishableKey: "lk_pub_..." }); // From your own prompt or onboarding. Returns { permission, canFullScreen, ... }. await Lakolosy.requestPermissions(); // Android 14+, when canFullScreen is false: the screen that allows full-screen rings. await Lakolosy.openFullScreenIntentSettings(); // On every start, once your user is known. On sign out: Lakolosy.logout(). await Lakolosy.login(user.id, { name: user.name }); // A ring is on screen. Lakolosy.addListener("ring", (ring) => {}); // The user answered. call.joinToken joins the RealtimeKit meeting. Lakolosy.addListener("ringAccepted", ({ ring, call }) => { router.push(`/call/${ring.ringId}`); }); // reason: declined, missed, cancelled, answeredElsewhere, ended or failed. Lakolosy.addListener("ringEnded", ({ ring, reason }) => {}); // A notification arrived while the app is open, or was tapped. n.data is your data. Lakolosy.addListener("notification", (n) => {}); Lakolosy.addListener("notificationOpened", (n) => {}); // Background failures (registration, ring reports). Lakolosy.addListener("error", (error) => {}); // { deviceId, externalId, permission, canFullScreen, registered, ... }, as a hook. const state = useLakolosy(); // The accepted call of a ring, also after a cold start. Lakolosy.getCall(ringId); // Hang up (or decline) the current call and tell the API. await Lakolosy.endCall(); ``` The call screen, when the ring uses RealtimeKit: ```tsx import { LakolosyCallView } from "@lakolosy/expo/realtimekit"; router.back()} /> ``` Rings need a real iPhone: the iOS Simulator ends incoming calls right away. Push notifications work in the Simulator. ## Server `@lakolosy/node`, for Node, Bun, Deno and Workers (it only needs `fetch`). ```ts import { Lakolosy, LakolosyApiError } from "@lakolosy/node"; const lakolosy = new Lakolosy({ secretKey: process.env.LAKOLOSY_SECRET_KEY! }); // Ring const ring = await lakolosy.ring({ // Or { externalId, name, avatarUrl } (creates the user), { userId } or { deviceId }. to: "user_123", caller: { id: "u_9", name: "Mom", avatarUrl: "https://..." }, // Your JSON, 2 KB at most, given back to the app with the ring. data: { taskId: "t_981" }, // Or { provider: "custom", id } for your own calls, { provider: "none" } (default). call: { provider: "realtimekit" }, video: false, timeoutSeconds: 30, }, { idempotencyKey: "task-t_981" }); await lakolosy.rings.get(ring.id, { expand: ["deliveries", "events"] }); await lakolosy.rings.list({ externalId: "user_123", status: "missed", limit: 20 }); // While it rings, and after it was accepted. await lakolosy.rings.cancel(ring.id, { reason: "Caller hung up" }); await lakolosy.rings.end(ring.id); // Notify await lakolosy.notify({ // Or { externalIds: [...] }, { deviceIds: [...] } (90 at most), { all: true }. to: "user_123", title: "Your order shipped", body: "It arrives tomorrow.", data: { orderId: "o_42" }, // Where a click goes in a browser. url: "https://shop.example/orders/o_42", imageUrl: "https://cdn.example/box.jpg", }); // Users await lakolosy.users.upsert("user_123", { name: "Rova", avatarUrl: "https://..." }); // The user and their devices. await lakolosy.users.get("user_123"); // Deletes the user. Their devices are unlinked. await lakolosy.users.delete("user_123"); // For identity verification. Signed locally, no request. await lakolosy.users.createToken("user_123"); await lakolosy.devices.delete("dev_..."); ``` Errors throw `LakolosyApiError` with `status`, `code`, `message`, `requestId` and `errors` (one per invalid field). Timeouts and network failures have `status: 0` and code `timeout` or `network_error`. The ring you get back has a `callerToken`: hand it to the caller's own app so it can read the ring (`GET /v1/rings/:id`), cancel it or end it, without your secret key. With RealtimeKit, `ring.call.callerJoinToken` lets the caller join the meeting while the phone is still ringing. ## Web push Browsers get normal notifications (never rings) through standard Web Push: Chrome, Edge, Firefox and Safari. Needs https (`localhost` works). On iPhone and iPad, only in a web app added to the Home Screen (iOS 16.4 and later). 1. Studio, **Integrations**, **Web push**: generate a VAPID key pair with a contact (`mailto:` or an https URL), or import the keys of another provider so its subscribers keep working. 2. Serve the service worker at your site root: copy `packages/web/service-worker/lakolosy-sw.js` (also at `https://lakolosy.marindrano.com/lakolosy-sw.js`). With your own worker, add `importScripts("/lakolosy-sw.js")` to it and pass `serviceWorkerPath`. Icon and badge: `/lakolosy-sw.js?icon=/icon-192.png&badge=/badge-72.png`. 3. In your site: ```ts import { Lakolosy } from "@lakolosy/web"; // Costs no request until the visitor opts in or logs in. Lakolosy.initialize({ appId: "app_...", publishableKey: "lk_pub_..." }); // From a click: Safari and Firefox only ask during a user gesture. button.onclick = () => Lakolosy.requestPermission(); // On every page load, once the user is known. On sign out: Lakolosy.logout(). await Lakolosy.login(user.id); Lakolosy.isSupported(); // "granted", "denied", "undetermined" or "unsupported". Lakolosy.getPermission(); ``` Send to browsers like to phones. The Studio Test console can add the browser you are using as a device of any user (**Add this browser**), so you can test without a website. ## Images Set `imageUrl` (https) on a notification. Android shows it through FCM. iOS shows it through a Notification Service Extension that the Expo plugin adds. iOS runs one extension per app, so if you already have one (OneSignal, for example), set the plugin option `notificationImages: false`. Chrome and Edge show it on Windows, Android and ChromeOS. Keep images under 1 MB: when the download fails, the notification arrives without it. ## REST API Base URL `https://api.lakolosy.marindrano.com/v1`. JSON in and out. Auth: `Authorization: Bearer `. | Method and path | Auth | Does | |---|---|---| | `POST /rings` | secret | Ring a user or a device | | `GET /rings` | secret | List rings. Query: `externalId`, `status`, `limit` (1 to 50), `cursor` | | `GET /rings/:id` | secret, caller, ring | Read a ring. `?expand=deliveries,events` with the secret key | | `POST /rings/:id/cancel` | secret, caller | Stop a ring that is still ringing. Body: `{ reason? }` | | `POST /rings/:id/end` | secret, caller, ring | End an accepted call | | `POST /rings/:id/accept`, `/decline`, `/report` | ring | Used by the SDK on the phone | | `POST /notifications` | secret | Send a notification | | `GET /notifications/:id` | secret | Read a notification and its stats | | `PUT /users/:externalId` | secret | Create or update a user: `{ name?, avatarUrl?, locale?, metadata? }` | | `GET /users/:externalId` | secret | A user with their devices | | `GET /users/:externalId/devices` | secret | The devices of a user | | `DELETE /users/:externalId` | secret | Delete a user. Their devices are unlinked, their rings stay | | `POST /devices` | publishable | Register a device (the SDKs do this) | | `PATCH /devices/:id`, `POST /devices/:id/identify`, `/logout` | publishable + `Lakolosy-Device-Secret` | Used by the SDKs | | `DELETE /devices/:id` | secret, or publishable + device secret | Delete a device | | `GET /web-push/config` | publishable, secret | The app's VAPID public key | Ring body (`POST /rings`): `to` (required), `title` or `caller.name` (one is required), `caller { id, name, avatarUrl }`, `kind` (`call` default, or `alert`: on iOS a Time Sensitive notification instead of CallKit), `video`, `timeoutSeconds` (5 to 120), `data`, `call`. Ring status: `ringing`, then `accepted`, `declined`, `missed`, `cancelled` or `failed`. `endReason`: `declined`, `timeout`, `cancelled_by_caller`, `cancelled_by_api`, `no_devices`, `all_deliveries_failed`, `call_ended`. Notification body (`POST /notifications`): `to` (required), `title` or `body` (one is required), `subtitle`, `data`, `url`, `imageUrl`, `sound`, `badge`, `priority` (`high` default, or `normal`), `ttlSeconds` (up to 28 days), `collapseId` (replaces an older notification), `ios { interruptionLevel, threadId, category }`, `android { channelId, tag }`. The response lists `errors.unknownExternalIds` and `errors.externalIdsWithoutDevices`. ```bash curl https://api.lakolosy.marindrano.com/v1/rings \ -H "Authorization: Bearer $LAKOLOSY_SECRET_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: task-t_981" \ -d '{ "to": "user_123", "caller": { "name": "Mom" }, "data": { "taskId": "t_981" } }' ``` - **Idempotency**: send `Idempotency-Key` (or `idempotencyKey` in the body) on `POST /rings` and `POST /notifications`. A retry with the same key returns the first result with `Idempotent-Replayed: true` instead of ringing twice. - **Rate limit**: 100 per minute per app for each kind of write (rings, notifications, users), and per app and IP for device writes. Over it: `429 rate_limited` with `Retry-After: 60`. - **Body size**: 64 KB per request. A web push payload must stay under about 4 KB once encrypted. - **Request id**: every response has `Lakolosy-Request-Id`. Give it when something looks wrong. ### Errors ```json { "error": { "type": "invalid_request_error", "code": "validation_failed", "message": "...", "errors": [{ "field": "to", "message": "..." }], "requestId": "req_..." } } ``` | Code | Means | |---|---| | `missing_api_key`, `invalid_api_key` (401) | No key, or a wrong or revoked one | | `key_not_allowed` (403) | That kind of key cannot call this endpoint (a publishable key sending a ring, for example) | | `validation_failed` (400) | See `errors` for each field | | `invalid_json` (400) | The body is not a JSON object | | `user_not_found` (404) | No user with this `externalId` | | `web_push_not_configured` (404) | No VAPID keys yet | | `invalid_device_secret`, `invalid_ring_token` (401) | SDK credentials expired or wrong | | `app_suspended` (403) | The app is suspended | | `payload_too_large` (413) | The body is over 64 KB, or a web push payload is over about 4 KB | | `rate_limited` (429) | Wait a minute | ## Identity verification Off by default: any app holding the publishable key can call `login("someone")`. Turn it on in **App settings** to require a user token. Your server mints it with `lakolosy.users.createToken(externalId)` (HS256, signed locally, 30 days at most), your app passes it to `Lakolosy.login(id, { userToken })`. ## Rules worth knowing - iOS kills an app that gets a VoIP push without showing a call, so every ring shows CallKit, even a cancelled one (it ends right away). - Apple may reject CallKit for things that are not calls. Use `kind: "alert"` for alarms and reminders: iOS shows a Time Sensitive notification instead. - Android 14 and later need the user to allow full-screen rings: call `openFullScreenIntentSettings()` when `canFullScreen` is false. - A ring to a user rings all their phones. The first device to answer wins, the others stop ringing. - Rings skip browsers. Notifications reach phones and browsers. - `.p8` keys, FCM service accounts and VAPID private keys are stored encrypted and never shown again.