Skip to main content

Textify Voice: Architecture

Companion to SPEC.md. This file is how: components, adapters, call flows (inbound, outbound, broadcast), data model, APIs, auth, communication, infra. Stack: Textify (Go) → Voice Engine (Java 21, Spring Boot) → Africa's Talking + LiveKit (self-hosted) + OpenAI. 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 ·   │
                          │ outbound call API · recordings · SIP  │
                          └───────┬───────────────────▲──────────┘
             voice callbacks (POST)│                   │ XML replies, call API (REST)
             + Dial to SIP         │                   │
┌──────────────────────── Voice box (dedicated) ───────┼─────────────────────────────┐
│                                  ▼                   │                             │
│   ┌──────────────┐  SIP  ┌──────────────────────────────────────────┐              │
│   │ 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)│    └───────▲──────────────────┬───────────────┘              │
│   └──────▲──────────┘            │ REST (API key)   │ webhooks (HMAC)              │
└──────────┼───────────────────────┼──────────────────┼──────────────────────────────┘
           │ WebRTC                │                  ▼
   ┌───────┴────────┐       ┌──────┴──────────────────────────┐        ┌─────────┐
   │ Agent browser  │◄─app─►│ TEXTIFY (Go)                     │        │ OpenAI  │◄─ AiAdapter
   │ (Textify UI +  │       │ users · tenants · wallet ·       │        │ (Ph 2–3)│
   │ LiveKit JS SDK)│       │ pricing · UI · SMS/WhatsApp      │        └─────────┘
   └────────────────┘       └─────────────────────────────────┘

Three rules hold the design together:

  1. AT is the only road to the phone network, both directions. Inbound arrives as AT callbacks; outbound starts with AT's call API.
  2. Talking to a person (or AI) happens in LiveKit. The engine's XML says Dial sip:{callId}@<livekit-sip-host> and the call enters room call-{callId}. LiveKit only ever receives calls from AT.
  3. The engine is blind. It knows accountId, agentRef, numbers and calls, never users, roles or money.

1.1 Responsibilities

Component Owns Never owns
Textify (Go) Users, login, roles, tenants, partners, wallet, prices, all UI screens (flow editor, campaigns, dashboard), contact lists, SMS/WhatsApp follow-ups Telephony / LiveKit / OpenAI logic
Voice Engine Client apps, accounts (opaque), numbers, flows, agents (opaque refs), ring groups, presence, calls, campaigns, recordings, transcripts, usage events Users, auth of people, prices, money
AT PSTN, numbers, XML execution, outbound dialing, recording Anything else
LiveKit Rooms, WebRTC to browsers, SIP from AT, hold audio, (Ph 3) AI participant Business logic
OpenAI Ph 2: transcribe + summarize. Ph 3: live voice State, routing

2. Inside the engine

Stack: Java 21, Spring Boot 3.x, PostgreSQL + Flyway, Redis, @Scheduled jobs, virtual threads.

2.1 Adapters (plain interfaces, one implementation each, no plugin framework)

interface TelephonyAdapter {          // impl: AfricasTalkingAdapter
  String render(List<Action> actions);              // → AT XML
  OutboundResult placeCall(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);
  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.

2.2 Packages

com.qbitspark.voice
├── api          // client REST /v1/** (API-key auth)
├── hooks        // /hooks/at/{secret}/**, /hooks/livekit, /hooks/openai
├── telephony    // AfricasTalkingAdapter, XML builder (Say, Play, GetDigits, Dial, Record, Reject)
├── media        // LiveKitAdapter, tokens, lobby/ring, ingress hold audio
├── ai           // OpenAiAdapter
├── flows        // flow JSON model, validator, executor (node → XML)
├── calls        // call lifecycle, state machine, timeline
├── routing      // ring groups, presence, waiting list, claim (first accept wins)
├── campaigns    // broadcast campaigns, dispatcher, retries, opt-outs
├── recording    // download → S3, retention
├── jobs         // Postgres job table + @Scheduled workers (transcribe, summarize, cleanup)
├── events       // outbox + signed webhook dispatcher
├── 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's voice callback URL in AT points to https://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 the calls row. 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 flows

3.1 Inbound → menu → browser agent

Caller   AT                     Engine                          LiveKit             Agent browser
  │dial─►│── POST /voice ──────►│ number→account→flow           │                      │
  │      │                      │ can-call? (Textify, 1.5s, fail-open)                 │
  │      │◄── XML: Say notice + Play greeting + GetDigits ─────│                      │
  │ "2" ►│── POST /step/{id}/menu (dtmfDigits=2) ─────────────►│ node=support (ring)  │
  │      │◄── XML: Dial sip:{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) ──────────────────│
  │      │                      │ claim (Redis SET NX) → room token ───────────────────►│
  │      │                      │ stop hold audio ─────────────►│◄═════ joins, talks ══│
  │◄═════╪══════════════════════╪═════════ audio ═══════════════╪══════════════════════│
  │ hangup                      │                               │                      │
  │      │── POST /voice (isActive=0: duration, amount, recordingUrl) ►│ close call → events
  • Waiting: if no agent is available, the caller stays on hold in the room; when an agent becomes AVAILABLE (or finishes a call), the engine rings again. Order = oldest waiting call first (Redis sorted set per group).
  • Timeout → noAnswer node. Since the call is now in LiveKit, voicemail there = the engine plays the prompt via ingress and records via egress, or simpler: remove the SIP participant so AT's Dial ends, then AT continues with the next XML (Record) on the Dial's callback. [VERIFY] AT continues the flow after Dial ends (prefer this: keeps voicemail on AT).
  • Missed: caller hangs up or nobody handled it → call.missed.

3.2 Outbound click-to-call

Agent clicks number → Textify → POST /v1/calls/outbound {accountId, fromNumberId, to, agentRef}
Engine:
  1. can-call(OUTBOUND)
  2. create call + room call-{callId}; return agent token → browser joins (hears ringback tone from page)
  3. TelephonyAdapter.placeCall(from, to) → AT dials the customer; store AT sessionId → callId
Customer answers → AT callback (direction=Outbound, sessionId) → engine finds callId
  → XML: Dial sip:{callId}@sip.voice…  → customer joins the agent's room → talk
End → final callback → call.ended (pstnSeconds outbound, trunkSeconds)

3.3 Broadcast campaign

Textify → POST /v1/campaigns {accountId, fromNumberId, audioUrl|ttsText, keys:{1:"grp_sales",9:"optout"},
                              window, cps, maxConcurrent, retries} + recipients (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://rec/{accountId}/{callId}.mp3 → recording.ready
  → job TRANSCRIBE (OpenAI, sw/en) → job SUMMARIZE (OpenAI, JSON) → summary.ready
daily job RETENTION → delete 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, intent, resolved, followUps[], sentiment, topic, language}.

3.5 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 → IN_FLOW → WAITING_AGENT → ANSWERED → COMPLETED
OUTBOUND: DIALING → ANSWERED → COMPLETED
any → MISSED | NO_ANSWER | BUSY | FAILED | REJECTED_NO_BALANCE
handled_by: NONE | FLOW_ONLY | AGENT | AI | AI_THEN_AGENT

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/left webhooks (automatic online/offline) + explicit status from Textify.
    • Ringing = engine sendData(lobby, {type:"ring", callId, from, numberLabel, groupName}).
  • 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, name, api_key_hash, api_key_hash_next null,   -- 2 keys for rotation
             webhook_url, webhook_secret, can_call_url, created_at)

accounts (id uuid pk, client_app_id fk, external_ref, status, default_language default 'sw',
          retention_days default 90, created_at, unique(client_app_id, external_ref))

phone_numbers (id uuid pk, e164 unique, provider default 'AT', type,          -- REGULAR | TOLL_FREE
               status, account_id fk null, label, flow jsonb, flow_version int, updated_at)

agents (id uuid pk, account_id fk, external_ref, display_name, status, last_seen_at,
        unique(account_id, external_ref))
ring_groups (id uuid pk, account_id fk, name, strategy)                     -- ALL_AT_ONCE | ROUND_ROBIN
ring_group_members (ring_group_id fk, agent_id fk, position, pk(ring_group_id, agent_id))

calls (id uuid pk, account_id fk, number_id fk, campaign_id fk null,
       direction, from_e164, to_e164, at_session_id, livekit_room,
       status, handled_by, agent_id null, current_node,
       started_at, answered_at, ended_at, end_reason,
       pstn_seconds, trunk_seconds, ai_seconds,
       at_amount numeric, at_currency, dtmf_path text, ai_usage jsonb null)
call_events (id bigserial pk, call_id fk, type, at, data jsonb)

campaigns (id uuid pk, account_id fk, from_number_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, 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 fk unique, 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|…
outbox_events (id uuid pk, client_app_id fk, type, payload jsonb, attempts, next_attempt_at,
               delivered_at null, last_error)

Redis: presence:{accountId}:{agentRef}, call:{id}:agent (claim), wait:{groupId} (sorted set of waiting callIds), rr:{groupId}, campaign:{id}:active (concurrency counter).


6. APIs

6.1 Client API (Textify → engine)

Authorization: Bearer <client_api_key>, scoped to that client's accounts. 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}/flow (validates) · GET /numbers/{id}
Agents PUT /accounts/{id}/agents/{ref} · DELETE … · PUT …/status · POST …/lobby-token
Ring 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 (signed URL) · GET /calls/{id}/transcript
Campaigns POST /campaigns · POST /campaigns/{id}/recipients (chunks) · `POST /campaigns/{id}/start
Opt-outs GET/POST/DELETE /accounts/{id}/opt-outs
Ops only POST /client-apps · POST /numbers (register AT number)

6.2 Engine → Textify

  • Balance check: GET {can_call_url}?accountRef&direction&estimatedCalls? → {allowed, maxMinutes?}; 1.5 s timeout → allow.
  • Webhooks (outbox, signed, retried 1m/5m/30m/2h… up to 24h, idempotent by event id):
Event Key data
call.started / call.answered callId, accountRef, direction, from, to, handledBy, agentRef
call.ended status, usage {pstnSeconds, trunkSeconds, aiSeconds, atAmount}, recorded, endReason
call.missed callId, from, to, numberLabel → Textify SMS/WhatsApp
voicemail.received callId, from, recording
recording.ready / summary.ready callId, (summary JSON)
campaign.progress counts by status
campaign.recipient_results batch of {e164, status, seconds, keypress, atAmount}
campaign.completed totals

6.3 Webhooks into the engine

Endpoint From Verification
POST /hooks/at/{secret}/voice and /step/{callId}/{node} AT secret in path + AT IP allowlist + destinationNumber/callerNumber must match a known number/call [VERIFY AT IPs; any signature]
POST /hooks/livekit LiveKit signed JWT (WebhookReceiver)
POST /hooks/openai (Ph 3) OpenAI webhook signature + dedupe

7. Communication choices

Need Mechanism
Textify ⇄ engine commands/queries REST
Engine → Textify facts Webhooks via Postgres outbox + @Scheduled dispatcher
AT call control AT callbacks + XML; AT REST call API
Ring agents in browser LiveKit data messages (lobby room)
Presence, claims, waiting lists, campaign concurrency Redis
Background work (download, transcribe, summarize, retention) Postgres jobs table + @Scheduled workers (FOR UPDATE SKIP LOCKED)
Broadcast queue campaign_recipients table + dispatcher

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 trunk allowed_addresses = AT IPs only. Inbound trunk accepts any user part (our callId) only from AT IPs [VERIFY trunk config without a numbers filter]. 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

9.1 Dedicated Voice box (Docker Compose; host networking for media)

Service Notes
caddy / nginx TLS for voice-api.… (engine API + hooks) and wss://voice-rtc.… (LiveKit)
livekit livekit/livekit-server, 7880 on localhost behind proxy
sip livekit/sip, 5060 + RTP 10000–20000, receives AT only
ingress livekit/ingress, hold audio into rooms [VERIFY URL input for short audio]
redis shared by LiveKit + engine (separate DB index)
postgres engine DB
voice-engine Spring Boot jar
(Ph 3 optional) egress only if AT recording isn't enough

Storage: S3-compatible private bucket (Linode Object Storage or MinIO). Start 4 vCPU / 8 GB; measure. Choose the region with the lowest RTT to AT's SIP servers.

9.2 Ports

Port Open to Purpose
22 admin IPs SSH
80/443 world API, hooks, WSS
7881/tcp, 50000–60000/udp world WebRTC for browser agents
5060 udp/tcp, 10000–20000/udp AT IPs only SIP + RTP from AT
7880, 6379, 5432, 8080 localhost internal
(Ph 3) out 5061/tcp + RTP to OpenAI CIDRs OpenAI AI over SIP

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)

  1. Inbound trunk at: allowed_addresses = AT IPs (no number filter, since the user part is our callId).
  2. Dispatch rule dispatch_rule_callee with room_prefix: "call-", so sip:{callId}@… → room call-{callId}. [VERIFY] callee rule uses the SIP user part.
  3. (Ph 3) Outbound trunk openai (TLS + SRTP).

10. Observability & testing

  • Logs: JSON, with callId, accountId, atSessionId, campaignId on 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

  1. 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).
  2. AT: does XML continue after a Dial leg ends (for voicemail after timeout)?
  3. LiveKit: callee dispatch rule with arbitrary user part; inbound trunk without number filter; ingress URL input for hold audio.
  4. LiveKit JVM SDK (io.livekit:livekit-server) method names for tokens, data messages, ingress, participants.
  5. 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 webhook realtime.call.incoming (or Live API live.transport.incoming) → match header to a call in AI_DIALING → POST /v1/realtime/calls/{id}/accept with instructions (Swahili first, short answers, never invent prices), voice, tools (transfer_to_human, take_message, end_call) → WebSocket wss://api.openai.com/v1/realtime?call_id=… for tool calls/transcripts/usage → POST …/hangup after handover.
  • Firewall: out TCP 5061 to sip.api.openai.com; RTP with OpenAI media CIDRs 13.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 voice isn't good enough → fallback: small Python LiveKit Agents worker with another TTS, calling engine internal endpoints; no other change.