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 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

ThingWhat it is
AppOne of your products. Has an App ID (app_...), keys and its own push credentials.
Secret keylk_live_.... Server only. Sends rings and notifications, manages users. Shown once.
Publishable keylk_pub_.... Safe to ship in an app or a website. Only registers devices.
UserYour own user id, called externalId (at most 128 characters). Lakolosy never needs your user table.
DeviceA phone or a browser. The SDK registers it and links it to a user with login(externalId).
RingA full-screen incoming call to every device of a user. Ends as accepted, declined, missed or cancelled.
NotificationA 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.
  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.

// 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).

  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:
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 pathAuthDoes
POST /ringssecretRing a user or a device
GET /ringssecretList rings. Query: externalId, status, limit (1 to 50), cursor
GET /rings/:idsecret, caller, ringRead a ring. ?expand=deliveries,events with the secret key
POST /rings/:id/cancelsecret, callerStop a ring that is still ringing. Body: { reason? }
POST /rings/:id/endsecret, caller, ringEnd an accepted call
POST /rings/:id/accept, /decline, /reportringUsed by the SDK on the phone
POST /notificationssecretSend a notification
GET /notifications/:idsecretRead a notification and its stats
PUT /users/:externalIdsecretCreate or update a user: { name?, avatarUrl?, locale?, metadata? }
GET /users/:externalIdsecretA user with their devices
GET /users/:externalId/devicessecretThe devices of a user
DELETE /users/:externalIdsecretDelete a user. Their devices are unlinked, their rings stay
POST /devicespublishableRegister a device (the SDKs do this)
PATCH /devices/:id, POST /devices/:id/identify, /logoutpublishable + Lakolosy-Device-SecretUsed by the SDKs
DELETE /devices/:idsecret, or publishable + device secretDelete a device
GET /web-push/configpublishable, secretThe 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 (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

{
  "error": {
    "type": "invalid_request_error",
    "code": "validation_failed",
    "message": "...",
    "errors": [{ "field": "to", "message": "..." }],
    "requestId": "req_..."
  }
}
CodeMeans
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.