When someone joins through a phone invite, the owner and that person can message and call each other inside Allternit for free. Nobody else can: every route checks that the two people are linked (an invite_contacts row, in either direction) and answers 403 {"error":"not_a_contact"} otherwise. All routes are on api.allternit.com and take a Clerk session or a compute-scoped API key. :userId is always the other person. Migration 038_inapp_calls.sql is applied by hand to the production database. Threads and call state live in the cloud, so a sleeping runtime never blocks a ring. Ringing needs the LiveKit env from Voice; without it the ring route answers 503 {"error":"livekit_not_configured"} and the thread routes keep working.

Threads

POST /api/v1/phone/contacts/:userId/thread with {"botId": "bot_1"} returns {"threadId": "dm_…", "created": true}. It is one thread per owner, owner’s bot and contact, and both people get the same id. botId is the owner’s bot; a contact who is ringing or messaging the owner may leave it blank.
  • GET /api/v1/phone/threads/:id/events?after=<eventId>&limit=200 returns {"events":[{"id","threadId","kind","senderUserId","body","room","createdAt"}]}. Both people read the same log, oldest first. Poll with the last id you saw as after.
  • POST /api/v1/phone/threads/:id/messages with {"text": "…"} (1 to 4000 characters) returns 201 {"event": {…}}.
kind is message, call.ring, call.answered, call.declined, call.cancelled or call.missed. Calls carry the LiveKit room. Anyone who isn’t one of the two people gets 404 thread_not_found.

Ringing

POST /api/v1/phone/contacts/:userId/ring with {"botId": "bot_1", "mode": "voice"}:
The caller joins room with token at url straight away. A ring lasts 45 seconds. Errors: If the callee has turned on Let my bot answer (PUT /api/v1/phone/call-prefs {"autoAnswerBotId": "bot_9"}; null turns it off, and the bot must be theirs), nobody rings: the room is created with the allternit-voice agent, the call is answered at once and the response has "state": "answered", "answeredBy": "agent". That works even when the callee is offline. If the callee’s app is closed but they registered a device for Web Push, the ring goes through anyway: the response is the normal ringing one and their devices get an “Incoming call” notification. When the ring ends unanswered (45 seconds) or the caller cancels, the same notification is replaced by “Missed call from …”. A new thread message also pushes the other person, at most one push per thread per 20 seconds.

Presence

A person is online when their app asked for incoming calls in the last 60 seconds, or one of their paired runtimes checked in. GET /api/v1/phone/incoming is the poll and the heartbeat in one; POST /api/v1/phone/presence is the heartbeat alone. Apps poll every 4 to 5 seconds while foregrounded.

Incoming calls

GET /api/v1/phone/incoming returns the rings waiting for the signed-in user:
from.label is the name the owner gave the contact; a contact sees the owner’s first name. The callee’s join token is minted when they answer, not before.

Answer, decline, cancel

:userId is the other person. A ring only moves once: ringing goes to answered, declined, cancelled or missed. Acting on one that is over answers 410 call_over; an unknown room, or one that isn’t yours to act on, answers 404 call_not_found. A ring nobody answers becomes missed within about 10 seconds and the thread gets a call.missed event.