api.allternit.com and take a Clerk session or a compute-scoped API key. The /i/:code/* routes are public: the unguessable code is the credential, and they are rate limited per IP, per invite and per phone number. The invite page itself is served from allternit.com/i/<code> (it redirects to m.allternit.com/i/<code>).
Migration 035_phone_invites.sql is applied by hand to the production database. Sending the code needs the carrier env from Phone numbers and SMS; without it the phone step answers 503 {"error":"phone_not_configured"}. Creating, listing and revoking invites works without it.
Owner routes
POST /api/v1/inviteswith{botId, numberId?, label, about, botName?}returns201 {id, url, expiresAt}.numberIddefaults to the bot’s number; a bot with no number gets409 bot_has_no_number. The link is single use and expires after 14 days. The code appears only inurl, once.GET /api/v1/invites?botId=returns{invites:[{id, botId, numberId, label, about, status, phoneLast4, expiresAt, createdAt}]}.statusispending,verified,joining,joined,declined,revokedorexpired.DELETE /api/v1/invites/:idrevokes a link that is not yet joined or declined. Returns{ok:true}.GET /api/v1/phone/contacts?botId=returns an array of{e164?, userId?, label, reach:{app, sms, call, email}, consent:{basis, at}|null, invite:{status}}. It merges people who confirmed through an invite, people who joined the app through one, invites still waiting (consentisnull), and anyone else the number has a consent record for (invite.statusisnone).reach.smsandreach.callturn off after STOP.POST /api/v1/invites/claimwith{joinToken}links the signed-in user as an in-app contact of the owner.409 own_inviteif the owner uses their own link.
Public routes
GET /i/:code/inforeturns{ownerFirstName, botName, about, numberE164, channels, status}.channelsis a subset ofapp,sms,call.POST /i/:code/phonewith{e164, consentText, consent:true}texts a 6-digit code from the bot’s number and returns{sent:true, channel:"sms"|"call", expiresAt}. The code lasts 10 minutes and only the newest one works. If the carrier refuses the text (for example registration is still pending) and LiveKit is configured, the code is read aloud on a call from the bot’s number (channel:"call"). A number that texted STOP gets403 opted_out.POST /i/:code/verifywith{e164, code}returns{ok:true, optedOut}and uses the invite up. Five wrong tries burn the code (429 too_many_attempts).POST /i/:code/declineends the invite and records no consent.POST /i/:code/joinreturns{url, joinToken}.urlis the sign-up page on the app with a redirect back to/i/<code>/welcome?jt=<joinToken>; after sign-up the app callsPOST /api/v1/invites/claimwith that token.
404 invite_not_found. A link that is used, revoked or expired is 410 with invite_used, invite_revoked or invite_expired. Too many codes is 429 otp_rate_limited; a code that could not be delivered by text or call is 502 carrier_error.
Consent record
A verified code writes one row tosms_consent_log with kind explicit and source invite. The evidence is JSON: {inviteId, ipHash, userAgent, consentText, timestamp}. The IP is stored only as a hash. Verifying never lifts a STOP; only the person’s own START does.
App client
The invite page, Contacts and the new-text consent step live in the allternit-ai app (src/lib/gateway/invites.ts, src/components/invites/) and are shared by Desktop, ai.allternit.com and the m.allternit.com phone app. The page is route /i/:code and the sign-up step is /i/:code/welcome?jt=, which calls POST /api/v1/invites/claim once the user is signed in. On the phone app both routes work signed out.
Messaging and ringing a contact who joined Allternit use two owner routes the client calls and the control plane still needs to serve; until it does the app says the feature isn’t available yet (404, 405 and 501 are all treated that way):
POST /api/v1/phone/contacts/:userId/threadwith{botId}returns{threadId}: the direct thread between the bot and that user, created once and reused.POST /api/v1/phone/contacts/:userId/ringwith{botId}returns{callId, threadId?, room?}: rings the user’s app or bot phone. The call then runs on the existing voice path and shows in the thread as acall.*event stream.