Textify Voice: Architecture
Companion to
SPEC.md(what and why). This file is how: components,boundaries,adapters, callflows,flows (inbound, outbound, broadcast), data model, APIs,events,auth,infrastructure,communication,security.infra. Stack: Textify (Go)+→ VoiceServiceEngine (Java 21, Spring Boot) → Africa's Talking + LiveKit (self-hosted) + OpenAIRealtime (over SIP) + Africa's Talking. NoPython.Python,Anythingnomarkedmessage broker. [VERIFY]must=be checkedcheck againstcurrentprovider docs or Africa's Talking before coding against it.
1. System overviewOverview
┌────────────────────────────────────────┐
CallerPhones (any TZ network) │ Africa's Talking (AT) │
network) ◄──PSTN─── PSTN ──────►│ TZ numbers · telcoinbound interconnectcallbacks · XML · │
│ outbound call API · recordings · SIP │
└───────────────────┬───────────────────▲──────────┘
voice callbacks (POST)│ │ XML replies, call API (REST)
+ Dial to SIP trunk│ (5060 + RTP)
▼│
┌───────────────────────── Textify Voice box (dedicated) ───────┼─────────────────────────────┐
│ │
│ ┌─────────────┐ Redis ┌────────────────┐ ┌───────────────┐ │
│ │ livekit-sip │◄───────►│ livekit-server │◄─►│ livekit-egress│──► S3 storage │
│ └──────┬──────┘ └───────┬────────┘ └───────────────┘ (recordings) │
│ │ │ ┌───────────────┐ │
│ │ │◄──────────►│livekit-ingress│ (hold/consent │
│ │ │ └───────────────┘ audio files) │
│ │ │ server API + webhooks │
│ │ ▼ │ │
│ ┌──────────────────────┐ SIP ┌────────────┐ │
│ │ │ VOICE SERVICE │◄────►│ PostgreSQL │ │
│ │ │ (Java Spring Boot) │ └────────────┘ │
│ │ │ numbers · routing · │ │
│ │ │ calls · AI control ·│ │
│ │ │ recordings · usage │ │
│ │ └───┬──────────┬───────┘ │
└─────────┼─────────────────┼──────────┼──────────────────────────────────────────┐ │
│ │ livekit-sip │◄──────┤ VOICE ENGINE (Spring Boot) │──► Postgres │
│ └──────┬───────┘ (AT) │ accounts · numbers · flows · agents · │──► Redis │
│ │ │ calls · campaigns · recordings · events │──► S3 store │
│ ┌──────▼──────────┐ │ │ │
│ │ livekit-server │◄──►│ TelephonyAdapter MediaAdapter AiAdapter│ │
│ │ + ingress (hold)│ └───────▲──────────────────┬───────────────┘ │
SIP (TLS 5061 │ REST +└──────▲──────────┘ │ REST (clientAPI token)key) ▲ signed│ webhooks │ + SRTP)(HMAC) │
WebSocket│└──────────┼───────────────────────┼──────────────────┼──────────────────────────────┘
│ WebRTC │ ▼
▼ (control)▼ │┌───────┴────────┐ ┌──────┴──────────────────────────┐ ┌──────────────────────────┴──┐ ┌─────────────────┐
│ OpenAIAgent Realtimebrowser │ ◄─app─►│ TEXTIFY (Go) │◄───►│ Agent browser │ OpenAI │◄─ AiAdapter
│ joins(Textify theUI call room as a+ │ │ users · tenants · wallet · │ app │ (TextifyPh web UI,│
│ SIP participant; webhooks │ │ pricing · dashboard · 2–3)│
│ LiveKit JS SDK) │ │ +pricing control· toUI Voice Service │ │· SMS/WhatsApp follow-ups │ └────────┬────────┘
└────────────────────────────┘ └─────────────────────────────────┘ │ WebRTC
▼
livekit-server
KeyThree idea:rules every phone call is one LiveKit room. The caller,hold the AIdesign and human agents are all participants in that room:together:
thecallerarrives from AT over the SIP trunk;theAIis OpenAI Realtime, which Spring Bootdials into the room over SIPand controls through OpenAI's call-control REST + WebSocket;human agentsjoin from the browser over WebRTC.
Spring BootAT is the only "brain"road to the phone network, both directions. Inbound arrives as AT callbacks; outbound starts with AT's call API.
Dial sip:{callId}@<livekit-sip-host> and the call enters room call-{callId}. accountId, agentRef, numbers and calls, never users, roles or money.1.1 Responsibilities (hard boundary)
| Component | Owns | |
|---|---|---|
| Textify (Go) | Users, login, roles, tenants, |
|
| Voice |
Client apps, accounts ( |
Users, |
| Anything else | ||
| LiveKit | Rooms, WebRTC to browsers, SIP |
Business logic |
| OpenAI |
State, routing | |
Rule of thumb: if a feature mentions people, plans, roles or money, it belongs in Textify. If it mentions calls, audio, numbers or AI behaviour on a call, it belongs in the Voice Service.
1.2 Why a separate Voice Service
Reusable: JikoXpress, Mauzodukani and NexGate can become clients later (each is aclient_appwith accounts).Failure isolation: a portal outage or deploy doesn't drop calls, and the other way round.Security: the box with SIP ports open holds no user credentials.One language for the call logic: Java (team strength). Textify stays Go.
2. ComponentInside detailsthe engine
2.1 Voice Service (Java Spring Boot)
Stack: Java 21, Spring Boot 3.x, PostgreSQL + Flyway, Redis (locks, presence, short-lived call state),Redis, @Scheduled jobs (no Kafka at this scale),jobs, virtual threadsthreads.
2.1 Adapters (plain interfaces, one OpenAIimplementation WebSocketeach, perno activeplugin AIframework)
interface TelephonyAdapter { // impl: AfricasTalkingAdapter
String render(List<Action> actions); // → AT XML
OutboundResult placeCall(String from, List<String> to); // AT call isAPI
fine).InputStream downloadRecording(String url);
}
interface MediaAdapter { // impl: LiveKitAdapter
String sipUriFor(UUID callId); // sip:{callId}@sip.voice…
String roomToken(String room, String identity, boolean canPublish);
void sendData(String room, Object message); // ring / ring_cancel to lobbies
void startHoldAudio(String room); void stopHoldAudio(String room);
void deleteRoom(String room);
// Ph 3: dialAi(room, callId), removeParticipant(room, identity)
}
interface AiAdapter { // impl: OpenAiAdapter
Transcript transcribe(InputStream audio, String langHint); // Ph 2
Summary summarize(Transcript t); // Ph 2
// Ph 3: acceptLiveCall(openAiCallId, SessionConfig), control WebSocket, hangup
}
Swapping later (another telco, another AI/TTS) = a new class, nothing else changes.
Libraries
2.2
LiveKit server SDK for JVM: io.livekit:livekit-server (Kotlin, usable from Java). Room service, SIP service (trunks, dispatch rules, CreateSIPParticipant, TransferSIPParticipant), egress, ingress, AccessToken, WebhookReceiver. [VERIFY] exact class/method names in the current version.
OpenAI: plain HTTPS (RestClient) for call accept/reject/refer/hangup, transcription and summaries; Java WebSocket client (java.net.http.WebSocket or Spring WebSocketClient) for live call control.
AWS SDK v2 S3 client (S3-compatible storage) for recordings.
io.livekit:livekit-serverCreateSIPParticipantTransferSIPParticipantAccessTokenWebhookReceiverRestClientjava.net.http.WebSocketWebSocketClientPackage layout
com.qbitspark.voice
├── api // client-facingclient REST (/v1/...),** client-token(API-key authauth)
├── hooks // inbound webhooks:/hooks/at/{secret}/**, /hooks/livekit, /hooks/openai
├── telephony // AfricasTalkingAdapter, XML builder (signature-verified)Say, Play, GetDigits, Dial, Record, Reject)
├── livekitmedia // SDK wrapper,LiveKitAdapter, tokens, trunk/dispatch bootstrap,lobby/ring, ingress (hold audio), egressaudio
├── ai // OpenAIOpenAiAdapter
call├── control: accept + session config, WebSocket session handler,
│flows // toolflow execution,JSON AImodel, usagevalidator, capture;executor post-call(node transcription→ + summaryXML)
├── calls // call lifecyclelifecycle, state machine, call timeline
├── routing // ring groups, agent presence, accept/waiting list, claim logic(first accept wins)
├── campaigns // broadcast campaigns, dispatcher, retries, opt-outs
├── recording // egressdownload start/stop,→ storage,S3, retention
├── jobs // Postgres job table + @Scheduled workers (transcribe, summarize, cleanup)
├── events // outbox table + dispatcher (signed webhookswebhook to client apps)dispatcher
├── accounts // client apps, accounts, numbers, number config, agents
└── common
// errors, ids, time, config
2.23 AIFlow onmodel calls:(stored OpenAIper Realtimenumber, overbuilt SIP,in controlledTextify's from JavaUI)
Caller ──AT──► LiveKit room{
"call-…"record": (caller = SIP participant)
▲
│ (1) Spring Boot: CreateSIPParticipant(outbound trunktrue,
"openai"hours": { "tz": "Africa/Dar_es_Salaam", │"open": sip_call_to"Mon-Fri =08:00-17:00, <OpenAISat project SIP URI>09:00-13:00",
│ header X-Voice-Call-Id = <callId>)
▼
OpenAI Realtime (SIP, TLS 5061 + SRTP)
│ (2) webhook realtime.call.incoming"closed": {call_id, sip_headers}"say": ──►"Tumefunga POSTkwa /hooks/openaisasa. │Acha (3)ujumbe.", Spring"next": Boot:"vm" POST} /v1/realtime/calls/{call_id}/accept},
│"start": "greet",
"nodes": {model,
voice,"greet": instructions,{ tools,"type": transcription"play", on}"url": │ (4) Spring Boot: WebSocket wss:"https://api.openai.com/v1/realtime?call_id={call_id}
│ ← function calls, transcripts, usage
│ → tool results,…/karibu.mp3", "saynext": this""menu" responses},
│"menu": (5){ Spring"type": Boot:"menu", POST"say": /v1/realtime/calls/"Bonyeza 1 mauzo, 2 huduma kwa wateja",
"options": {call_id}/hangup (AI"1": leaves;"sales", room"2": stays)"support" },
"noInput": "support", "retries": 2 },
"sales": { "type": "ring", "group": "grp_sales", "timeoutSec": 30, "noAnswer": "vm" },
"support": { "type": "ring", "group": "grp_support", "timeoutSec": 60, "noAnswer": "vm" },
"vm": { "type": "voicemail", "say": "Acha ujumbe baada ya mlio", "maxSec": 60 }
}
}
(The Swahili prompts read: "We're closed right now. Leave a message." · "Press 1 for sales, 2 for customer service" · "Leave a message after the tone".)
Node types: say, play, menu, ring, voicemail, hangup (Ph 3: ai). The engine validates on save (every next exists, no loops without input).
2.4 Executing a flow with AT callbacks
- Each number's
Correlationvoice callback URL: pass ourcallIdas a custom SIP header onCreateSIPParticipant; read it back fromindata.sip_headersOpenAI'sATwebhook.points tohttps://voice-api…/hooks/at/{secret}/voice. - The executor finds the number → flow → current node, and returns XML for that node.
- State travels in the callback URL of each action (e.g. GetDigits
callbackUrl=/hooks/at/{secret}/step/{callId}/menu), plus thecallsrow. No session memory needed. - The final callback (
isActive=0) carries duration, cost (amount,currencyCode),recordingUrl, hangup cause → close the call. [VERIFY]LiveKitexactsupportsATcustomfieldheadersnames. - XML responses must be fast (< 500 ms): number/flow config cached in memory, refreshed on
outbound SIP participants and OpenAI forwards them. Treat headers as untrusted: only accept calls whosecallIdexists in our DB with statusAI_DIALING. Session config on accept(built from the number's config):instructions: base receptionist rules (Swahili first, short spoken answers, never invent prices, offer a human when unsure) + business name +ai_instructions+ "start by saying the recording notice and greeting: …".voice: chosen after the Swahili test.tools:transfer_to_human,take_message(name, phone, reason),end_call. (Phase 2: account-defined tools proxied to the client app.)Input transcription on (for live transcript + summaries).
Tool handling(on the WebSocket, in Java):transfer_to_human→ routing rings agents (§3.2) → tool result"ringing"→ AI tells the caller to hold → on accept, AI briefs the agent in one sentence → Spring Boot hangs up the OpenAI leg.take_message→ save tocalls.message_taken→ eventcall.message_taken.end_call→ wait for the AI to finish speaking → delete the LiveKit room.
AI usage: capture usage from OpenAI response events and the leg duration (participant_joined/leftof identityai) →calls.ai_seconds,calls.ai_usage.OpenAI API variants: OpenAI documents both the Realtime API (/v1/realtime/calls/...) and a newer Live API (/v1/live/sessions/..., webhooklive.transport.incoming). Pick one at build time[VERIFY]. The design is the same.Requirements: LiveKit outbound trunk to OpenAI usesTLS (5061) + SRTP; OpenAI requires SRTP, and trunks must support Opus with SDES-SRTP. LiveKit SIP supports TLS/SRTP. Interop[VERIFY]in the PoC.Trade-off: locked to OpenAI's realtime voices and turn detection. If OpenAI's Swahili speech isn't good enough → Appendix A.
2.3 LiveKit (self-hosted, dedicated box)
| |
| |
| |
| |
|
Telephony objects (created once at bootstrap, reused for every call):
Inbound trunk "at-inbound": our AT numbers + AT IPs only (numbers,allowed_addresses).Dispatch rule:dispatch_rule_individual,room_prefix: "call-",no agent dispatch(Spring Boot reacts to webhooks instead).Outbound trunk "at-outbound": AT SIP host + credentials, for calls to customers.Outbound trunk "openai": OpenAI SIP host, TLS transport, SRTP on, for dialing the AI into rooms.
SIP participant attributes used: sip.phoneNumber (caller), sip.trunkPhoneNumber (our dialed number), sip.callID. [VERIFY] names, and that they are included in LiveKit webhook participant info.
LiveKit SIP limitations: no SIP REGISTER (AT must send calls to our SIP URI directly); no video over SIP; DTMF, cold transfer (REFER), warm transfer, TLS/SRTP and noise cancellation supported. AT is not on LiveKit's tested-provider list, so the PoC must prove compatibility.
Room naming: the individual dispatch rule puts the caller's number in the room name (PII in logs). We can't control the To header on calls from AT, so this is accepted for Phase 1 with access-controlled logs.
2.4 Browser agent client (inside Textify's web app)
LiveKit JS SDK. Room tokens are minted by the Voice Service and fetchedthrough Textify's backend; the browser never holds Voice Service credentials.Lobby room per agent:lobby-{accountId}-{agentRef}, connected (mic off) while the agent is available:Presence: lobbyparticipant_joined/leftwebhooks = online/offline, automatically.Ringing: the Voice Service sends a data message{type:"ring", callId, caller, numberLabel}into the lobby → incoming-call UI + ringtone.
Accept → Textify backend →POST /v1/calls/{id}/accept→ first accept wins → returns a call-room token → the browser joins with mic on.Losers get409and a{type:"ring_cancel"}data message.change.
3. Call flows
3.1 Inbound,Inbound AI_FIRST→ (mainmenu flow)→ browser agent
Caller AT Engine LiveKit (sip/server)Agent Voice Service (Java) OpenAI Textifybrowser
│dial─►│── dialPOST /voice ──────►│ number→account→flow │ │
│ │ │ │──can-call? INVITE(Textify, ────►│1.5s, trunk ok → room call-…fail-open) │
│ │
│ │ │◄── webhookXML: participant_joinedSay (SIP,notice to=our+ number)Play greeting + GetDigits ───────►│ │
│ │"2" │ │ lookup number → account │ │
│ │ │ ►│── GETPOST can-call/step/{id}/menu ─────────────┼──(dtmfDigits=2) ─────────────►│ │node=support (ring) │
│ │◄── XML: Dial sip:{allowed:true}callId}@sip.voice… record=true ───│ │
│ │══ SIP INVITE (user part = callId) ════════════════════════► room call-{callId}
│ │ │◄─ webhook participant_joined ─│ │
│ │ │ start hold audio (ingress) ──►│ │
│ │ │ pick available agents ─ ring data msg ─► lobby ─────►│ popup
│ │ │◄──────────┼──── accept (via Textify) ──────────────────│
│ │ │ │ create callclaim (RINGING),Redis eventSET call.startedNX) ──►│→ │room │token │◄── CreateSIPParticipant(openai trunk, X-Voice-Call-Id) ───│ │
│ │ │── SIP INVITE (TLS/SRTP) ────┼────────────────────────────►│
│ │ │ │stop │◄── webhook call.incoming ───│ │
│ │ │ │── accept {instructions,tools}► │
│ │ │ │── open control WebSocket ──►│ │
│ │ │ │ start egress (if recording) │ │
│◄═══════╪═══hold audio ═════╪═══ AI: recording notice + greeting, conversation ═════════│ │
│ │ │ │◄── tool call: end_call ─────│ │
│ │ │◄── delete room ─────────────│ │ │
│ │◄── BYE ───────│── webhooks: participant_left, room_finished ─────────────►│◄═════ joins, talks ══│
│◄═════╪══════════════════════╪═════════ audio ═══════════════╪══════════════════════│
│ hangup │ │ │
│ │── computePOST usage/voice (isActive=0: duration, amount, recordingUrl) ►│ close call → call.ended ─┼───────────────►│ deduct walletevents
If
- Waiting:
saysifno: play the "insufficient balance" audio via ingress → delete the room → callREJECTED_NO_BALANCE(noAI cost). If Textify doesn't answer within 1.5 s:allow(fail-open) and log it.3.2 AI → human handover (warm transfer)AI calls tooltransfer_to_human(caller asked for a person,agent isupset, or the AI can't answer).Voice Service: find the number's ring group → available agents (Redis presence) → sendringdata messages to their lobbies (ALL_AT_ONCE or ROUND_ROBIN) → tool result{"status":"ringing"}.AI tellsavailable, the caller"nakuunganishastaysnaonmhudumuholdsasainhivi"the room; when an agent becomesAVAILABLE("I'morconnecting you tofinishes astaff member now").Agent accepts → claim (SET call:{id}:agent NX) → call-room token → agent joins.Voice Service sendscall), theAIenginearings"briefagain.theOrderagent=inoldestone sentence" instruction over the WebSocket → after it speaks →POST …/calls/{call_id}/hangupon OpenAI → AI leg leaves; caller and agent keep talking. AI seconds stop.Timeout (default 30 s) with no accept → tool result{"status":"no_agent"}→ AI takes a message →call.message_taken.
3.3 Inbound, HUMAN_ONLYSame start as 3.1 (webhook → number lookup → can-call →waiting callrecord)first (Redis sorted set per group).No AI dialed.TimeoutVoice Service starts ingress playing consent + hold audio into the room, and rings agents.Agent accepts → stop ingress → agent joins.Timeout→noAnswerActionnoAnswer:- node.
AI: dial OpenAI intoSince theroomcall(asis now in3.1)LiveKit, VOICEMAIL:voicemailplaythere = the engine plays the prompt viaingress,ingressrecordand records viaegressegress, or simpler: remove the SIP participant so AT'sDialends, then AT continues with the next XML (PhaseRecord)2)on the Dial's callback. [VERIFY] AT continues the flow after Dial ends (prefer this: keeps voicemail on AT).Missed:HANGUPdeletecallerthehangsroomup→orMISSEDnobody
it →MISSEDcall.missedevent → Textify sends the SMS/WhatsApp follow-up..
can-call3.4 Inbound, AI_ONLY
Same as 3.1, but transfer_to_human isn't offered; the AI takes messages instead.
3.52 Outbound / click-to-call (agent → customer)
Agent browserclicks number → Textify → POST /v1/calls/outbound {accountId, numberId,fromNumberId, toE164,to, agentRef}
Voice Service:Engine:
1. GET can-call (direction=call(OUTBOUND)
2. create call + room out-call-{callId}
3.; return agent token → browser joins (hears ringback)ringback 4.tone CreateSIPParticipant(at-outbound,from sip_call_to=toE164,page)
sip_number=our3. number,TelephonyAdapter.placeCall(from, room,to) wait_until_answered)→ AT ringsdials the customercustomer; store AT sessionId → answercallId
Customer answers → AT callback (direction=Outbound, sessionId) → engine finds callId
→ XML: Dial sip:{callId}@sip.voice… → customer joins the agent's room → talk
End → room_finishedfinal callback → call.ended (pstnSeconds outbound, trunkSeconds)
3.63 TransferBroadcast to an external mobile (fallback, off by default)
Preferred:TransferSIPParticipant(SIP REFER) of the caller totel:+255….[VERIFY]whether AT honours REFER.Alternative:CreateSIPParticipantdials the mobile into the same room (extra outbound leg,externalTransferSeconds).
3.7 Recording + summary pipelinecampaign
callTextify answered→ ──►POST start/v1/campaigns RoomComposite{accountId, egressfromNumberId, audioUrl|ttsText, keys:{1:"grp_sales",9:"optout"},
window, cps, maxConcurrent, retries} + recipients (audio_only)chunks of ≤10k)
Dispatcher (@Scheduled every 1s, per campaign):
- inside calling window? not paused? concurrent < max?
- SELECT … FROM campaign_recipients WHERE status='PENDING' AND next_attempt_at<=now()
ORDER BY id LIMIT :cps FOR UPDATE SKIP LOCKED
- skip opt-outs → mark OPTED_OUT
- placeCall(from, batch) → mark DIALING, store AT sessionIds
Recipient answers → AT callback → XML: Play audio + GetDigits(keys)
- "1" → Dial sip:{callId}@livekit → ring group (same as 3.1)
- "9" → add to opt_outs → Say "Umeondolewa" ("You've been removed") → hangup
Final callback → recipient ANSWERED / NO_ANSWER / BUSY / FAILED (+seconds, amount)
- NO_ANSWER/BUSY & attempts < max → PENDING with next_attempt_at = now + retryDelay
Events: campaign.progress every 30s (counts) · campaign.recipient_results batches · campaign.completed
Textify: SMS fallback to NO_ANSWER numbers; pauses campaign (POST /pause) if wallet low
The campaign_recipients table is the queue. No broker needed.
3.4 Recording → transcript → summary (Phase 2)
final callback recordingUrl → job DOWNLOAD → S3 s3://recordings/rec/{accountId}/{callId}.oggmp3 room_finished→ ──►recording.ready
egress ends ──► webhook egress_ended ──► recordings row READY
│
▼ (@Scheduled→ job picks pending)
transcript: AI part already captured live from the OpenAI WebSocket;
full callTRANSCRIBE (incl.OpenAI, human agent part)sw/en) → OpenAIjob transcriptionSUMMARIZE (sw/en)OpenAI, │JSON) ▼
OpenAI chat (fixed prompt, JSON output) ──► summary
│
▼
transcripts row DONE ──► event→ summary.ready
──► Textify
daily retention job ──►RETENTION → delete objects + rows where delete_after < now()
Recording comes from AT (Dial record="true"), so no LiveKit egress service is needed. [VERIFY] AT records the Dial-to-SIP leg; fallback = LiveKit egress.
Summary JSON:
{summary, "summary":intent, "Mtejaresolved, aliuliza bei ya kifurushi cha mwezi na akaomba apigiwe kesho.",
"intent": "pricing_inquiry",
"resolved": false,
"followUps": ["Call back tomorrow morning with monthly package price"followUps[], "sentiment":sentiment, "neutral",topic, "topic": "sales",
"language": "sw"
}
language}(The example summary says: "The customer asked the price of the monthly package and asked to be called back tomorrow.").
3.85 AI (Phase 3), same pattern
Flow node ai (or noAnswer: ai, or campaign key 1: ai) → call is dialed into LiveKit as usual → engine dials OpenAI Realtime into the room over SIP (LiveKit outbound trunk, TLS 5061 + SRTP) → OpenAI webhook → engine accepts with instructions/tools → controls via WebSocket from Java → transfer_to_human rings agents → agent joins → engine hangs up the AI leg. Details kept in Appendix A.
3.6 Call state machine
INBOUND: RINGING ──►→ AI_DIALINGIN_FLOW ──►→ WAITING_AGENT → ANSWERED ──►→ COMPLETED
│OUTBOUND: │DIALING │──→ (noANSWERED balance)→ ──►COMPLETED
any → MISSED | NO_ANSWER | BUSY | FAILED | REJECTED_NO_BALANCE
│──handled_by: (nobodyNONE handled| /FLOW_ONLY caller| hung up) ──► MISSED
└── (SIP/system error) ──► FAILED (AI_DIALING/ANSWERED can also → FAILED)
handled_by: NONEAGENT | AI | AGENTAI_THEN_AGENT
| AI_THEN_AGENT.
If the OpenAI leg fails to connect within 5 s: fall back to ringing agents (HUMAN_ONLY path) and log AI_UNAVAILABLE.
4. Browser agents
- Agent UI lives in Textify's web app, using the LiveKit JS SDK.
- Lobby room per agent:
lobby-{accountId}-{agentRef}, connected with mic off while logged in.- Presence = lobby
participant_joined/leftwebhooks (automatic online/offline) + explicit status from Textify. - Ringing = engine
sendData(lobby, {type:"ring", callId, from, numberLabel, groupName}).
- Presence = lobby
- Accept → browser → Textify backend →
POST /v1/calls/{id}/accept {agentRef}→ engine claims (SET call:{id}:agent NX EX 60) → returns call-room token → browser joins with mic on. Losers:409+ring_cancel. - Browser never holds engine credentials; only short-lived room tokens (≈10 min to join, room-scoped).
5. Data model (PostgreSQL)
client_apps (
id uuid pk, namename, text,api_key_hash, token_hashapi_key_hash_next text,null, webhook_url-- text,2 webhook_secretkeys text,for can_call_urlrotation
text,webhook_url, created_atwebhook_secret, timestamptzcan_call_url, )created_at)
accounts (
id uuid pk, client_app_id uuid fk, external_refexternal_ref, text, -- e.g. Textify tenant id
status text, -- ACTIVE | SUSPENDEDstatus, default_language text default 'sw',
recording_retention_days intretention_days default 90, created_atcreated_at, timestamptz,
unique (unique(client_app_id, external_ref)
)
phone_numbers (
id uuid pk, e164 text unique, provider text default 'AFRICAS_TALKING'AT', type text,type, -- REGULAR | TOLL_FREE
status text, -- UNASSIGNED | ACTIVE | RELEASEDstatus, account_id uuid fk null, labellabel, text,flow --jsonb, "Salesflow_version line"int, answer_mode text, -- AI_FIRST | AI_ONLY | HUMAN_ONLY
greeting text,
ai_instructions text, -- business info, FAQs, tone
ai_voice text null, -- override default voice
recording_enabled boolean default true,
ring_group_id uuid fk null,
ring_timeout_sec int default 30,
no_answer_action text, -- AI | VOICEMAIL | HANGUP
external_transfer_e164 text null,
updated_at timestamptz
)updated_at)
agents (
id uuid pk, account_id uuid fk, external_refexternal_ref, text,display_name, --status, e.g.last_seen_at,
Textify user id
display_name text,
status text, -- AVAILABLE | BUSY | OFFLINE (live presence also in Redis)
last_seen_at timestamptz,
unique (unique(account_id, external_ref)
)
ring_groups (id uuid pk, account_id uuid fk, namename, text, strategy text)strategy) -- ALL_AT_ONCE | ROUND_ROBIN
ring_group_members (ring_group_id uuid fk, agent_id uuid fk, positionposition, int,
primary key (pk(ring_group_id, agent_id))
calls (
id uuid pk, account_id uuid fk, number_id uuid fk, directioncampaign_id text, -- INBOUND | OUTBOUND
from_e164 text, to_e164 text,
livekit_room text, sip_call_id text,
openai_call_id textfk null,
statusdirection, text,from_e164, handled_byto_e164, text,at_session_id, livekit_room,
status, handled_by, agent_id uuid null, started_atcurrent_node,
timestamptz,started_at, answered_atanswered_at, timestamptz,ended_at, ended_atend_reason,
timestamptz,pstn_seconds, end_reasontrunk_seconds, ai_seconds,
at_amount numeric, at_currency, dtmf_path text,
inbound_seconds int default 0, outbound_seconds int default 0,
trunk_seconds int default 0, ai_seconds int default 0,
external_transfer_seconds int default 0,
message_taken jsonb null, -- {name, phone, reason} ai_usage jsonb null -- model, token/audio counters (cost measurement)
)null)
call_events (id bigserial pk, call_id uuid fk, typetype, text, at timestamptz,at, data jsonb)
campaigns (id uuid pk, account_id fk, from_number_id fk, status, -- timelineDRAFT|RUNNING|PAUSED|DONE|CANCELLED
audio_url, tts_text, keys jsonb, window jsonb, cps int, max_concurrent int,
max_retries int, retry_delay_sec int, starts_at, created_at, completed_at)
campaign_recipients (id bigserial pk, campaign_id fk, e164, vars jsonb null,
status, attempts int, next_attempt_at, last_call_id fk null,
answered_seconds, keypress, index(campaign_id, status, next_attempt_at))
opt_outs (account_id fk, e164, created_at, pk(account_id, e164))
recordings (
id uuid pk, call_id uuid fk unique, egress_idsource_url, text,storage_key, storage_keyduration_sec, text,size_bytes,
duration_secstatus, int, size_bytes bigint,
status text, -- RECORDING | READY | FAILED | DELETED
delete_after timestamptz
)delete_after)
transcripts (
call_id uuid pk fk, statusstatus, text, -- PENDING | DONE | FAILED
language text, textlanguage, text, segments jsonb, summary jsonb, modelmodel, text,created_at)
created_atjobs timestamptz(id )bigserial pk, type, ref_id, status, attempts, run_after, last_error) -- DOWNLOAD|TRANSCRIBE|SUMMARIZE|…
outbox_events (
id uuid pk, client_app_id uuid fk, type text,type, payload jsonb, attemptsattempts, int default 0, next_attempt_at timestamptz,next_attempt_at,
delivered_at timestamptz null, last_error text
)last_error)
RedisRedis: keys:
presence:{accountId}:{agentRef},→AVAILABLE|BUSY(TTL, refreshed by lobby webhooks)call:{callId}id}:agent→(claim),claim lockwait:{groupId}(SETsortedNX,setTTL)of waitingcall:{callId}:ai→callIds),OpenAI call id + WebSocket owner noderr:{,ringGroupId}groupId}campaign:{id}:active→(concurrencyround-robin pointer
5.6. APIs
5.6.1 Client-facingClient API (Textify → Voice Service)engine)
| Accounts | POST /accounts | |
PATCH /accounts/{id} |
||
| Numbers | GET /numbers?status=UNASSIGNED | |
POST /numbers/{id}/assign | | |
POST /numbers/{id}/release | ||
PUT /numbers/{id}/ | GET |
|
| Agents | PUT /accounts/{id}/agents/{ | · PUT …/status · POST …/lobby-token |
groups | ||
| | |
| ||
POST/PUT/DELETE /accounts/{id}/ring-groups[/{gid}] |
||
| Calls | POST /calls/outbound · POST /calls/{id}/accept | · |
POST /calls/{id}/hangup | ||
POST /calls/{id}/transfer | · | |
| | |
GET /calls? | ||
GET /calls/{id} | ||
GET /calls/{id}/recording | ||
GET /calls/{id}/transcript |
||
POST · POST /campaigns/{id}/recipients (chunks) · `POST /campaigns/{id}/start |
||
Admin-
GET/POST/DELETE /accounts/{id}/opt-outsPOST ops/client-apps POST /numbers (register POST /client-apps5.6.2 Voice Service → Textify: balance check
GET {can_call_url}?accountRef=…&direction=INBOUND|OUTBOUND → {allowed: bool, maxMinutes?: int}. Timeout 1.5 s → allow (fail-open), log it.
5.3 Webhooks: Voice ServiceEngine → Textify
- Balance check:
→POSTGET {webhook_url}can_call_url}?accountRef&direction&estimatedCalls?{allowed, maxMinutes?};headers1.5X-Voice-Event-Id,sX-Voice-Event-Type,timeoutX-Voice-Timestamp,→X-Voice-Signature: sha256=HMAC(secret, timestamp + "." + body).allow. Delivered via theoutboxWebhookswith(outbox,exponentialsigned,backoffretried(1 m, 5 m, 30 m, 2 h, …1m/5m/30m/2h… up to2424h,h).idempotentThe receiver must beidempotentonby eventid.id):
| Event | |
|---|---|
call.started | |
call.answered |
callId, |
call.ended |
|
call.missed |
callId, from, to, |
|
callId, |
recording.ready | |
summary.ready |
callId, (summary |
campaign.progress |
counts by status |
campaign.recipient_results |
batch of {e164, status, seconds, keypress, atAmount} |
campaign.completed |
totals |
Example
6.3 call.ended:Webhooks {
"id": "evt_01J...", "type": "call.ended", "occurredAt": "2026-10-21T12:30:05Z",
"data": {
"callId": "3f2c...", "accountExternalRef": "tenant_123",
"direction": "INBOUND", "from": "+2557XXXXXXXX", "to": "+2557YYYYYYYY",
"status": "COMPLETED", "handledBy": "AI_THEN_AGENT", "agentRef": "user_88",
"usage": { "inboundSeconds": 245, "outboundSeconds": 0, "trunkSeconds": 245,
"aiSeconds": 70, "externalTransferSeconds": 0, "recorded": true },
"endReason": "CALLER_HANGUP"
}
}
5.4 Inbound webhooks tointo the Voice Serviceengine
{
"id": "evt_01J...", "type": "call.ended", "occurredAt": "2026-10-21T12:30:05Z",
"data": {
"callId": "3f2c...", "accountExternalRef": "tenant_123",
"direction": "INBOUND", "from": "+2557XXXXXXXX", "to": "+2557YYYYYYYY",
"status": "COMPLETED", "handledBy": "AI_THEN_AGENT", "agentRef": "user_88",
"usage": { "inboundSeconds": 245, "outboundSeconds": 0, "trunkSeconds": 245,
"aiSeconds": 70, "externalTransferSeconds": 0, "recorded": true },
"endReason": "CALLER_HANGUP"
}
}
POST /hooks/livekit: verified with LiveKit WebhookReceiver.
| Verification | ||
|---|---|---|
|
secret destinationNumber/callerNumber must match a known number/call |
|
|
signed |
|
( |
||
|
| |
| ||
| ||
| | |
|
POST
/hooks/openai:
7. verifyCommunication OpenAI webhook signature, dedupe by event id.choices
| Textify ⇄ engine commands/queries | REST |
| Engine → Textify facts | Webhooks via Postgres outbox + dispatcher |
| AT call control | AT callbacks + XML; AT REST call API |
| Ring agents in browser | LiveKit data messages ( |
| Presence, claims, waiting lists, campaign concurrency | Redis |
| Background work (download, transcribe, summarize, retention) | Postgres table + @Scheduled workers (FOR UPDATE SKIP LOCKED) |
|
DurationsNot used in v1: MQTT (IoT protocol, and browsers are alwayscovered computedby fromLiveKit), server-sideKafka webhook(overkill), timestamps.RabbitMQ.
When to add one: several engine instances and very high event volume, or many consumers of the same events → Redis Streams first (already running Redis), RabbitMQ only if outgrown. The outbox/jobs tables make that a contained swap.
6.8. Security (service authentication)
- In (Textify → engine): API key per client app; stored hashed; two active keys for rotation; scoped to the client's accounts; rate-limited.
- Out (engine → Textify):
X-Voice-Event-Id,X-Voice-Timestamp,X-Voice-Signature: sha256=HMAC(secret, ts + "." + body); Textify rejects bad signatures and anything > 5 min old. - AT callbacks: unguessable secret in the path, IP allowlist, sanity checks against our numbers/calls.
- LiveKit / OpenAI webhooks: built-in signature verification.
- SIP: firewall and LiveKit inbound trunk
allowed_addresses= AT IPs only. Inbound trunk accepts any user part (our callId) only from AT IPs [VERIFY trunk config without anumbersfilter]. Unknown callId → reject. - Browsers: only short-lived room tokens, requested through Textify (where the human auth happens).
- Recordings: private bucket, signed URLs (~5 min), retention job, recording notice.
- No OAuth/Keycloak/mTLS between our services. Secrets only in env. Keep CrowdSec/fail2ban practice.
9. Infrastructure
6.9.1 BoxDedicated Voice box (Phase 0/1: single dedicated VPS, separate from NexGate)
Docker Compose,Compose; host networking for media services (SIP/RTP/WebRTC need real ports).
| Service | Notes | |
|---|---|---|
caddy | TLS for voice-api.… (wss://voice-rtc.… ( |
|
livekit |
livekit/livekit-server | , |
sip |
livekit/sip | , RTP |
| | |
ingress |
livekit/ingress | , hold audio into rooms [VERIFY URL input for |
redis |
| shared |
postgres |
| |
voice- |
Spring Boot jar | |
(egress |
Storage: private S3-compatible private bucket (e.g. Linode Object Storage or MinIO). SignedStart URLs4 only.vCPU Audio/ prompt files (consent, hold, no-balance) in a separate bucket/path, readable by ingress.
Sizing (start, then measure): 4–8 vCPU,GB; 8–16 GB RAM.measure. Choose the region with the lowest RTT to Africa's Talking'AT's SIP servers (measure in the PoC); OpenAI SIP is GeoIP-routed (sip.api.openai.com, EU: sip-eu.api.openai.com).servers.
6.9.2 Ports & firewall
| Port | Purpose | ||
|---|---|---|---|
| 22 | SSH | ||
| WebRTC | |||
| 5060 udp/tcp, 10000–20000/udp | SIP | ||
| |||
| 7880, 6379, 5432, 8080 | |||
| (Ph 3) out 5061/tcp + RTP to OpenAI CIDRs | AI |
9.3 Env
AT_USERNAME / AT_API_KEY / AT_CALLBACK_SECRET / AT_IPS
LIVEKIT_URL=ws://127.0.0.1:7880 / LIVEKIT_PUBLIC_WS_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET
LIVEKIT_SIP_HOST=sip.voice.textify.africa
DB_URL / DB_USER / DB_PASSWORD · REDIS_URL / REDIS_PASSWORD
S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY
OPENAI_API_KEY (Ph 2) · OPENAI_WEBHOOK_SECRET / OPENAI_SIP_URI (Ph 3)
AUDIO_HOLD_URL / AUDIO_CONSENT_URL / AUDIO_NO_BALANCE_URL
9.4 LiveKit bootstrap (once, idempotent)
- Inbound trunk
at:allowed_addresses= AT IPs (no number filter, since the user part is our callId). - Dispatch rule
dispatch_rule_calleewithroom_prefix: "call-", sosip:{callId}@…→ roomcall-{callId}. [VERIFY] callee rule uses the SIP user part. - (Ph 3) Outbound trunk
openai(TLS + SRTP).
10. Observability & testing
- Logs: JSON, with
callId,accountId,atSessionId,campaignIdon every line. - Metrics: active calls, waiting callers, answer/missed rate, ring-to-accept time, callback latency, campaign dials/sec and answer rate, outbox backlog, job failures.
- Alerts: SIP down, callback errors > N%, zero inbound in business hours, outbox backlog, campaign stalled.
- PoC checklist = SPEC §10 Phase 0 steps 1–5.
- Unit: flow validator/executor (node → XML), routing claim race, waiting order, campaign dispatcher (window, cps, retries, opt-out), outbox retry, signatures.
- Integration: AT sandbox/real number, LiveKit dev, browser test page, Textify webhook mock.
11. Tech stack & tools (one place)
| Layer | Tool | Used for |
|---|---|---|
| Engine | Java 21, Spring Boot 3.x (Web, Validation, Data JPA/JDBC, Scheduling) | All engine logic |
| DB | PostgreSQL 16 + Flyway | State, outbox, jobs, campaign queue |
| Cache/locks | Redis 7 | Presence, claims, waiting lists, campaign concurrency (shared with LiveKit) |
| Telephony | Africa's Talking Voice API: callbacks + XML (Say, Play, GetDigits, Dial, Record, Reject), outbound call API, recordings; optional africastalking-java SDK for REST calls (XML built by our own small builder) |
PSTN both directions |
| Media | LiveKit server, LiveKit SIP, LiveKit Ingress (hold audio); JVM server SDK io.livekit:livekit-server; LiveKit JS SDK in Textify's agent UI |
Rooms, SIP from AT, browser agents, data messages |
| AI | OpenAI REST (transcription, summaries) from Java RestClient; Ph 3: OpenAI Realtime over SIP + Java WebSocket client |
Summaries; live AI |
| Storage | S3-compatible (Linode Object Storage / MinIO) via AWS SDK v2 | Recordings, audio prompts |
| HTTP clients | Spring RestClient (sync, virtual threads), java.net.http.WebSocket (Ph 3) |
AT, Textify, OpenAI |
| Security | API keys (hashed, BCrypt/SHA-256), HMAC-SHA256 webhooks, LiveKit WebhookReceiver, OpenAI webhook verify, ufw firewall, CrowdSec |
Service auth |
| Edge | Caddy (or nginx) | TLS, reverse proxy for API, hooks, WSS |
| Deploy | Docker Compose on a dedicated VPS | All services |
| Observability | JSON logs (Logback), Spring Actuator + Micrometer (Prometheus format) | Logs, metrics, health |
| Testing | JUnit 5, Testcontainers (Postgres, Redis), WireMock (AT/Textify/OpenAI mocks), browser test page | Unit + integration |
| Not used | MQTT, Kafka, RabbitMQ, OAuth/Keycloak, Python | Not needed (§7, Appendix A) |
12. Repository layout
textify-voice/
├── docs/ SPEC.md, ARCHITECTURE.md
├── voice-engine/ Spring Boot (Java 21)
├── deploy/ docker-compose.yaml, livekit.yaml, sip, ingress, Caddyfile, firewall.sh, .env.example
├── audio/ hold, consent, no-balance prompts (sw/en)
└── agent-test/ minimal HTML page with LiveKit JS for PoC (real UI lives in Textify)
13. Open items to verify
- AT: Dial to
sip:{callId}@host, IPs, codecs/DTMF, billing, recording of Dial-to-SIP, CPS/concurrency, outbound per-second vs per-minute, multi-number call API, callback fields and signature (SPEC §11). - AT: does XML continue after a
Dialleg ends (for voicemail after timeout)? - LiveKit: callee dispatch rule with arbitrary user part; inbound trunk without number filter; ingress URL input for hold audio.
- LiveKit JVM SDK (
io.livekit:livekit-server) method names for tokens, data messages, ingress, participants. - Ph 3: LiveKit → OpenAI SIP interop (TLS/SRTP), OpenAI Realtime vs Live API, Swahili quality and cost.
Appendix A: Phase 3 AI details (Java-only)
- LiveKit has no Java SDK that joins rooms as an audio participant, and Spring AI / LangChain4j are text-only, so OpenAI joins the room as a SIP participant instead.
- Engine:
CreateSIPParticipant(outbound trunk "openai", to = OpenAI project SIP URI, header X-Voice-Call-Id)→ OpenAI webhookrealtime.call.incoming(or Live APIlive.transport.incoming) → match header to a call inAI_DIALING→POST /v1/realtime/calls/{id}/acceptwith instructions (Swahili first, short answers, never invent prices), voice, tools (transfer_to_human,take_message,end_call) → WebSocketwss://api.openai.com/v1/realtime?call_id=…for tool calls/transcripts/usage →POST …/hangupafter handover. - Firewall: out TCP 5061 to
sip.api.openai.com; RTP with OpenAI media CIDRs(from OpenAI's SIP guide, Oct 2026; re-check before go-live):13.79.45.80/28, 23.98.140.64/28, 40.67.149.176/28, 40.83.204.240/28. WebRTC range and SIP RTP range must not overlap.6.3 Configuration (env)LIVEKIT_URL=ws://127.0.0.1:7880 LIVEKIT_PUBLIC_WS_URL=wss://voice-rtc.textify.africa LIVEKIT_API_KEY / LIVEKIT_API_SECRET REDIS_URL / REDIS_PASSWORD DB_URL / DB_USER / DB_PASSWORD S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY OPENAI_API_KEY OPENAI_WEBHOOK_SECRET OPENAI_SIP_URI # project SIP URI from OpenAI [VERIFY format] OPENAI_REALTIME_MODEL / OPENAI_DEFAULT_VOICE AT_SIP_HOST / AT_SIP_USERNAME / AT_SIP_PASSWORD / AT_SIP_IPS AUDIO_CONSENT_URL / AUDIO_HOLD_URL / AUDIO_NO_BALANCE_URLClient app tokens, webhook secrets and can-call URLs live inclient_apps(tokens hashed).6.4 Bootstrap (one-time, idempotent on startup)Inbound trunkat-inbound(numbers + AT IPs).Dispatch rule (individual,call-prefix, no agent dispatch).Outbound trunkat-outbound(AT host + credentials).Outbound trunk(OpenAI SIPopenaihost,guide,TLS,OctSRTP)2026; re-check).Configure theTrade-off: OpenAIproject webhook →https://voice-api…/hooks/openai.
Store the ids.Never create trunks/rules per call.7. SecuritySIP exposedonlyto AT IPs (inbound) and OpenAI media CIDRs (firewallandLiveKitallowed_addresses).OpenAI webhook signature verification + only accept OpenAI calls matching a call inAI_DIALING.Client app → Voice Service: bearer token (hashed at rest), scoped to its accounts.Voice Service → client app: HMAC-signed webhooks with timestamp (reject > 5 min old).LiveKit webhooks: signature verified.Browser gets only short-lived, room-scoped LiveKit tokens (~10 min to join).OpenAI API key and SIP credentials only on the server.Recordings: private bucket, signed URLs (~5 min), retention job, consent notice.Secrets in envvoices only.Keep the CrowdSec/fail2ban practice from Textify's infra.
8. ObservabilityStructured JSON logs withcallId,room,accountId,openaiCallIdon every line.Metrics: active calls, active AI sessions, calls/min, answer rate, missed rate, AI handover rate, AI dial-in time (CreateSIPParticipant → accept), AI first-response latency, egress failures, webhook backlog, can-call timeouts.Alerts: SIP service down, OpenAI dial-in failures > N%, zero inbound calls for X minutes in business hours, outbox backlog > N, egress failure rate > N%.
9. Testing strategyPoC checklistAT number → trunk → room created →participant_joinedwebhook received.Spring Boot dials OpenAI into the room →call.incomingwebhook → accept → AI greets in Swahili → caller hears it.Tool call over the WebSocket (end_call) works → room deleted → durations correct.Handover: agent in browser joins, AI leg hangs up, caller stays.
Swahili quality test: 10–20 real callers (Swahili, English, mixed). Score understanding and voice.Ifthe voice is weak → Appendix A.Cost: logai_usageper call; compute TZS per AI minute.Unit: routing (ring strategies, claim race), state machine, outbox retry, signature verification, OpenAI tool dispatch.Integration: LiveKit dev server + SIPp/softphone from an allowed IP; OpenAI in a test project; Textify webhook mock.
10. Repository layout (suggested)textify-voice/ ├── docs/ SPEC.md, ARCHITECTURE.md ├── voice-service/ Spring Boot (Java 21) ├── deploy/ docker-compose.yaml, livekit.yaml, sip, egress.yaml, ingress.yaml, Caddyfile, firewall.sh, .env.example ├── telephony/ trunk + dispatch-rule JSON templates └── audio/ consent / hold / no-balance prompt files (sw + en)11. Open items to verify before codingAT trunk behaviour (SPEC §11): external SIP URI, outbound auth, IPs, billing, REFER, codecs.LiveKit SIP → OpenAI SIPinterop (TLS + SRTP, Opus/G.711), custom header pass-through, OpenAI project SIP URI format.OpenAI Realtime API vs Live API choice; webhook event names; usage fields.LiveKit JVM SDK method names (SIP participant create/transfer, egress, ingress, tokens).LiveKit SIP participant attributes present in webhook payloads.Ingress URL input for short audio prompts (latency to start playing).OpenAI Swahili quality + per-minute cost; choose a voice.Egress CPU usage for audio-only recordings on the chosen box.
Appendix A: Fallback if OpenAI SIP fails the PoC (Python worker)Use only if (a) LiveKit ⇄ OpenAI SIP can't interoperate, or (b) OpenAI'sSwahili voice isn't good enoughand→we need a different TTS provider.Add afallback: small Pythonservice usingLiveKit Agents(livekit-agents[openai]),workerdispatched into each call room by the dispatch rule (room_config.agents).It runs the voice loop (OpenAI Realtime, or STT + LLM +with anotherSwahiliTTS,TTS)callingand exposes the same three tools, which call the Voice Service over a localhost-onlyengine internalAPI (/internal/calls/start | handoff | message | end | ai-usage).No database,endpoints; nobusinessotherlogic; about 150–300 lines. Spring Boot stays the brain; data model, client APIs and events above are unchanged.change.