Textify Voice: Architecture
Companion to
SPEC.md(what/why) andFLOWS.md(flow & action reference). This file is how: components, adapters, call paths, media & voice, knowledge base, data model, APIs, auth, communication, 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 · callbacks · XML (Play, │
│ GetDigits, Record, Dial) · call API · │
│ recordings · SIP to LiveKit │
└───────┬───────────────────▲──────────┘
callbacks (POST) │ │ XML replies · call API · fetch audio URLs
Dial sip:{callId} │ │
┌──────────────────────── Voice box (dedicated) ──────┼──────────────────────────────┐
│ ▼ │ │
│ ┌──────────────┐ SIP ┌──────────────────────────────────────────────┐ │
│ │ livekit-sip │◄─────┤ VOICE ENGINE (Spring Boot) │──► Postgres│
│ └──────┬───────┘ (AT) │ accounts · numbers · flows · media · agents · │──► Redis │
│ ┌──────▼─────────┐ │ calls · campaigns · endpoints · knowledge · │──► MinIO │
│ │ livekit-server │◄──►│ recordings · events │ │
│ │ + ingress │ │ Telephony│Media│Tts(Basic,Premium)│Ai adapters │──► tts-basic│
│ └──────▲─────────┘ └──────▲───────────────┬────────────────────────┘ (CPU model)│
└─────────┼─────────────────────┼───────────────┼────────────────────────────────────┘
│ WebRTC │ REST (API key) │ webhooks (HMAC) ┌──────────────┐
┌───────┴────────┐ ┌──────┴───────────────▼──────────┐ │ OpenAI / │
│ Agent browser │◄app►│ TEXTIFY (Go) │ │ Azure (TTS, │
│ (Textify UI + │ │ users · tenants · wallet · prices│ │ transcribe, │
│ LiveKit JS) │ │ UI · contacts · tickets · SMS/WA │ │ summary, AI) │
└────────────────┘ └──────────────────────────────────┘ └──────────────┘
customer systems ◄── webhook actions (signed) ── engine
Rules that hold the design together
- AT is the only road to the phone network, both directions.
- Listening =
Play, keys =GetDigits, voicemail =Record, live =Dial→ LiveKit.Sayis not used. - Waiting for an agent happens on AT (hold loop); the call enters LiveKit only once an agent accepted.
- No TTS during live calls. Text becomes audio files at save time / before dialing.
- The engine is blind:
accountId,agentRef, numbers, calls. Never users, roles, prices, tickets or contacts.
1.1 Responsibilities
| Component | Owns | Never owns |
|---|---|---|
| Textify (Go) | Users/login/roles, tenants, partners, wallet, prices, all UI (answer-mode templates, campaign screens, audio library UI, KB editor), contacts, tickets, screen-pop data, SMS/WhatsApp sending | Telephony / LiveKit / TTS / AI logic |
| Voice Engine | Client apps, accounts (opaque), numbers, flows + versions, media + generated voice, agents (opaque refs), departments, presence, waiting orders, calls, campaigns, opt-outs, endpoints, knowledge base, recordings, transcripts, usage events | People, money, tickets |
| AT | PSTN, numbers, XML execution, outbound dialing, recording | Anything else |
| LiveKit | Rooms, WebRTC, SIP from AT, agent-hold audio, Ph 3 AI participant | Business logic |
| TTS providers | Turning text into audio files | State |
| OpenAI | Ph 2 transcribe/summarize; Ph 3 live AI | State, routing |
2. Inside the engine
Stack: Java 21, Spring Boot 3.x, PostgreSQL + Flyway, Redis, @Scheduled workers, virtual threads, ffmpeg in the image.
2.1 Adapters (plain interfaces; no plugin framework)
interface TelephonyAdapter { // impl: AfricasTalkingAdapter
String render(List<XmlAction> actions); // → AT XML (Play, GetDigits, Record, Dial, Reject)
List<OutboundResult> placeCalls(String from, List<String> to);
InputStream downloadRecording(String url);
}
interface MediaAdapter { // impl: LiveKitAdapter
String sipUriFor(UUID callId); // sip:{callId}@sip.voice…
String roomToken(String room, String identity, boolean publish);
void sendData(String room, Object msg); // ring / ring_cancel / connecting
void startHoldAudio(String 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), PremiumTtsAdapter (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, String accountContext); // Ph 2
// Ph 3: acceptLiveCall(openAiCallId, SessionConfig), control WebSocket, hangup
}
A TtsRouter picks the adapter by tier. Swapping a provider = new class + config.
2.2 Packages
com.qbitspark.voice
├── api // client REST /v1/** (API-key auth)
├── hooks // /hooks/at/{secret}/**, /hooks/livekit, /hooks/openai
├── telephony // AT adapter, XML builder
├── media // LiveKit adapter, tokens, lobby/ring, ingress (agent hold)
├── voice // TtsRouter, Basic/Premium adapters, Swahili text normalizer, generation cache
├── library // media assets: upload, validate, ffmpeg transcode, signed play URLs
├── flows // model, validator, versions, NodeExecutor per node type (FLOWS.md)
├── waiting // hold loop, waiting order per department, segment builder
├── routing // departments, presence, ring strategies, claim (first accept wins)
├── calls // lifecycle, state machine, context, trail
├── campaigns // 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 → MinIO, retention
├── jobs // Postgres job table + workers
├── events // outbox + signed dispatcher to client apps
├── accounts // client apps, accounts, numbers, agents
└── common
3. Call paths
3.1 Inbound → menu → waiting → browser agent
Caller AT Engine LiveKit Agent browser
│dial─►│── POST /voice ───►│ number → account → flow(open/closed) │ │
│ │ │ can-call? (Textify, 1.5 s, fail-open) │ │
│ │◄── Play notice + Play greeting, GetDigits(menu) ───────────│ │
│ "2" ►│── POST /step/{id}/menu ────────────────────────────────────►│ connect dep_support │
│ │ │ add to waiting order; ring agents ───────────── ring ──────►│ popup
│ │◄── GetDigits(timeout 1){ Play hold segment } → /wait/{id} ──│ │
│ ♪♪ │── POST /wait/{id} (segment ended) ─────────────────────────►│ not accepted yet │
│ │◄── next segment (music / comfort message) ──────────────────│◄──── accept ───────│ (via Textify)
│ │ │ claim (Redis SET NX) → room token ───────────────────────────►│ joins room
│ │── POST /wait/{id} ─►│ accepted → Dial sip:{callId}@… record │ │ "Connecting…"
│ │══ SIP INVITE ═════════════════════════════════════════════════► room call-{id} ◄══│
│◄═════╪═══════════════════════ live audio ═══════════════════════════╪════════════════════│
│hangup│── POST /voice (isActive=0: duration, amount, recordingUrl) ►│ close → events, jobs │
- Agents accept while the caller is mid-segment; connection happens at the next callback (segments 10–15 s).
maxWaitSecreached / key pressed → outcome (voicemail etc.) on AT. Unanswered →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 → Textify → POST /v1/calls/outbound {accountId, fromNumberId, to, agentRef}
Engine: can-call → create call + room call-{callId} → agent token (agent joins, hears ringback in page)
→ AT call API (from, to) → store AT sessionId
Customer answers → AT callback (Outbound, sessionId) → XML Dial sip:{callId}@… → customer joins → talk
End → final callback → call.ended
3.3 Campaign
Textify → POST /v1/campaigns (+ recipients in chunks) → POST /start
Engine:
1. validate flow (pinned version), 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 1 s): window? not paused? active < maxConcurrent?
SELECT … campaign_recipients WHERE status='PENDING' AND next_attempt_at<=now()
ORDER BY id LIMIT :cps FOR UPDATE SKIP LOCKED → AT call API → DIALING
4. Answer → AT callback → run campaign flow (FLOWS §5.2) with the recipient's variables
5. Final callback → ANSWERED / NO_ANSWER / BUSY / FAILED; retries per policy
Events: campaign.progress (30 s) · campaign.recipient_results (batches) · campaign.paused · campaign.completed
Textify: SMS fallback to NO_ANSWER; pause on low wallet
3.4 After the call (jobs)
final callback recordingUrl → DOWNLOAD → MinIO voice-recordings → recording.ready
→ (Ph 2) TRANSCRIBE → SUMMARIZE → summary.ready
voicemail → voicemail.received (+ recording)
daily RETENTION → delete expired recordings
Recording comes from AT (Dial record="true", Record). No LiveKit egress needed. [VERIFY] AT records Dial-to-SIP; fallback egress.
3.5 Phase 3 AI
ai node → Dial into LiveKit → engine dials OpenAI Realtime into the room over SIP → accepts with instructions built from the knowledge base + node instructions + tools → controls via WebSocket from Java → handoff rings a department (agent joins room, AI leg hung up) → 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: FLOW_ONLY | VOICEMAIL | AGENT | AI | AI_THEN_AGENT
4. Browser agents
- No browser extension or app install needed. The agent UI is a normal page in Textify's web app using the LiveKit JS 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
lobby-{accountId}-{agentRef}(mic off) while available:- presence = lobby join/leave webhooks + explicit status;
- ringing =
sendData(lobby, {type:"ring", callId, from, numberLabel, department}).
- Accept → browser → Textify backend →
POST /v1/calls/{id}/accept {agentRef}→ claim → call-room token → join (shows "Connecting caller…" until the caller arrives). Losers:409+ring_cancel. - Screen pop: the browser uses
fromto ask Textify for contact + open tickets. The engine never sees them. - 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 with hold music), hang 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, 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 during rotation
audit_log (id bigserial 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, -- pool owner (null = global pool)
account_id fk null,
label, hours jsonb, current_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, 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))
departments (id uuid pk, account_id fk, name, strategy, wrap_up_sec default 0)
department_members (department_id fk, agent_id fk, position, pk(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, 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,
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,
window jsonb, cps int, max_concurrent int, max_retries int, retry_delay_sec int,
starts_at, premium_chars_estimate int, created_at, completed_at, pause_reason null)
campaign_recipients (id bigserial pk, campaign_id fk, e164, vars jsonb, media_id fk null,
status, attempts, next_attempt_at, last_call_id fk null, answered_seconds,
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)
-- 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, SET NX), wait:{departmentId} (sorted set: callId by wait start), rr:{departmentId}, campaign:{id}:active, flow/number 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
10.1 Client API (Textify → engine) · Authorization: Bearer <api key> · base https://voice-api.textify.africa/v1 [VERIFY domain]
| 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 (validate + new version) · 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 |
| Departments | POST/PUT/DELETE /accounts/{id}/departments[/{did}] |
| Calls | POST /calls/outbound · POST /calls/{id}/accept · `POST /calls/{id}/hold |
| Campaigns | POST /campaigns · POST /campaigns/{id}/recipients (chunks ≤ 10k) · POST /campaigns/{id}/estimate · `POST /campaigns/{id}/start |
| Opt-outs | GET/POST/DELETE /accounts/{id}/opt-outs |
| Endpoints | 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 /ops/client-apps/{id}/keys · DELETE /ops/client-apps/{id}/keys/{keyId} · POST /ops/client-apps/{id}/test-webhook · `POST /ops/client-apps/{id}/suspend |
10.2 Engine → Textify
- Balance:
GET {can_call_url}?accountRef&direction&estimatedCalls?→{allowed, maxMinutes?}(1.5 s, fail-open). - Webhooks (outbox, signed, retried 1m/5m/30m/2h… ≤ 24 h, idempotent by event id):
| Event | Data |
|---|---|
call.started · call.answered |
callId, accountRef, direction, from, to, number, handledBy, agentRef, department |
call.ended |
status, usage {pstnSeconds, trunkSeconds, waitSeconds, aiSeconds, atAmount}, recorded, endReason, flowVersion |
call.missed |
callId, from, to, numberLabel, department |
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 |
callId (+ summary JSON) |
media.ready · media.failed |
mediaId, duration / error |
tts.usage |
accountRef, chars, tier, ref (flow version / campaign) |
campaign.progress · campaign.recipient_results · campaign.paused · campaign.completed |
counts / batches / reason / totals |
kb.unanswered (Ph 3) |
accountRef, questions[] |
test.ping |
sent on request to verify the client's webhook receiver |
10.3 Hooks 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 /media/{id}.mp3?t=… · GET /media/tts/{hash}.mp3?t=… |
AT / LiveKit Ingress |
11. Infrastructure
11.1 Dedicated Voice box (Docker Compose; host networking for media)
| Service | Notes |
|---|---|
caddy / nginx |
TLS for voice-api.… (API, hooks, media URLs) and wss://voice-rtc.… (LiveKit) |
livekit |
livekit/livekit-server, 7880 on localhost |
sip |
livekit/sip, 5060 + RTP 10000–20000, AT only |
ingress |
livekit/ingress, agent-hold audio [VERIFY URL input] |
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 |
(fallback) egress |
only if AT recording isn't sufficient |
Start 4 vCPU / 8 GB, measure. Region with lowest RTT to AT SIP servers.
11.2 Ports
| Port | Open to | Purpose |
|---|---|---|
| 22 | owner IPs only (key-only login) | SSH, voice-admin CLI |
| 80/443 | world | API, hooks, media, WSS |
| 7881/tcp, 50000–60000/udp | world | WebRTC (browser agents) |
| 5060 udp/tcp, 10000–20000/udp | AT IPs only | SIP + RTP |
| 7880, 6379, 5432, 9000/9001, tts-basic, 8080 | localhost | internal |
| (Ph 3) out 5061 + RTP to OpenAI CIDRs | OpenAI | AI over SIP |
11.3 Env
AT_USERNAME / AT_API_KEY / AT_CALLBACK_SECRET / AT_IPS
LIVEKIT_URL / LIVEKIT_PUBLIC_WS_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET / LIVEKIT_SIP_HOST
DB_URL / DB_USER / DB_PASSWORD · REDIS_URL / REDIS_PASSWORD
MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY
MEDIA_URL_SECRET
TTS_BASIC_URL · TTS_PREMIUM_PROVIDER (openai|azure) / keys
OPENAI_API_KEY (Ph 2) · OPENAI_WEBHOOK_SECRET / OPENAI_SIP_URI (Ph 3)
11.4 LiveKit bootstrap (once, idempotent)
- Inbound trunk
at:allowed_addresses= AT IPs, no number filter. - Dispatch rule
dispatch_rule_callee,room_prefix: "call-"→sip:{callId}@…⇒ roomcall-{callId}[VERIFY]. - (Ph 3) Outbound trunk
openai(TLS + SRTP).
12. Observability & testing
- JSON logs with
callId,accountId,atSessionId,campaignId,node. - Metrics: active calls, waiting callers + wait time, ring-to-accept, answer/missed rate, callback latency (p95 < 500 ms), campaign dials/sec + answer rate, TTS jobs + premium chars, endpoint delivery failures, outbox backlog.
- Alerts: SIP down, callback errors, zero inbound in business hours, outbox backlog, campaign stalled, TTS failures, system-file fallback used.
- PoC = SPEC §10 Phase 0.
- Unit: flow validator, every NodeExecutor (node → XML), wait loop, claim race, ring strategies, campaign dispatcher (window/cps/retries/opt-out), Swahili normalizer, signers, SSRF guard.
- Integration: Testcontainers (Postgres, Redis, MinIO), WireMock (AT, Textify, endpoints, TTS, OpenAI), LiveKit dev + browser test page, real AT number.
13. Tech stack & tools
| Layer | Tool | Used for |
|---|---|---|
| Engine | Java 21, Spring Boot 3.x (Web, Validation, JDBC/JPA, Scheduling, Actuator) | All engine logic |
| DB | PostgreSQL 16 + Flyway (pgvector later) | State, flows, outbox, jobs, campaign queue, KB |
| Cache/locks | Redis 7 | Presence, claims, waiting orders, concurrency |
| Telephony | Africa's Talking Voice (callbacks + XML: Play, GetDigits, Record, Dial; call API); optional africastalking-java for REST |
PSTN both directions |
| Media | LiveKit server, SIP, Ingress; io.livekit:livekit-server JVM SDK; LiveKit JS SDK in Textify UI |
Rooms, browser agents |
| 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 (transcribe, summarize); Ph 3 Realtime over SIP + java.net.http.WebSocket |
Summaries, live AI |
| Storage | MinIO via AWS SDK v2 | Media library, generated voice, recordings |
| Audio processing | ffmpeg | Transcode, normalize, trim |
| HTTP | Spring RestClient |
AT, Textify, endpoints, TTS, OpenAI |
| Security | Hashed API keys, HMAC-SHA256, LiveKit WebhookReceiver, SSRF guard, ufw, CrowdSec |
Service auth |
| Edge | Caddy (or nginx) | TLS, reverse proxy |
| Deploy | Docker Compose, dedicated VPS | Everything |
| Observability | Logback JSON, Actuator + Micrometer (Prometheus) | Logs, metrics |
| Testing | JUnit 5, Testcontainers, WireMock, browser test page | Unit + integration |
| Not used | AT Say, MQTT, Kafka, RabbitMQ, OAuth/Keycloak, Python, browser extension |
Not needed |
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/ system prompts (sw/en): notices, hold default, sorry, invalid option, numbers
└── agent-test/ minimal HTML page with LiveKit JS for the PoC
15. Open items to verify
- AT: Dial to
sip:{callId}@host; IPs; codecs/DTMF; billing; Dial-to-SIP recording; CPS/concurrency; per-second vs per-minute; multi-number call API; callback signature (SPEC §11). - AT: GetDigits wrapping Play (key interrupt, timeouts, callbacks per call) for the wait loop; Play fetch timeout/streaming; XML continuation after Dial.
- LiveKit: callee dispatch with arbitrary user part; trunk without number filter; Ingress URL input; JVM SDK method names.
- TTS: Basic candidate quality + commercial licence; Premium choice; Swahili normalizer coverage.
- Ph 3: LiveKit → OpenAI SIP interop; Realtime vs Live API; Swahili AI quality + cost.
Appendix A: Phase 3 AI (Java-only)
- No Java SDK joins LiveKit rooms as an audio participant; Spring AI / LangChain4j are text-only. So OpenAI joins the room as a SIP participant.
- 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; Swahili first; short 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 …/hangupAI 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 isn't good enough → fallback: small Python LiveKit Agents worker with another TTS, calling engine internal endpoints; nothing else changes.