Textify Voice: Architecture
Companion to
SPEC.md(what/why) andFLOWS.md(flow & action reference). This file is how: components, adapters, callflowspaths,(inbound,mediaoutbound,&broadcast),voice, knowledge base, data model, APIs, auth, communication,infra.infra, tools. Stack: Textify (Go) → Voice Engine (Java 21, Spring Boot) → Africa's Talking + LiveKit (self-hosted) + TTS (Basic/Premium) + OpenAI + MinIO. No Python, no message broker. [VERIFY] = check against provider docs or Africa's Talking before coding against it.
1. Overview
┌──────────────────────────────────────┐
Phones (any TZ network) │ Africa's Talking (AT) │
◄──── PSTN ──────►│ numbers · inbound callbacks · XML (Play, │
│ GetDigits, Record, Dial) · call API · │
│ outbound call API · recordings · SIP to LiveKit │
└───────┬───────────────────▲──────────┘
voice callbacks (POST) │ │ XML replies,replies · call API (REST)· +fetch audio URLs
Dial to SIPsip:{callId} │ │
┌──────────────────────── Voice box (dedicated) ──────┼─┼─────────────────────────────┐
│ ▼ │ │
│ ┌──────────────┐ SIP ┌──────────────────────────────────────────────┐ │
│ │ livekit-sip │◄──────┤ VOICE ENGINE (Spring Boot) │──► Postgres │Postgres│
│ └──────┬───────┘ (AT) │ accounts · numbers · flows · media · agents · │──► Redis │
│ │ │ calls · campaigns · recordings · events │──► S3 store │
│ ┌──────▼──────────┐ │ calls · campaigns · endpoints · knowledge · │──► MinIO │
│ │ livekit-server │◄──►│ TelephonyAdapterrecordings MediaAdapter· AiAdapter│events │ │
│ │ + ingress (hold)│ │ Telephony│Media│Tts(Basic,Premium)│Ai adapters │──► tts-basic│
│ └───────▲─────────┘ └──────▲───────────────┬───────────────┘ │
│ └──────▲──────────┘ │(CPU REST (API key) │ webhooks (HMAC) model)│
└──────────┼───────────────────────┼──────────────────┼────────────────────────────────────┘
│ WebRTC │ ▼REST (API key) │ webhooks (HMAC) ┌──────────────┐
┌───────┴────────┐ ┌──────┴───────────────▼───────────┐ ┌─────────┐ │ OpenAI / │
│ Agent browser │◄─app─►app►│ TEXTIFY (Go) │ │ OpenAIAzure (TTS, │◄─ AiAdapter
│ (Textify UI + │ │ users · tenants · wallet · │prices│ │ (Phtranscribe, 2–3)│
│ LiveKit JSJS) SDK)│ │ pricing · UI · contacts · tickets · SMS/WhatsAppWA │ └─────────┘│ summary, AI) │
└────────────────┘ └──────────────────────────────────┘ └──────────────┘
customer systems ◄── webhook actions (signed) ── engine
ThreeRules rulesthat hold the design together:together
- AT is the only road to the phone
network,network, both directions.Inbound arrives as AT callbacks; outbound starts with AT's call API. TalkingListeningto=aPlay,personkeys(or=AI)GetDigits,happensvoicemailin=Record, live =Dial→ LiveKit.The engine's XML saysDial sip:{callId}@<livekit-sip-host>Sayandis not used.- Waiting for an agent happens on AT (hold loop); the call enters
roomcall-{callId}.LiveKit onlyeveroncereceivesancallsagentfromaccepted. - No TTS during live calls. Text becomes audio files at save time / before dialing.
- The engine is
blind.blindIt knows:accountId,agentRef,numbersnumbers,andcalls.calls, neverNever users,rolesroles, prices, tickets ormoney.contacts.
1.1 Responsibilities
| Component | Owns | Never owns |
|---|---|---|
| Textify (Go) | Telephony / LiveKit / |
|
| Voice Engine | Client apps, accounts (opaque), numbers, |
|
| AT | PSTN, numbers, XML execution, outbound dialing, recording | Anything else |
| LiveKit | Rooms, |
Business logic |
| TTS providers | Turning text into audio files | State |
| OpenAI | Ph |
State, routing |
2. Inside the engine
Stack: Java 21, Spring Boot 3.x, PostgreSQL + Flyway, Redis, @Scheduled jobs,workers, virtual threads.threads, ffmpeg in the image.
2.1 Adapters (plain interfaces, one implementation each,interfaces; no plugin framework)
interface TelephonyAdapter { // impl: AfricasTalkingAdapter
String render(List<ActionXmlAction> actions); // → AT XML (Play, GetDigits, Record, Dial, Reject)
List<OutboundResult> placeCall(placeCalls(String from, List<String> to); // AT call API
InputStream downloadRecording(String url);
}
interface MediaAdapter { // impl: LiveKitAdapter
String sipUriFor(UUID callId); // sip:{callId}@sip.voice…
String roomToken(String room, String identity, boolean canPublish)publish);
void sendData(String room, Object message)msg); // ring / ring_cancel to/ lobbiesconnecting
void startHoldAudio(String room)room, String url); void stopHoldAudio(String room); // agent hold
void removeParticipant(String room, String identity); void deleteRoom(String room);
// Ph 3: dialAi(room, callId)
}
interface TtsAdapter { // impls: BasicTtsAdapter (self-hosted), removeParticipant(room,PremiumTtsAdapter identity)(OpenAI or Azure)
byte[] synthesize(String text, String lang, String voice);
List<VoiceOption> voices();
}
interface AiAdapter { // impl: OpenAiAdapter
Transcript transcribe(InputStream audio, String langHint); // Ph 2
Summary summarize(Transcript t)t, String accountContext); // Ph 2
// Ph 3: acceptLiveCall(openAiCallId, SessionConfig), control WebSocket, hangup
}
A TtsRouter picks the adapter by tier. Swapping latera (another telco, another AI/TTS)provider = a new class,class nothing+ else changes.config.
2.2 Packages
com.qbitspark.voice
├── api // client REST /v1/** (API-key auth)
├── hooks // /hooks/at/{secret}/**, /hooks/livekit, /hooks/openai
├── telephony // AfricasTalkingAdapter,AT adapter, XML builder (Say, Play, GetDigits, Dial, Record, Reject)
├── media // LiveKitAdapter,LiveKit adapter, tokens, lobby/ring, ingress hold(agent audiohold)
├── aivoice // OpenAiAdapterTtsRouter, Basic/Premium adapters, Swahili text normalizer, generation cache
├── library // media assets: upload, validate, ffmpeg transcode, signed play URLs
├── flows // flow JSON model, validator, executorversions, NodeExecutor per node type (node → XML)FLOWS.md)
├── callswaiting // callhold lifecycle,loop, statewaiting machine,order timelineper department, segment builder
├── routing // ring groups,departments, presence, waitingring list,strategies, claim (first accept wins)
├── calls // lifecycle, state machine, context, trail
├── campaigns // broadcastcampaigns, campaigns,recipients, pre-generation, dispatcher, retries, opt-outs
├── endpoints // customer webhook endpoints, signer, SSRF guard, sync/async calls
├── knowledge // KB storage, prompt builder (Ph 3), search tool (later)
├── ai // OpenAI adapter
├── recording // download → S3,MinIO, retention
├── jobs // Postgres job table + @Scheduled workers (transcribe, summarize, cleanup)
├── events // outbox + signed webhookdispatcher dispatcherto client apps
├── accounts // client apps, accounts, numbers, agents
└── common
2.3 Flow model (stored per number, built in Textify's UI)
{
"record": true,
"hours": { "tz": "Africa/Dar_es_Salaam", "open": "Mon-Fri 08:00-17:00, Sat 09:00-13:00",
"closed": { "say": "Tumefunga kwa sasa. Acha ujumbe.", "next": "vm" } },
"start": "greet",
"nodes": {
"greet": { "type": "play", "url": "https://…/karibu.mp3", "next": "menu" },
"menu": { "type": "menu", "say": "Bonyeza 1 mauzo, 2 huduma kwa wateja",
"options": { "1": "sales", "2": "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'svoice callback URLin AT 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 URLof each action (e.g. GetDigitscallbackUrl=/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]exact AT field names.XML responses must be fast (< 500 ms): number/flow config cached in memory, refreshed on change.
3. Call flowspaths
3.1 Inbound → menu → waiting → browser agent
Caller AT Engine LiveKit Agent browser
│dial─►│── POST /voice ──────►│ number→account→flownumber → account → flow(open/closed) │ │
│ │ │ can-call? (Textify, 1.5s,5 s, fail-open) │ │
│ │◄── XML: SayPlay notice + Play greetinggreeting, +GetDigits(menu) GetDigits ───────────│ │
│ "2" ►│── POST /step/{id}/menu (dtmfDigits=2) ────────────────────────────────────►│ node=supportconnect (ring) │
│ │◄── XML: Dial sip:{callId}@sip.voice… record=true ───│ │
│ │══ SIP INVITE (user part = callId) ════════════════════════► room call-{callId}
│ │ │◄─ webhook participant_joined ─│dep_support │
│ │ │ startadd holdto audiowaiting (ingress)order; ──►│ │
│ │ │ pick availablering agents ───────────── ring data msg ─► lobby ─────►│ popup
│ │◄── GetDigits(timeout 1){ Play hold segment } → /wait/{id} ──│ │◄
│ ♪♪ │── POST /wait/{id} (segment ended) ─────────────────────────►│ acceptnot accepted yet │
│ │◄── next segment (viamusic Textify)/ comfort message) ──────────────────│◄──── accept ───────│ (via Textify)
│ │ │ claim (Redis SET NX) → room token ───────────────────►│
│ │ │ stop hold audio ─────────────►│◄ joins room
│ │── POST /wait/{id} ─►│ accepted → Dial sip:{callId}@… record │ │ "Connecting…"
│ │══ SIP INVITE ═════════════════════════════════════════════════► joins,room talkscall-{id} ◄══│
│◄═════╪══════════════════════╪═ live audio ═════════ audio ══════════════════╪══════════════════════│
│ hangup │ │ │
│ │hangup│── POST /voice (isActive=0: duration, amount, recordingUrl) ►│ close call → eventsevents, jobs │
Waiting:Agentsifacceptno agent is available,while the callerstays on hold in the room; when an agent becomesAVAILABLE(or finishes a call), the engine rings again. Order = oldest waiting call first (Redis sorted set per group).Timeout→noAnswernode. Since the callisnowmid-segment;inconnectionLiveKit,happensvoicemail there = the engine plays the prompt via ingress and records via egress,orsimpler: remove the SIP participant so AT'sDialends, then AT continues withat the nextXMLcallback (segments 10–15 s).reached / key pressed → outcome (voicemail etc.) onRecordmaxWaitSectheAT.Dial's callback.[VERIFY]AT continues the flow after Dial ends (prefer this: keeps voicemail on AT).Missed: caller hangs up or nobody handled itUnanswered →call.missed.- Agent hold/transfer inside LiveKit: engine starts Ingress hold audio into the room.
- [VERIFY] wait-loop behaviour (FLOWS §6); fallback = Dial immediately, hold music via Ingress.
3.2 Outbound click-to-call
Agent clicks number → Textify → POST /v1/calls/outbound {accountId, fromNumberId, to, agentRef}
Engine: 1.can-call can-call(OUTBOUND)
2.→ create call + room call-{callId}; return→ agent token →(agent browserjoins, joins (hears ringback tone fromin page)
3.→ TelephonyAdapter.placeCall(AT call API (from, to) → AT dials the customer; store AT sessionId → callId
Customer answers → AT callback (direction=Outbound, sessionId) → engine finds callId
→ XML:XML Dial sip:{callId}@sip.voice…@… → customer joins the agent's room → talk
End → final callback → call.ended
(pstnSeconds outbound, trunkSeconds)
3.3 Broadcast campaignCampaign
Textify → POST /v1/campaigns {accountId, fromNumberId, audioUrl|ttsText, keys:{1:"grp_sales",9:"optout"},
window, cps, maxConcurrent, retries} (+ recipients in chunks) → POST /start
Engine:
1. validate flow (chunkspinned ofversion), ≤10k)opt-outs, variables vs recipient columns
2. PRE-GENERATE voice: render each recipient's text, dedupe by hash, TtsRouter → files (jobs)
cost estimate (premium chars) available before start; failure → campaign PAUSED (TTS_FAILED)
3. Dispatcher (@Scheduled every1 1s, per campaign)s):
- inside calling window? not paused? concurrentactive < max?
-maxConcurrent?
SELECT … FROM campaign_recipients WHERE status='PENDING' AND next_attempt_at<=now()
ORDER BY id LIMIT :cps FOR UPDATE SKIP LOCKED -→ skipAT opt-outscall API → markDIALING
OPTED_OUT4. - placeCall(from, batch) → mark DIALING, store AT sessionIds
Recipient answersAnswer → AT callback → XML:run Playcampaign audio + GetDigits(keys)
- "1" → Dial sip:{callId}@livekit → ring groupflow (sameFLOWS as§5.2) 3.1)with -the "9"recipient's →variables
add to opt_outs → Say "Umeondolewa" ("You've been removed") → hangup5. Final callback → recipient ANSWERED / NO_ANSWER / BUSY / FAILEDFAILED; (+seconds,retries amount)per - NO_ANSWER/BUSY & attempts < max → PENDING with next_attempt_at = now + retryDelaypolicy
Events: campaign.progress every(30 30s (counts)s) · campaign.recipient_results batches(batches) · campaign.paused · campaign.completed
Textify: SMS fallback to NO_ANSWERNO_ANSWER; numbers;pause pauseson campaign (POST /pause) iflow wallet
low
The campaign_recipients table is the queue. No broker needed.
3.4 RecordingAfter →the transcript → summarycall (Phase 2)jobs)
final callback recordingUrl → job DOWNLOAD → S3MinIO s3://rec/{accountId}/{callId}.mp3voice-recordings → recording.ready
→ job(Ph 2) TRANSCRIBE (OpenAI, sw/en) → job SUMMARIZE (OpenAI, JSON) → summary.ready
voicemail → voicemail.received (+ recording)
daily job RETENTION → delete whereexpired delete_after < now()recordings
Recording comes from AT (Dial record="true"), soRecord). noNo LiveKit egress service is needed. [VERIFY] AT records the Dial-to-SIP leg;SIP; fallback = LiveKit egress.
Summary JSON: {summary, intent, resolved, followUps[], sentiment, topic, language}.
3.5 AI (Phase 3),3 same patternAI
Flow node ai (or noAnswer: ai, or campaign key 1: ai)node → call is dialedDial 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/instructions built from the knowledge base + node instructions + tools → controls via WebSocket from Java → rings transfer_to_humanhandoffagentsa →department (agent joins → engine hangs up theroom, AI leg.leg Detailshung keptup) in→ summary job. Appendix A.
3.6 Call state machine
INBOUND: RINGING → IN_FLOW → WAITING_AGENT → CONNECTING → ANSWERED → COMPLETED
OUTBOUND: DIALING → IN_FLOW (campaign) / CONNECTING (click-to-call) → ANSWERED → COMPLETED
any → MISSED | NO_ANSWER | BUSY | FAILED | REJECTED_NO_BALANCE
handled_by: NONEFLOW_ONLY | FLOW_ONLYVOICEMAIL | AGENT | AI | AI_THEN_AGENT
4. Browser agents
AgentNo browser extension or app install needed. The agent UIlivesis a normal page in Textify's web app,using the LiveKit JSSDK.SDK; browsers have WebRTC built in (Chrome, Edge, Firefox, Safari).- Requires: HTTPS, microphone permission, the Textify tab open (can be in the background).
- Browsers block sound until the user interacts with the page, so agents click "Start shift / Go available" once; that click unlocks the ringtone.
- Ring alerts when the tab is in the background: Web Notifications API + ringtone; optional PWA (installable web app) for a desktop-app feel. A Chrome extension or native/mobile app is not required; consider only later (e.g. ringing with the browser closed).
- Lobby room per
agent:agentlobby-{accountId}-{agentRef},connected with(micoffoff) whilelogged in.available:Presencepresence = lobbyjoin/leave webhooksparticipant_joined/left(automatic online/offline)+ explicitstatus from Textify.status;Ringingringing =enginesendData(lobby, {type:"ring", callId, from, numberLabel,.groupName}department})
- Accept → browser → Textify backend →
POST /v1/calls/{id}/accept {agentRef}→engine claims (SET call:{id}:agent NX EX 60)claim →returnscall-room token →browserjoinjoins(showswith"Connectingmiccaller…"on.until the caller arrives). Losers:409+ring_cancel. BrowserScreenneverpop:holdsthe browser usesfromto ask Textify for contact + open tickets. The enginecredentials;neveronlyseesshort-livedthem.- Contacts: Textify's existing contact module (the one SMS/WhatsApp already use) is reused for the call center: campaign recipients, caller names, customer timeline. The engine stores no contacts; campaigns receive
{e164, vars}only. - In-call: mute (client-side), hold (engine starts Ingress music, mutes agent), transfer (engine re-rings target; caller stays in room
tokenswith(≈10holdminmusic),tohangjoin, room-scoped).up.
5. Media library & voice generation
5.1 Uploaded audio
Textify (file or browser recording) → POST /v1/accounts/{id}/media (multipart)
Engine: validate (≤ 10 MB, ≤ 5 min, magic bytes) → MinIO original/ → job TRANSCODE (ffmpeg:
mono, 16 kHz, MP3, loudness-normalized, trimmed) → MinIO play/ → READY → media.ready
5.2 Text → voice (generated audio)
- Messages with
source: textare generated at flow save (inbound) or before dialing (campaigns, per-recipient text). - Pipeline: render variables → Swahili normalizer (numbers, currency, dates, times → words) → cache lookup
hash(text+lang+voice+tier)→TtsRouter.tier → adapter→ ffmpeg normalize → MinIOgenerated/→media_assetsrow (kindGENERATED). - Basic: self-hosted light model in a small sidecar (
tts-basic, CPU), e.g. Piper with a Swahili voice, only if quality + commercial licence pass the listening test. [VERIFY licence] - Premium: OpenAI TTS or Azure Speech (Swahili neural voices), picked by listening test.
- Costs predictable: premium character count computed before generation → shown in Textify → reported (
tts.usage). - No runtime fallback to AT
Say. Failure → flow save error (previous version stays live) / campaign paused. Call-time safety net: system file. - On-demand generation exists only for sync webhook responses (Ph 2):
Play url=/media/tts/{hash}.mp3generated on first fetch, then cached. [VERIFY] ATPlayfetch timeout.
5.3 Playing to AT
- XML uses
<Play url="https://voice-api…/media/{id}.mp3?t={hmac,24h}">; the engine streams from MinIO (cache headers). Buckets stay private. - Same URLs feed LiveKit Ingress (agent hold).
- Deleting media used by an active flow / running campaign → 409. Per-account storage quota.
5.4 Buckets
voice-media/system/{lang}/{name}.mp3 # notices, hold default, sorry, invalid option, numbers 1–20
voice-media/{accountId}/original/{mediaId}.{ext}
voice-media/{accountId}/play/{mediaId}.mp3
voice-media/generated/{hash}.mp3 # shared cache for generated voice
voice-recordings/{accountId}/{yyyy}/{mm}/{callId}.mp3 # retention applies
6. Knowledge base (Phase 3)
- Stored in the engine per account (business facts for call handling; not user data). Edited in Textify, pushed with
PUT /v1/accounts/{id}/knowledge. - Structure:
({ "profile": { "name": "ABC Ltd", "about": "...", "location": "...", "hours": "..." }, "services": [ { "name": "Kifurushi cha mwezi", "price": "TZS 25,000", "notes": "..." } ], "faqs": [ { "q": "...", "a": "..." } ], "departments": [ { "id": "dep_sales", "description": "New orders and prices" } ], "rules": { "never": ["Promise refunds"], "handoverWhen": ["Caller asks for a person", "Complaint"] }, "documents": [ "kb_doc_1" ] }Kifurushi cha mwezi= "Monthly package".) - v1 of AI: prompt builder puts the whole KB into the AI session instructions (small businesses fit easily).
- Large KB: chunk documents into Postgres (
kb_chunks, full-text or pgvector) and give the AI asearch_knowledgetool. Still just Postgres. - Live data via the account's endpoints exposed as AI tools.
- Improvement loop: the summary job extracts unanswered questions →
kb.unansweredevent → Textify suggests FAQ additions.
7. Data model (PostgreSQL)
client_apps (id uuid pk, name, api_key_hash, api_key_hash_next null,status, -- ACTIVE | SUSPENDED | REMOVED
webhook_url, webhook_secret_hash, can_call_url, rate_limit_rps, features jsonb,
suspended_reason null, created_at, removed_at null)
client_app_keys (id uuid pk, client_app_id fk, key_hash, prefix, created_at, last_used_at,
last_used_ip, revoked_at null) -- ≥1 active; 2 keys forduring rotation
webhook_url,audit_log webhook_secret,(id can_call_url,bigserial created_at)pk, actor, action, target, data jsonb, ip, at)
accounts (id uuid pk, client_app_id fk, external_ref, status, default_language default 'sw',
retention_days default 90, features jsonb, created_at, unique(client_app_id, external_ref))
phone_numbers (id uuid pk, e164 unique, provider default 'AT', type, status,
client_app_id fk null, -- REGULARpool |owner TOLL_FREE(null status,= global pool)
account_id fk null,
label, flowhours jsonb, flow_versioncurrent_flow_version_id fk null, updated_at)
flows (id uuid pk, account_id fk, kind, -- NUMBER | CAMPAIGN
owner_id uuid, created_at)
flow_versions (id uuid pk, flow_id fk, version int, updated_at)definition jsonb, created_at,
premium_chars int default 0, unique(flow_id, version))
media_assets (id uuid pk, account_id fk null, -- null = system / shared generated
kind, -- UPLOAD | GENERATED | SYSTEM
name, original_key null, play_key, text_hash null, tier null, lang, voice,
mime, size_bytes, duration_sec, status, error null, created_at)
agents (id uuid pk, account_id fk, external_ref, display_name, status, last_seen_at,
unique(account_id, external_ref))
ring_groupsdepartments (id uuid pk, account_id fk, name, strategy)strategy, --wrap_up_sec ALL_AT_ONCEdefault |0)
ROUND_ROBIN
ring_group_membersdepartment_members (ring_group_iddepartment_id fk, agent_id fk, position, pk(ring_group_id,department_id, agent_id))
endpoints (id uuid pk, account_id fk, name, url, secret, active bool, created_at)
calls (id uuid pk, account_id fk, number_id fk, campaign_id fk null, recipient_id fk null,
flow_version_id fk, direction, from_e164, to_e164, at_session_id, livekit_room,
status, handled_by, agent_id null, current_node,department_id null, context jsonb,
started_at, answered_at, ended_at, end_reason,
pstn_seconds, trunk_seconds, ai_seconds, wait_seconds, at_amount numeric, at_currency, dtmf_path text,
ai_usage jsonb null)
call_events (id bigserial pk, call_id fk, type, node_id null, at, data jsonb) -- trail
campaigns (id uuid pk, account_id fk, from_number_id fk, flow_version_id fk, status, -- DRAFT|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, premium_chars_estimate int, created_at, completed_at)completed_at, pause_reason null)
campaign_recipients (id bigserial pk, campaign_id fk, e164, vars jsonbjsonb, media_id fk null,
status, attempts int,attempts, next_attempt_at, last_call_id fk null, answered_seconds,
keypress,keys text, index(campaign_id, status, next_attempt_at))
opt_outs (account_id fk, e164, source, created_at, pk(account_id, e164))
knowledge_bases (account_id pk fk, data jsonb, updated_at) -- Ph 3
kb_chunks (id bigserial pk, account_id fk, doc_id, text, tsv tsvector, embedding vector null) -- later
recordings (id uuid pk, call_id fk unique, kind, -- CALL | VOICEMAIL
source_url, storage_key, duration_sec, size_bytes, status, delete_after)
transcripts (call_id pk fk, status, language, text, segments jsonb, summary jsonb, model, created_at)
jobs (id bigserial pk, type, ref_id, status, attempts, run_after, last_error)
-- DOWNLOAD|TRANSCRIBE|SUMMARIZE|…TRANSCODE | TTS | DOWNLOAD | TRANSCRIBE | SUMMARIZE | RETENTION
outbox_events (id uuid pk, client_app_id fk, type, payload jsonb, attempts, next_attempt_at,
delivered_at null, last_error)
endpoint_deliveries (id uuid pk, endpoint_id fk, call_id fk, node_id, payload jsonb, mode,
attempts, next_attempt_at, status, response_code null, last_error)
Redis: presence:{accountId}:{agentRef}, call:{id}:agent (claim)claim, SET NX), wait:{ (sorted groupId}departmentId}setset: ofcallId waitingby callIds)wait start), rr:{, groupId}departmentId}campaign:{id}:active, (concurrencyflow/number counter).config cache invalidation channel.
8. Security (service authentication)
| Traffic | Method |
|---|---|
| Textify → engine | API key per client app (hashed; 2 active keys for rotation; scoped to its accounts; rate-limited) |
| Engine → Textify | HMAC-SHA256 signed webhooks + timestamp (reject > 5 min) |
| Engine → customer endpoints | HMAC with the endpoint's secret, idempotency key, HTTPS only, SSRF guard (block private/loopback/link-local/metadata IPs after DNS resolution), 3 s timeout |
| AT → engine | Unguessable secret in path + AT IP allowlist + sanity checks (known number / call) [VERIFY AT IPs, any signature] |
| LiveKit → engine | Signed JWT (WebhookReceiver) |
| OpenAI → engine (Ph 3) | Webhook signature + dedupe |
| AT → LiveKit SIP | Firewall + inbound trunk allowed_addresses = AT IPs; unknown callId → reject |
| Browser → LiveKit | Short-lived room tokens minted by engine, requested through Textify (human auth happens there) |
| AT → audio URLs | HMAC-signed, expiring media URLs |
No OAuth/Keycloak/mTLS between our services. Secrets in env only. Recordings in private buckets with signed URLs, retention job, recording notice. Keep CrowdSec/fail2ban.
8.1 Client apps: how a service gets onto the engine, uses it, and is stopped or removed
Any service that consumes the engine (Textify first; later JikoXpress, Mauzodukani, NexGate…) is a client app. The engine treats them all the same and keeps them fully isolated from each other.
Who registers a service: only the engine owner (QBIT SPARK ops). There's no self-signup, and client apps can never create, see or manage other client apps. Textify is simply the first client app registered.
How: CLI-first (voice-admin), no public ops API
- The admin tool is a mode of the engine jar (
java -jar voice-engine.jar admin …, wrapped asvoice-admin), run on the Voice box over SSH. It talks directly to the engine database, so no ops endpoint exists on the internet. - SSH: key-only login, root login disabled, port 22 open only to the owner's IPs (ufw), CrowdSec on.
- Optional later (web ops panel): the
/ops/*API binds only to localhost or a WireGuard VPN interface, never the public domain, and requires an ops token stored only on the box (env), plus the admin IP allowlist.
$ voice-admin client-app create --name textify \
--webhook-url https://portal.textify.africa/voice/events \
--can-call-url https://portal.textify.africa/voice/can-call \
--features inbound,outbound,campaigns --rate-limit 50
→ clientAppId: ca_7f3…
→ API key: vk_live_3f9a… (shown ONCE)
→ Webhook secret: whsec_81c2… (shown ONCE)
$ voice-admin numbers allocate +2557XXXXXXXX --client ca_7f3…
$ voice-admin client-app test-webhook ca_7f3…
$ voice-admin key rotate ca_7f3… # new key; old one stays valid until revoked
$ voice-admin key revoke ca_7f3… key_12…
$ voice-admin client-app suspend ca_7f3… --reason "…" [--hard]
$ voice-admin client-app resume ca_7f3…
$ voice-admin client-app export ca_7f3… # zip of calls, recordings index, flows
$ voice-admin client-app remove ca_7f3… # grace period, then delete
$ voice-admin audit --client ca_7f3… --since 7d
Security throughout
| Stage | Protection |
|---|---|
| Who can register | Only someone with SSH access to the Voice box (owner's key + IP) |
| Creating credentials | Random 256-bit secrets generated by the engine; only hashes stored; shown once in the terminal |
| Hand-over | Owner pastes them directly into the consuming service's env / secret store on its own server. Never email, chat, tickets or git |
| Identifiable keys | Prefix vk_live_ / vk_test_ + short key id, so a leaked key is easy to spot and revoke; full key never logged |
| Using the engine | HTTPS only; key → exactly one client app; all data filtered by client_app_id; per-client rate limit + feature flags |
| Webhooks back | Signed with that client's webhook secret; client verifies signature + timestamp |
| Leak / staff change | key rotate (both valid briefly) → key revoke: effective immediately |
| Stop / remove | suspend (reversible) / remove (export → release numbers → revoke keys → delete after grace) |
| Traceability | Every admin command (who, when, from which IP) and each key's first/last use + IP in audit_log |
No approval steps: protection comes from who can reach the CLI plus show-once, hashed, scoped, rotatable keys.
Lifecycle
| Step | What happens | How |
|---|---|---|
| 1. Register | Create the client app: name, webhook_url, can_call_url, rate limit, allowed features (inbound, campaigns, AI) |
voice-admin client-app create … → returns clientAppId, API key and webhook secret, shown once (only hashes stored) |
| 2. Give it numbers | Assign AT numbers from the global pool to the client app's pool | voice-admin numbers allocate <e164> --client <id> |
| 3. Client stores credentials | The client keeps the API key + webhook secret in its own secrets/env | Client side |
| 4. Verify | Client calls GET /v1/ping (auth check); engine sends a signed test.ping webhook to check the client's receiver |
voice-admin client-app test-webhook <id> |
| 5. Consume | Client creates its accounts (POST /v1/accounts {externalRef}), assigns numbers from its pool to accounts, saves flows, adds agents, runs campaigns… |
/v1/** with Authorization: Bearer <key> |
| 6. Rotate key | Issue a second key; both work; client switches; old key revoked | voice-admin key rotate <id> → voice-admin key revoke <id> <keyId> |
| 7. Suspend (stop, reversible) | API returns 403 CLIENT_SUSPENDED; running campaigns paused; no new outbound calls; webhooks held in the outbox (delivered after resume). Inbound calls keep working by default, so callers aren't hurt. Option hard: true also answers inbound with a system "service unavailable" message |
voice-admin client-app suspend <id> --reason … [--hard] · … resume <id> |
| 8. Remove (permanent) | Suspend → cancel campaigns → release numbers back to the global pool (AT callback config untouched; numbers reusable) → revoke keys → stop webhooks → optional export (calls, recordings, flows as a zip) → data deleted after a grace period (default 30 days) | voice-admin client-app export <id> · … remove <id> |
Isolation rules
- Every
/v1request resolves to exactly one client app from the API key; every query is filtered byclient_app_id. A key can never see another client app's accounts, numbers, calls or media. external_refis unique per client app (two apps can both have "tenant_1").- Numbers belong to one client app's pool at a time; inbound calls are routed by number → client app → account.
- Each client app has its own webhook URL + secret, rate limit (e.g. 50 req/s) and feature flags.
- An account inside a client app can also be suspended (
PATCH /v1/accounts/{id} {status: SUSPENDED}): same effect as above, but for one tenant. Textify uses this when a customer stops paying.
Audit: every admin command and key use (first/last seen, IP) is logged in audit_log; voice-admin audit reads it.
9. Communication
| Need | Mechanism |
|---|---|
| Textify ⇄ engine commands/queries | REST |
| Engine → Textify facts | Webhooks via Postgres outbox + @Scheduled dispatcher |
| Engine → customer systems | webhook node: async via endpoint_deliveries (retries), sync direct (Ph 2) |
| AT call control | Callbacks + XML; AT REST call API |
| Ring agents | LiveKit data messages (lobby rooms) |
| Presence, claims, waiting orders, concurrency, cache invalidation | Redis |
| Background work | Postgres jobs + workers (FOR UPDATE SKIP LOCKED) |
| Broadcast queue | campaign_recipients table + dispatcher |
Not used: MQTT, Kafka, RabbitMQ. Add Redis Streams first only if several engine instances + high event volume demand it.
10. APIs
6.10.1 Client API (Textify → engine)
| Area | Endpoints |
|---|---|
| Accounts | POST /accounts · PATCH /accounts/{id} |
| Numbers | GET /numbers?status=UNASSIGNED · POST /numbers/{id}/assign · POST /numbers/{id}/release · PUT /numbers/{id}/hours · PUT /numbers/{id}/flow (GET /numbers/{id}/flow/versions · POST /numbers/{id}/flow/rollback {version} · POST /numbers/{id}/test-call {toE164} |
| Flow tools | POST /flows/validate (dry run, returns errors + premium char estimate) |
| Media | POST /accounts/{id}/media (multipart) · POST /accounts/{id}/media/generate {text, lang, voice, tier} (preview) · GET /accounts/{id}/media · GET /media/{id} · DELETE /media/{id} · GET /voices |
| Agents | PUT /accounts/{id}/agents/{ref} · DELETE … · PUT …/status · POST …/lobby-token |
POST/PUT/DELETE /accounts/{id}/ |
|
| Calls | POST /calls/outbound · POST /calls/{id}/accept · `POST /calls/{id}/hold |
| Campaigns | POST /campaigns · POST /campaigns/{id}/recipients (POST /campaigns/{id}/estimate · `POST /campaigns/{id}/start |
| Opt-outs | GET/POST/DELETE /accounts/{id}/opt-outs |
POST/PUT/DELETE /accounts/{id}/endpoints[/{eid}] · POST /endpoints/{eid}/test |
|
| Knowledge (Ph 3) | PUT /accounts/{id}/knowledge · POST /accounts/{id}/knowledge/documents · GET /accounts/{id}/knowledge/unanswered |
| Stats | GET /numbers/{id}/stats (per node) · GET /campaigns/{id}/stats |
| Health | GET /ping (auth check) |
Ops: CLI voice-admin on the box (primary). Optional /ops HTTP API bound to localhost/VPN only, ops token + admin IPs |
POST /ops/client-apps · POST / DELETE · POST /ops/client-apps/{id}/test-webhook · `POST /ops/client-apps/{id}/suspend |
6.10.2 Engine → Textify
Balance check:Balance:GET {can_call_url}?accountRef&direction&estimatedCalls?→{allowed, maxMinutes?};(1.5ss,timeout → allow.fail-open).- Webhooks (outbox, signed, retried 1m/5m/30m/2h…
up≤to2424h,h, idempotent by event id):
| Event | |
|---|---|
call.started call.answered |
callId, accountRef, direction, from, to, number, handledBy, |
call.ended |
status, usage {pstnSeconds, trunkSeconds, waitSeconds, aiSeconds, atAmount}, recorded, |
call.missed |
callId, from, to, |
call.action_triggered |
callId, node, endpoint, status |
message.requested |
callId/campaignId, to, channel, template, params → Textify sends SMS/WhatsApp |
voicemail.received |
callId, from, mediaId/recording |
recording.ready summary.ready |
|
· media.failed |
|
tts.usage |
accountRef, chars, tier, ref (flow version / campaign) |
campaign.progress · campaign.recipient_results · campaign.paused · campaign.completed |
|
(Ph 3) |
accountRef, questions[] |
test.ping |
6.10.3 WebhooksHooks into the engine
| Endpoint | From | |
|---|---|---|
POST /hooks/at/{secret}/voice /step/{callId}/{node} · /wait/{callId} |
AT | |
POST /hooks/livekit |
LiveKit | |
POST /hooks/openai (Ph 3) |
OpenAI | |
GET GET /media/tts/{hash}.mp3?t=… |
AT / LiveKit Ingress |
7. Communication choices
| |
| |
|
Not used in v1: MQTT (IoT protocol, and browsers are covered by LiveKit), Kafka (overkill), 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.
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 trunkallowed_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.11. Infrastructure
9.11.1 Dedicated Voice box (Docker Compose; host networking for media)
| Service | Notes |
|---|---|
caddy / nginx |
TLS for voice-api.… (wss://voice-rtc.… (LiveKit) |
livekit |
livekit/livekit-server, 7880 on localhost |
sip |
livekit/sip, 5060 + RTP 10000–20000, |
ingress |
livekit/ingress, agent-hold audio |
redis |
shared by LiveKit + engine (separate DB index) |
postgres |
engine DB (pgvector extension later for KB) |
minio |
private buckets voice-media, voice-recordings; console on localhost |
tts-basic |
self-hosted light TTS model (CPU), HTTP on localhost (if Basic passes the test) |
voice-engine |
Spring Boot jar + ffmpeg |
(egress |
only if AT recording isn't |
Storage: S3-compatible private bucket (Linode Object Storage or MinIO). Start 4 vCPU / 8 GB;GB, measure. Choose the regionRegion with the lowest RTT to AT'sAT SIP servers.
9.11.2 Ports
| Port | Open to | Purpose |
|---|---|---|
| 22 | voice-admin CLI |
|
| 80/443 | world | API, hooks, media, WSS |
| 7881/tcp, 50000–60000/udp | world | WebRTC |
| 5060 udp/tcp, 10000–20000/udp | AT IPs only | SIP + RTP |
| 7880, 6379, 5432, 9000/9001, tts-basic, 8080 | localhost | internal |
| (Ph 3) out |
OpenAI | AI over SIP |
9.11.3 Env
AT_USERNAME / AT_API_KEY / AT_CALLBACK_SECRET / AT_IPS
LIVEKIT_URL=ws://127.0.0.1:7880LIVEKIT_URL / LIVEKIT_PUBLIC_WS_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET LIVEKIT_SIP_HOST=sip.voice.textify.africa/ LIVEKIT_SIP_HOST
DB_URL / DB_USER / DB_PASSWORD · REDIS_URL / REDIS_PASSWORD
S3_ENDPOINTMINIO_ENDPOINT / S3_BUCKETMINIO_ACCESS_KEY / S3_ACCESS_KEYMINIO_SECRET_KEY
MEDIA_URL_SECRET
TTS_BASIC_URL · TTS_PREMIUM_PROVIDER (openai|azure) / S3_SECRET_KEYkeys
OPENAI_API_KEY (Ph 2) · OPENAI_WEBHOOK_SECRET / OPENAI_SIP_URI (Ph 3)
AUDIO_HOLD_URL / AUDIO_CONSENT_URL / AUDIO_NO_BALANCE_URL
9.11.4 LiveKit bootstrap (once, idempotent)
- Inbound trunk
at:allowed_addresses= ATIPsIPs,(no numberfilter, since the user part is our callId).filter. - Dispatch rule
dispatch_rule_calleewith,room_prefix: "call-",so→sip:{callId}@…→⇒ roomcall-{callId}.[VERIFY]callee rule uses the SIP user part.. - (Ph 3) Outbound trunk
openai(TLS + SRTP).
10.12. Observability & testing
Logs:JSONJSON,logs withcallId,accountId,atSessionId,campaignId,on every line.node.- Metrics: active calls, waiting
callers,callers + wait time, ring-to-accept, answer/missed rate,ring-to-accept time,callbacklatency,latency (p95 < 500 ms), campaign dials/secand+ answer rate, TTS jobs + premium chars, endpoint delivery failures, outboxbacklog, job failures.backlog. - Alerts: SIP down, callback
errors > N%,errors, zero inbound in business hours, outbox backlog, campaignstalled.stalled, TTS failures, system-file fallback used. - PoC
checklist= SPEC §10 Phase0 steps 1–5.0. - Unit: flow
validator/executorvalidator, every NodeExecutor (node → XML),routingwait loop, claim race,waitingringorder,strategies, campaign dispatcher (window, cps, retries,window/cps/retries/opt-out),outboxSwahiliretry,normalizer,signatures.signers, SSRF guard. - Integration:
ATTestcontainerssandbox/real(Postgres,number,Redis, MinIO), WireMock (AT, Textify, endpoints, TTS, OpenAI), LiveKitdev,dev + browser test page,TextifyrealwebhookATmock.number.
11.13. Tech stack & tools (one place)
| Layer | Tool | Used for |
|---|---|---|
| Engine | Java 21, Spring Boot 3.x (Web, Validation, |
All engine logic |
| DB | PostgreSQL 16 + Flyway (pgvector later) | State, flows, outbox, jobs, campaign |
| Cache/locks | Redis 7 | Presence, claims, waiting |
| Telephony | Africa's Talking Voice africastalking-java |
PSTN both directions |
| Media | LiveKit server, io.livekit:livekit-server |
Rooms, |
| Voice (TTS) | Basic: self-hosted light model (e.g. Piper, CPU) [VERIFY licence/quality]; Premium: OpenAI TTS or Azure Speech | Text → audio files |
| AI | OpenAI REST (summarize); Ph java.net.http.WebSocket |
|
| Storage | ||
| Audio processing | ffmpeg | Transcode, normalize, trim |
| HTTP |
Spring RestClient |
AT, Textify, endpoints, TTS, OpenAI |
| Security | Hashed API WebhookReceiver, |
Service auth |
| Edge | Caddy (or nginx) | TLS, reverse proxy |
| Deploy | Docker |
|
| Observability | Logs, |
|
| Testing | JUnit 5, |
Unit + integration |
| Not used | AT Say, MQTT, Kafka, RabbitMQ, OAuth/Keycloak, |
Not needed |
12.14. Repository layout
textify-voice/
├── docs/ SPEC.md, FLOWS.md, ARCHITECTURE.md
├── voice-engine/ Spring Boot (Java 21)
├── tts-basic/ self-hosted TTS sidecar (if Basic passes)
├── deploy/ docker-compose.yaml, livekit.yaml, sip, ingress, Caddyfile, firewall.sh, .env.example
├── audio/ hold, consent, no-balancesystem prompts (sw/en): notices, hold default, sorry, invalid option, numbers
└── agent-test/ minimal HTML page with LiveKit JS for the PoC (real UI lives in Textify)
13.15. Open items to verify
- AT: Dial to
sip:{callId}@host,;IPs,IPs; codecs/DTMF,DTMF;billing, recording ofbilling; Dial-to-SIP,SIP recording; CPS/concurrency, outboundconcurrency; per-second vs per-minute,minute; multi-number callAPI,API; callbackfields andsignature (SPEC §11). - AT:
doesGetDigits wrapping Play (key interrupt, timeouts, callbacks per call) for the wait loop; Play fetch timeout/streaming; XMLcontinuecontinuation afteraDialleg ends (for voicemail after timeout)?Dial. - LiveKit: callee dispatch
rulewith arbitrary user part;inboundtrunk without number filter;ingressIngress URLinput for hold audio. LiveKitinput; JVM SDK(io.livekit:livekit-server)methodnamesnames.- TTS:
tokens,Basicdatacandidatemessages,qualityingress,+participants.commercial licence; Premium choice; Swahili normalizer coverage. - Ph 3: LiveKit → OpenAI SIP
interop (TLS/SRTP), OpenAIinterop; Realtime vs LiveAPI,API; Swahili AI qualityand+ cost.
Appendix A: Phase 3 AI details (Java-only)
LiveKit has noNo Java SDKthatjoinsjoinsLiveKit rooms as an audioparticipant, andparticipant; Spring AI / LangChain4j are text-only,only.soSo OpenAI joins the room as a SIP participantinstead..- 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 (KB prompt + node instructions; Swahilifirst,first; shortanswers,answers; never invent prices), voice, tools (transfer_to_human,take_message,end_call, endpoint tools,send_sms) → WebSocketwss://api.openai.com/v1/realtime?call_id=…for tool calls/transcripts/usage → handoff: ring department, agent joins,POST …/hangupafterAIhandover.leg. - Firewall: out TCP 5061 to
sip.api.openai.com; RTP with OpenAI media CIDRs13.79.45.80/28, 23.98.140.64/28, 40.67.149.176/28, 40.83.204.240/28(OpenAI SIP guide, Oct 2026; re-check). - Trade-off: OpenAI voices only. If Swahili
voiceisn't good enough → fallback: small Python LiveKit Agents worker with another TTS, calling engine internal endpoints;nonothingotherelsechange.changes.