Docs
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.
Working with an agent? Give it /llms.txt- API:
https://api.lakolosy.marindrano.com/v1 - Studio (dashboard):
https://lakolosy.marindrano.com/studio - These docs:
https://lakolosy.marindrano.com/docs, plain text athttps://lakolosy.marindrano.com/llms.txt - SDKs:
@lakolosy/expo(the app),@lakolosy/node(your server),@lakolosy/web(browsers). They live in the Lakolosy monorepo underpackages/and are not on npm yet: install them from a path, for examplepnpm 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
- Create an app. Copy the secret key (shown once) and the publishable key from API keys.
- Integrations, connect what you need:
- Apple APNs: the
.p8key, 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.
- Apple APNs: the
- App settings: ring timeout (5 to 120 seconds, default 30), identity verification, iOS alerts for non-call rings.
- 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.
// 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.
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:
import { LakolosyCallView } from "@lakolosy/expo/realtimekit";
<LakolosyCallView
joinToken={call.joinToken}
video={ring.video}
ringId={ring.ringId}
onLeave={() => 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).
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).
- 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. - Serve the service worker at your site root: copy
packages/web/service-worker/lakolosy-sw.js(also athttps://lakolosy.marindrano.com/lakolosy-sw.js). With your own worker, addimportScripts("/lakolosy-sw.js")to it and passserviceWorkerPath. Icon and badge:/lakolosy-sw.js?icon=/icon-192.png&badge=/badge-72.png. - In your site:
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 <key or token>.
| 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.
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(oridempotencyKeyin the body) onPOST /ringsandPOST /notifications. A retry with the same key returns the first result withIdempotent-Replayed: trueinstead 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_limitedwithRetry-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
{
"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()whencanFullScreenis 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.
.p8keys, FCM service accounts and VAPID private keys are stored encrypted and never shown again.