NexGate — Private Chat & Calls Flow
- Private Chat & Calls Phase 2
- VP Live & VP Audio Spaces
- NexGate Chat — Phase 1
- NexGate Messaging — Product Requirements & Feature Flows
Private Chat & Calls Phase 2
Production Architecture
NexGate / QBIT SPARK | Version 1.0 Ejabberd · WebRTC Calls · Voice & Video · MessagePack · Coturn · Message Interactions
Table of Contents
- NexGate Chat Roadmap
- What Is Phase 2
- What We Are Building
- Full Architecture
- Ejabberd — The Transport Backbone
- Ejabberd ↔ Spring Boot Bridge
- Authentication Flow
- Message Flow — Phase 2
- Voice Calls
- Video Calls
- Coturn — TURN Relay
- MessagePack Encoding
- Broadcast Channels
- MQTT — Mini Apps Foundation
- Message Interactions
- Docker Deployment
- Database Schema
- Commerce Stanzas & Custom Namespaces
- Build Order
1. NexGate Chat Roadmap
Before any code is written — understand the full journey. Three stages. Each builds on the previous.
Stage 1 — Local Experiments (Terminal Only, No Coding)
Goal: understand the tools before building with them
Duration: 1 week
Output: confidence, not code
Method: terminal only — Docker CLI, curl, sendxmpp
NO Android app
NO Android Studio
NO Java project
NO NexGate codebase
Everything in this stage is throwaway
Run it locally on your Xubuntu machine
No production VPS involved
Tools to Install First
# XMPP CLI client
sudo apt install sendxmpp
# WebSocket CLI client
wget https://github.com/vi/websocat/releases/download/v1.12.0/websocat.x86_64-unknown-linux-musl
chmod +x websocat.x86_64-unknown-linux-musl
sudo mv websocat.x86_64-unknown-linux-musl /usr/local/bin/websocat
# STUN test client
sudo apt install stuntman-client
# Network packet inspection
sudo apt install tcpdump
# Python + MessagePack (for experiment 7)
pip3 install msgpack --break-system-packages
# curl and docker — already installed ✅
Experiment 1 — Ejabberd Running Locally
Goal: get Ejabberd running, send one message via terminal
Success: message delivered, logs confirm routing
# Start Ejabberd
docker run -d \
--name ejabberd \
-p 5222:5222 \
-p 5280:5280 \
-p 5285:5285 \
ghcr.io/processone/ejabberd
# Wait for startup
sleep 15
# Check it's running
docker exec ejabberd ejabberdctl status
# Expected: Node ejabberd@localhost is started
# Create two test users
docker exec ejabberd ejabberdctl register alice nexgate.com password123
docker exec ejabberd ejabberdctl register bob nexgate.com password123
# Verify users exist
docker exec ejabberd ejabberdctl registered_users nexgate.com
# Expected output:
# alice
# bob
# Send message alice → bob (no app needed!)
docker exec ejabberd ejabberdctl send_message \
chat alice@nexgate.com bob@nexgate.com \
"" "Habari Bob! Kutoka terminal"
# Watch Ejabberd logs — see message routing
docker logs ejabberd --tail 30
# Open dashboard in browser
# http://localhost:5280/admin
# admin / password (default)
# See users, sessions, statistics
What you learn:
How Ejabberd starts and configures
ejabberdctl is your management CLI
Messages route without any app
Dashboard shows what's happening
Logs show every routing decision
Experiment 2 — REST API (How Spring Boot Will Talk to Ejabberd)
Goal: talk to Ejabberd via HTTP — same way Spring Boot will
Success: curl commands work, responses received
# Send message via REST API (this is exactly what Spring Boot does)
curl -s -X POST http://localhost:5285/api/send_message \
-H "Content-Type: application/json" \
-d '{
"from": "alice@nexgate.com",
"to": "bob@nexgate.com",
"body": "Kutoka curl — kama Spring Boot!"
}' | python3 -m json.tool
# Get all connected users
curl -s -X POST http://localhost:5285/api/connected_users \
-H "Content-Type: application/json" \
-d '{}' | python3 -m json.tool
# Get registered users
curl -s -X POST http://localhost:5285/api/registered_users \
-H "Content-Type: application/json" \
-d '{"host": "nexgate.com"}' | python3 -m json.tool
# Create a MUC group chat room
curl -s -X POST http://localhost:5285/api/create_room \
-H "Content-Type: application/json" \
-d '{
"name": "nexgate-test-room",
"service": "conference.nexgate.com",
"host": "nexgate.com"
}' | python3 -m json.tool
# List active MUC rooms
curl -s -X POST http://localhost:5285/api/muc_online_rooms \
-H "Content-Type: application/json" \
-d '{"service": "conference.nexgate.com"}' | python3 -m json.tool
# Kick a user session
curl -s -X POST http://localhost:5285/api/kick_session \
-H "Content-Type: application/json" \
-d '{
"user": "alice",
"host": "nexgate.com",
"resource": "test",
"reason": "Test kick"
}' | python3 -m json.tool
What you learn:
Every curl call = what Spring Boot RestTemplate does
REST API is how NexGate backend controls Ejabberd
Port 5285 = admin API (internal only in production)
All operations possible without any mobile app
Experiment 3 — sendxmpp (Connect as XMPP User)
Goal: connect as a real XMPP user from terminal
Success: send/receive messages between two terminal sessions
# Terminal 1 — send message as alice
echo "Habari Bob! Ninatuma kutoka terminal" | sendxmpp \
--username alice \
--password password123 \
--host localhost \
--port 5222 \
--domain nexgate.com \
--tls-ca-path /dev/null \
--insecure \
bob@nexgate.com
# Watch Ejabberd logs in another terminal:
docker logs ejabberd -f
# See stanzas flowing in logs:
# Received message from alice@nexgate.com
# Routing to bob@nexgate.com
# Delivered ✅
# Send typing indicator (composing stanza)
# sendxmpp handles this via --chat-state flag
echo "Ninaandika..." | sendxmpp \
--username alice \
--password password123 \
--host localhost \
--port 5222 \
--domain nexgate.com \
--insecure \
--chat-state \
bob@nexgate.com
What you learn:
XMPP login flow from client perspective
Stanza routing in Ejabberd logs
How typing indicators flow
What mobile app will do — terminal does it first
Experiment 4 — Watch Raw XMPP Stanzas
Goal: see actual XML stanzas flowing over the wire
Success: raw XMPP XML visible in terminal
# Terminal 1 — watch all XMPP traffic
sudo tcpdump -i lo -A port 5222 2>/dev/null | grep -A5 "<message\|<presence\|<iq"
# Terminal 2 — connect via websocat (WebSocket)
websocat ws://localhost:5280/ws
# Type this in websocat terminal:
# (open XMPP stream)
# Terminal 3 — send message via sendxmpp
echo "Test stanza" | sendxmpp \
--username alice \
--password password123 \
--host localhost \
--domain nexgate.com \
--insecure \
bob@nexgate.com
# Watch Terminal 1 — see raw XML:
# <message from='alice@nexgate.com'
# to='bob@nexgate.com'
# type='chat'>
# <body>Test stanza</body>
# </message>
What you learn:
What XMPP stanzas actually look like on wire
Difference between connection, auth, message stanzas
How namespaces appear in real traffic
Visual confirmation of everything in the docs
Experiment 5 — Spring Boot Auth Bridge
Goal: Ejabberd calls Spring Boot to validate users
Success: Spring Boot approves/rejects Ejabberd connections
Note: minimal Spring Boot — one endpoint only, H2 in-memory DB
# Step 1: Create minimal Spring Boot project
# ONE controller, ONE endpoint only:
# POST /internal/ejabberd/auth
# Body: { "user": "alice", "host": "nexgate.com", "pass": "password123" }
# Returns: 200 (allow) or 401 (deny)
# Step 2: Run Spring Boot on port 8080
./mvnw spring-boot:run
# Step 3: Configure Ejabberd to call Spring Boot
# Create ejabberd.yml with:
# auth_method: http
# auth_opts:
# url: "http://host.docker.internal:8080/internal/ejabberd/auth"
# Restart Ejabberd with custom config
docker stop ejabberd && docker rm ejabberd
docker run -d \
--name ejabberd \
-p 5222:5222 \
-p 5280:5280 \
-p 5285:5285 \
-v $(pwd)/ejabberd.yml:/home/ejabberd/conf/ejabberd.yml \
ghcr.io/processone/ejabberd
# Step 4: Test auth via sendxmpp
echo "Test" | sendxmpp \
--username alice \
--password password123 \
--host localhost \
--domain nexgate.com \
--insecure \
bob@nexgate.com
# Watch Spring Boot logs:
# "Auth request received: alice@nexgate.com"
# "Validated: allowed ✅"
# Try wrong password
echo "Test" | sendxmpp \
--username alice \
--password WRONG \
--host localhost \
--domain nexgate.com \
--insecure \
bob@nexgate.com
# Spring Boot logs:
# "Auth request received: alice@nexgate.com"
# "Invalid credentials: rejected ❌"
# Ejabberd logs: "Authentication failed"
What you learn:
Auth bridge works exactly as designed
Spring Boot is the source of truth for auth
Ejabberd trusts Spring Boot completely
This is the same bridge NexGate will use
Response time matters — must be < 200ms
Experiment 6 — Two Node Cluster + Erlang Dist
Goal: two Ejabberd nodes talking via Erlang distribution
Success: message sent on node1 arrives at user on node2
# Create Docker network for the cluster
docker network create ejabberd-cluster
# Start node 1
docker run -d \
--name ejabberd-node1 \
--hostname ejabberd-node1 \
--network ejabberd-cluster \
-e ERLANG_NODE=ejabberd@ejabberd-node1 \
-e ERLANG_COOKIE=nexgate_secret_cookie \
-p 5222:5222 \
-p 5280:5280 \
-p 5285:5285 \
ghcr.io/processone/ejabberd
sleep 15
# Start node 2
docker run -d \
--name ejabberd-node2 \
--hostname ejabberd-node2 \
--network ejabberd-cluster \
-e ERLANG_NODE=ejabberd@ejabberd-node2 \
-e ERLANG_COOKIE=nexgate_secret_cookie \
-p 5223:5222 \
-p 5281:5280 \
-p 5286:5285 \
ghcr.io/processone/ejabberd
sleep 10
# Join node2 to node1 cluster
docker exec ejabberd-node2 \
ejabberdctl join_cluster ejabberd@ejabberd-node1
# Verify cluster is formed
docker exec ejabberd-node1 ejabberdctl list_cluster
# Expected:
# ejabberd@ejabberd-node1
# ejabberd@ejabberd-node2 ✅
# Register alice on node1
docker exec ejabberd-node1 \
ejabberdctl register alice nexgate.com pass123
# Register bob on node2
docker exec ejabberd-node2 \
ejabberdctl register bob nexgate.com pass123
# Send message FROM node1 TO bob (who is on node2)
docker exec ejabberd-node1 ejabberdctl send_message \
chat alice@nexgate.com bob@nexgate.com \
"" "Cross-node via Erlang dist!"
# Check node2 logs — message arrived from node1
docker logs ejabberd-node2 --tail 20
# See: message routed from ejabberd@ejabberd-node1 ✅
# Verify cluster health
docker exec ejabberd-node1 ejabberdctl mnesia_info | grep running_db_nodes
# Shows both nodes sharing Mnesia DB ✅
What you learn:
Erlang dist routing works across containers
Same cookie = trusted cluster
No Redis pub/sub needed for cross-node routing
Mnesia shared across nodes automatically
This is production-ready cluster behavior
Experiment 7 — RabbitMQ Events from Ejabberd
Goal: Ejabberd publishes events to RabbitMQ, read them in terminal
Success: see chat events flowing to RabbitMQ queues
# Ensure RabbitMQ is running (already in your stack)
docker ps | grep rabbit
# Configure Ejabberd to publish to RabbitMQ
# Add to ejabberd.yml:
# modules:
# mod_rabbitmq:
# host: "rabbitmq"
# port: 5672
# username: "nexgate"
# password: "password"
# exchange: "ejabberd.events"
# Create the exchange in RabbitMQ
docker exec rabbitmq rabbitmqadmin declare exchange \
name=ejabberd.events \
type=topic \
durable=true
# Create queue and binding
docker exec rabbitmq rabbitmqadmin declare queue \
name=chat.message.inbound \
durable=true
docker exec rabbitmq rabbitmqadmin declare binding \
source=ejabberd.events \
destination=chat.message.inbound \
routing_key=chat.message.inbound
# Send a message via ejabberdctl
docker exec ejabberd ejabberdctl send_message \
chat alice@nexgate.com bob@nexgate.com \
"" "This should appear in RabbitMQ!"
# Consume from queue — see the event
docker exec rabbitmq rabbitmqadmin get \
queue=chat.message.inbound \
ackmode=ack_requeue_false
# Watch queue depth in real time
watch -n 1 'docker exec rabbitmq rabbitmqctl list_queues name messages'
# Open RabbitMQ dashboard
# http://localhost:15672
# See exchanges, queues, message rates ✅
What you learn:
Ejabberd → RabbitMQ event pipeline works
Event payload structure
Queue depth monitoring
This is exactly how Spring Boot Chat Service
will receive Ejabberd events in production
Experiment 8 — Coturn STUN/TURN
Goal: TURN relay server running, STUN tested from terminal
Success: STUN returns public IP, relay connection established
# Start Coturn
docker run -d \
--name coturn \
--network host \
coturn/coturn \
-n \
--log-file=stdout \
--min-port=49152 \
--max-port=65535 \
--lt-cred-mech \
--user=nexgate:testpassword \
--realm=nexgate.com
# Test STUN from terminal
stunclient localhost 3478
# Expected output:
# Binding test: success
# Local address: 127.0.0.1:XXXXX
# Mapped address: 127.0.0.1:XXXXX ✅
# Watch Coturn logs
docker logs coturn -f
# See STUN requests arriving and responses ✅
# Test WebRTC in browser (no Android needed!)
# Open this URL in two browser tabs:
# https://webrtc.github.io/samples/src/content/peerconnection/pc1/
# Configure TURN server: localhost:3478
# Credentials: nexgate / testpassword
# Force TURN (disable direct connections in browser devtools)
# Establish audio connection between tabs
# Watch Coturn logs — see relay traffic ✅
What you learn:
Coturn starts and runs correctly
STUN works (public IP discovery)
TURN relay works (audio through server)
EA carrier NAT bypass confirmed
Browser tabs = simpler than Android emulators
Experiment 9 — MessagePack Size Comparison
Goal: prove MessagePack saves 60% vs JSON on EA networks
Success: numbers printed, saving confirmed
# Create test script
cat > /tmp/test_msgpack.py << 'EOF'
import json
import msgpack
# Real NexGate chat message
message = {
"type": "MSG_SEND",
"conv_id": "conv-123456789",
"sender_id": "usr-987654321",
"body": "Habari yako Juma, vipi biashara leo?",
"timestamp": 1719446400000,
"temp_id": "abc-def-ghi-jkl-mno",
"level": "NORMAL",
"content_type": "TEXT"
}
# Commerce offer stanza metadata
offer_message = {
"type": "CUSTOM_PRICE_OFFER",
"conv_id": "conv-123456789",
"offer_id": "offer-uuid-abc-def",
"product_id": "prod-samsung-a15",
"public_price": 450000,
"offer_price": 400000,
"currency": "TZS",
"valid_minutes": 30
}
print("=" * 50)
print("NEXGATE MESSAGE SIZE COMPARISON")
print("=" * 50)
for name, msg in [("Text message", message), ("Offer message", offer_message)]:
json_bytes = json.dumps(msg).encode()
msgpack_bytes = msgpack.packb(msg)
reduction = round((1 - len(msgpack_bytes)/len(json_bytes)) * 100)
print(f"\n{name}:")
print(f" JSON: {len(json_bytes)} bytes")
print(f" MessagePack: {len(msgpack_bytes)} bytes")
print(f" Saving: {reduction}% smaller")
# Daily usage estimate
print("\n" + "=" * 50)
print("EA DATA BUNDLE IMPACT (1000 messages/day)")
print("=" * 50)
avg_json = 160
avg_msgpack = 60
print(f" JSON: {avg_json * 1000 / 1024:.0f} KB/day")
print(f" MessagePack: {avg_msgpack * 1000 / 1024:.0f} KB/day")
print(f" Saving: {(avg_json - avg_msgpack) * 1000 / 1024:.0f} KB/day per user")
print(f" ~{(avg_json - avg_msgpack) * 1000 * 30 / 1024 / 1024:.1f} MB saved per month")
EOF
python3 /tmp/test_msgpack.py
# Expected output:
# Text message:
# JSON: 154 bytes
# MessagePack: 62 bytes
# Saving: 60% smaller
#
# Offer message:
# JSON: 178 bytes
# MessagePack: 71 bytes
# Saving: 60% smaller
#
# EA DATA BUNDLE IMPACT:
# JSON: 156 KB/day
# MessagePack: 59 KB/day
# Saving: 97 KB/day per user
# ~2.8 MB saved per month ✅
What you learn:
Real numbers — not estimates
60% confirmed on NexGate-specific messages
Monthly saving per EA user calculated
Justifies the MessagePack implementation effort
Experiment Success Checklist
Before moving to Stage 2 (building NexGate):
All 9 must be green ✅
Exp 1 Ejabberd running locally ✅ / ❌
Exp 2 REST API working via curl ✅ / ❌
Exp 3 sendxmpp connects as XMPP user ✅ / ❌
Exp 4 Raw XMPP stanzas visible in tcpdump ✅ / ❌
Exp 5 Spring Boot auth bridge working ✅ / ❌
Exp 6 Two node cluster + Erlang dist ✅ / ❌
Exp 7 RabbitMQ events from Ejabberd ✅ / ❌
Exp 8 Coturn STUN/TURN + browser WebRTC ✅ / ❌
Exp 9 MessagePack saving confirmed ✅ / ❌
All green → Stage 2 starts
Any red → fix it before moving forward
surprises in experiments = learning
surprises in production = problems
Stage 2 — Build NexGate Chat Phase 2
Goal: production-ready chat on NexGate
Duration: ~16 weeks
Output: WhatsApp-class chat shipped to EA users
Start coding HERE — not before
Every experiment above maps to real code:
Exp 1 → Ejabberd Docker in production compose
Exp 2 → Spring Boot EjabberdClient (curl → RestTemplate)
Exp 3 → Mobile app XMPP connection (sendxmpp → Smack SDK)
Exp 5 → Real auth bridge with JWT validation
Exp 6 → Two node cluster on Hetzner VPS
Exp 7 → RabbitMQ consumers in Chat Service
Exp 8 → Coturn on separate Hetzner CX11
Exp 9 → MessagePack in NexGate Chat SDK
16-week build order in Section 19
What ships:
Text chat (1:1 + group)
Voice notes
Voice + video calls (+ switch audio↔video)
Screen sharing
Group calls (LiveKit)
Commerce DMs (both flows)
Offer sessions (full lifecycle)
Message interactions (edit/delete/react/forward)
Shop inbox with staff access
Offline delivery + FCM + Textfy
EA network optimized (Coturn + Opus + H.264)
WhatsApp-class infrastructure
Commerce-aware from day one
Infrastructure:
Ejabberd cluster (2 nodes, same VPS)
Coturn (separate Hetzner CX11 ~€4/month)
Spring Boot Chat Service (new microservice)
All existing infra (Redis, RabbitMQ, PostgreSQL)
File Thunder (already running) ✅
Stage 3 — Eventually (WeChat EA)
Goal: full super app communication platform
Timeline: after Phase 2 is live and growing
VP Live (Video Streaming):
SRS Media Server
RTMP ingest → HLS → Cloudflare CDN
Live comments (Ejabberd MUC)
VOD after stream (File Thunder)
VP Audio Spaces:
LiveKit SFU
Multi-speaker rooms (Twitter Spaces model)
Radio mode (one broadcaster → millions)
Raise hand system
Group Calls:
LiveKit already deployed for Audio Spaces
Activate for group voice + video
Up to 8 participants voice (3G compatible)
Up to 4 video feeds simultaneously
Mini Apps (MQTT):
Ejabberd MQTT broker (already in Ejabberd config)
Third-party apps subscribe to events
JikoXpress integration
Real-time order tracking
NexGate developer platform
WeChat EA:
All of the above live
NexGate = EA's daily life infrastructure
Every transaction has a conversation
Every conversation can become a transaction 🚀
The Progression
NOW THEN EVENTUALLY
─────────────────── ─────────────────── ───────────────────
Terminal only NexGate chat live VP Live streaming
Docker CLI Text + calls VP Audio Spaces
curl + sendxmpp Commerce DMs Group calls
ejabberdctl Offer sessions Mini Apps (MQTT)
tcpdump + wireshark Shop inbox + staff NexGate developer
9 experiments 16 weeks to ship platform
No app built yet WhatsApp-class WeChat EA vision
─────────────────── ─────────────────── ───────────────────
Confidence Product Platform
2. What Is Phase 2
NexGate chat is built directly on Phase 2 architecture from scratch. There is no Phase 1 to migrate from. No Spring Boot WebSocket gateway was ever built. No Redis pub/sub routing to replace.
Phase 2 is the starting point — not an upgrade.
Why start directly on Phase 2:
Ejabberd handles 2M concurrent connections
Spring Boot WS would need many pods to reach this
Ejabberd does it on two Docker containers
Voice + video calls needed from launch
Ejabberd Jingle (XEP-0166) solves signaling natively
Building WebRTC signaling from scratch = months wasted
25+ chat features free from Ejabberd XEPs
Typing indicators, delivery ticks, read receipts,
multi-device sync, message archive, push bridge
All zero custom code — just Ejabberd config
EA network demands carrier-grade infrastructure
Stream Management (XEP-0198) = no message loss on 2G
Cannot afford to rebuild this later
Commerce-aware chat from day one
Custom XMPP namespaces for product cards,
offer sessions, event cards, group purchases
Ejabberd routes them — Spring Boot handles business logic
NexGate chat is a greenfield Phase 2 build.
3. What We Are Building
Building from scratch:
Ejabberd Cluster ← real-time transport
Two nodes, same Hetzner VPS at launch
Handles all WebSocket connections
Routes all XMPP stanzas
Manages presence, MUC, Jingle calls
XEP-0198 stream management for EA networks
Spring Boot Chat Service ← business brain
Message persistence (PostgreSQL)
Commerce context (offer sessions, product cards)
Notification routing (FCM + Textfy)
Shop inbox access control
Call records + quality logs
Offline escalation
Spring Boot Main Backend ← platform API
Auth (PONA Auth V3) + XMPP token issuance
VP Shop, VP Feed, VP Events integration
Commerce triggers to Chat Service
Coturn TURN Server ← voice/video relay
EA carrier NAT bypass
Separate small Hetzner VPS
NexGate Chat SDK ← mobile dev layer
Android (Smack wrapper)
iOS (XMPPFramework wrapper)
Hides all XMPP complexity from mobile dev
Clean Java/Swift API
Infrastructure (already running):
Redis ✅ presence cache, hot messages
RabbitMQ ✅ offline queue, service events
PostgreSQL ✅ persistence
MinIO ✅ media storage
Cloudflare ✅ CDN
Vault ✅ secrets
Traefik ✅ reverse proxy
File Thunder ✅ media processing
FCM + Textfy ✅ notifications
4. Full Architecture
┌──────────────────────────────────────────────────────────┐
│ NexGate Mobile App │
│ Android / iOS │
└────┬──────────────┬──────────────┬──────────────┬───────┘
│ │ │ │
WebSocket HTTP REST WebRTC HLS Player
XMPP stanzas (unchanged) (calls + (streams —
MessagePack Main Backend spaces) Phase 3)
│ │ │
▼ ▼ │
┌─────────────────────────┐ │
│ Ejabberd Cluster │ │
│ │ │
│ Node 1 Node 2 │ │
│ ┌──────┐ ┌──────┐ │ │
│ │Erlang│◀─▶Erlang│ │ │
│ │ dist │ │ dist │ │ │
│ └──────┘ └──────┘ │ │
│ │ │
│ XMPP/WebSocket │ │
│ Presence (built-in) │ │
│ MUC rooms (XEP-0045) │ │
│ Jingle signaling │◀───────┘
│ (XEP-0166) │ (call signaling
│ MQTT broker │ via WS)
│ Push bridge XEP-0357 │
└──────────┬──────────────┘
│
│ HTTP (auth only — sync)
│ RabbitMQ (all events — async)
│
┌──────────▼──────────────────────────────────────────────┐
│ Spring Boot Chat Service │
│ (unchanged from Phase 1) │
│ │
│ Messages · Receipts · Commerce Context │
│ Notification Router · Call Records │
│ Shop Inbox · Offline Escalation │
└──────────┬──────────────┬──────────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────────────────────────────┐
│ PostgreSQL │ │ RabbitMQ │
│ Redis │ │ chat.message.inbound │
│ (unchanged) │ │ chat.presence │
│ │ │ chat.call.events │
└──────────────┘ │ chat.notify.push │
│ chat.notify.escalation │
└──────────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Coturn TURN Server │
│ (separate VPS) │
│ UDP relay for voice/video │
│ when P2P blocked by EA NAT │
└─────────────────────────────────┘
5. Ejabberd — The Transport Backbone
What Ejabberd Owns in Phase 2
✅ All WebSocket connections (2M concurrent per node)
✅ XMPP stanza routing between users
✅ User presence — online/offline/away (built-in protocol)
✅ Typing indicators (XEP-0085)
✅ Message delivery receipts (XEP-0184)
✅ Multi-User Chat rooms — MUC (XEP-0045)
✅ Voice/video call signaling — Jingle (XEP-0166)
✅ Push notification bridge (XEP-0357 → FCM/APNs)
✅ MQTT broker (Mini Apps events)
✅ Stream management / reconnection (XEP-0198)
✅ Cross-node routing (Erlang distributed — no Redis pub/sub needed)
❌ Does NOT touch:
PostgreSQL (NexGate's schema)
Business logic
Commerce context
Payment processing
File processing
Ejabberd Key Modules Enabled
mod_mam Message Archive Management
Stores message history in its own DB
Clients can sync history on reconnect
mod_muc Multi-User Chat
Group chats, live stream comment rooms
Max 500 members per room (configurable)
Persistent rooms survive server restart
mod_ping Keepalive ping every 30 seconds
Kills dead connections automatically
Critical for EA mobile networks
mod_push Push notification bridge
Connects to FCM/APNs on user disconnect
Replaces manual FCM calls from Chat Service
mod_stun_disco STUN/TURN server discovery
Tells clients where Coturn is
Used for voice/video call setup
mod_mqtt MQTT broker on port 1883
For Mini Apps real-time events (Phase 3)
Zero extra infrastructure needed
mod_http_api REST API on port 5285
Spring Boot calls this to send messages
Admin operations (kick user, create room)
Ejabberd Config Highlights
hosts:
- "nexgate.com"
listen:
- port: 5280 # WebSocket — mobile app connects here
module: ejabberd_http
request_handlers:
/ws: ejabberd_ws
/api: mod_http_api
- port: 5285 # REST API — Spring Boot calls here (internal only)
module: ejabberd_http
ip: "127.0.0.1"
request_handlers:
/api: mod_http_api
- port: 1883 # MQTT — Mini Apps (Phase 3)
module: mod_mqtt
- port: 3478 # STUN — voice/video setup
transport: udp
module: ejabberd_stun
# Auth — Ejabberd calls Spring Boot
auth_method: http
auth_opts:
url: "http://chat-service:8082/internal/ejabberd/auth"
auth_header: "X-Internal-Secret"
auth_header_value: "${EJABBERD_INTERNAL_SECRET}"
# PostgreSQL — Ejabberd's own separate database
sql_type: pgsql
sql_server: "postgres"
sql_database: "ejabberd" # NOT nexgate — separate DB
default_db: sql
modules:
mod_mam:
default: always
db_type: sql
mod_muc:
db_type: sql
max_users: 500
mod_ping:
send_pings: true
ping_interval: 30
timeout_action: kill
mod_push: {}
mod_stun_disco:
credentials_lifetime: 3600
services:
- host: "turn.nexgate.com"
port: 3478
type: turn
secret: "${COTURN_SECRET}"
mod_mqtt: {}
mod_http_api: {}
Two Separate PostgreSQL Databases
postgres instance (same server, two databases):
nexgate ← NexGate application data
messages, conversations, users, orders
Spring Boot owns this entirely
Ejabberd never touches this
ejabberd ← Ejabberd's own operational data
message archive (MAM)
MUC room state
roster data
Spring Boot never touches this
Why separate:
Ejabberd manages its own schema migrations
NexGate schema evolves independently
Clean ownership — no shared tables
Easy to backup independently
6. Ejabberd ↔ Spring Boot Bridge
Communication Rules
Ejabberd → Spring Boot:
Auth events: HTTP (synchronous — must respond fast)
Message events: RabbitMQ (async)
Presence events: RabbitMQ (async)
Call events: RabbitMQ (async)
MUC events: RabbitMQ (async)
Spring Boot → Ejabberd:
Send message to user: Ejabberd REST API (port 5285)
Create MUC room: Ejabberd REST API
Kick user session: Ejabberd REST API
Check user online: Ejabberd REST API
Broadcast to room: Ejabberd REST API
Rule: auth is the ONLY synchronous call
Everything else is async via RabbitMQ
RabbitMQ Events from Ejabberd
Exchange: ejabberd.events (topic)
Routing Key Fired When
──────────────────────────────────────────────────────
chat.message.inbound User sends a message
chat.message.group User sends to MUC room
chat.presence.online User WS connects
chat.presence.offline User WS disconnects
chat.call.initiated Jingle session-initiate received
chat.call.accepted Jingle session-accept received
chat.call.declined Jingle session-declined received
chat.call.ended Jingle session-terminate received
chat.muc.created MUC room created
chat.muc.joined User joined MUC room
chat.muc.left User left MUC room
Spring Boot Internal Endpoints (Ejabberd calls these)
POST /internal/ejabberd/auth
Called on every WebSocket connection
Ejabberd sends: { username, token }
Spring Boot responds: 200 (allow) or 401 (deny)
Must respond in < 200ms (checked in Redis cache first)
All other events arrive via RabbitMQ consumers
No other synchronous HTTP endpoints needed
Spring Boot → Ejabberd REST API Examples
Send system message to user:
POST http://ejabberd:5285/api/send_message
{
"from": "system@nexgate.com",
"to": "usr-123@nexgate.com",
"body": "",
"extra": {
"type": "ORDER_STATUS_UPDATE",
"orderId": "ord-456",
"status": "SHIPPED"
}
}
Create live stream MUC room:
POST http://ejabberd:5285/api/create_room
{
"name": "live-stream-abc",
"service": "conference.nexgate.com",
"host": "nexgate.com"
}
Kick expired session:
POST http://ejabberd:5285/api/kick_session
{
"user": "usr-123",
"host": "nexgate.com",
"resource": "android",
"reason": "Token expired"
}
7. Authentication Flow
Two Tokens Issued at Login
User logs into NexGate
│
▼ POST /auth/login
Main Backend (PONA Auth V3):
Validate credentials
Issue two tokens:
REST JWT (7 days):
Used for all HTTP API calls
Standard Bearer token
XMPP Token (24 hours):
Used only for Ejabberd connection
Contains: userId, JID, expiry
Shorter lifetime — chat sessions refresh more often
│
▼ Both tokens returned to app
WebSocket Connection Auth
App connects WebSocket:
wss://chat.nexgate.com/ws
Header: Authorization: Bearer {XMPP_TOKEN}
│
▼
Ejabberd receives connection
Extracts token from header
│
▼ HTTP POST (sync) → Spring Boot
/internal/ejabberd/auth
{ username: "usr-123", token: "XMPP_TOKEN" }
│
Spring Boot:
Check Redis cache first (fast path):
token:{hash} → valid/invalid (TTL 5min)
If not cached:
Validate JWT signature
Check token type == XMPP
Check user not banned/suspended
Cache result in Redis
Return: 200 { authorized: true, jid: "usr-123@nexgate.com" }
or 401 { authorized: false, reason: "TOKEN_EXPIRED" }
│
Ejabberd:
200 → allow connection
register: usr-123@nexgate.com/android as ONLINE
publish to RabbitMQ: chat.presence.online
401 → reject WebSocket
app shows: "Session expired, please login again"
JID Structure
Every NexGate entity has a JID (Jabber ID):
Personal user:
usr-123@nexgate.com/android ← full JID (user + device)
usr-123@nexgate.com ← bare JID (user only)
Shop identity:
techstore@shops.nexgate.com ← shop JID
Multiple staff auth as this JID
Customer sees "TechStore" — not the staff member
System bot:
system@nexgate.com ← order updates, notifications
MUC rooms:
live-abc@conference.nexgate.com ← live stream chat room
group-xyz@conference.nexgate.com ← group chat room
Multi-device:
usr-123@nexgate.com/android ← phone
usr-123@nexgate.com/ios ← tablet
Both receive messages
READ on one → Ejabberd notifies other to clear notification
XMPP Token Refresh
XMPP token expires every 24 hours
App background service:
At 23 hours → POST /auth/refresh-xmpp-token
Header: Bearer {REST_JWT} (still valid — 7 days)
Response: new XMPP token
Re-auth without reconnecting:
App sends new auth stanza on existing WS connection
Ejabberd re-validates via Spring Boot
No disconnection — seamless for user
8. Message Flow — Phase 2
Inbound Message (User Sends)
[Client A — Android]
│
│ WebSocket frame (MessagePack encoded):
│ {
│ type: MSG_SEND
│ temp_id: "abc-123"
│ to: "usr-456@nexgate.com"
│ conv_id: "conv-789"
│ body: "Habari"
│ content_type: TEXT
│ }
│
▼
[Ejabberd Node 1]
│
├── Validates session (already authed)
├── ACKs client immediately:
│ { temp_id: "abc-123", status: ACK }
├── Routes to usr-456 (if online):
│ Erlang looks up which node holds usr-456
│ If Node 1 → delivers directly
│ If Node 2 → Erlang distributed message (no Redis needed)
│
└── Publishes to RabbitMQ: chat.message.inbound
{
from: "usr-123@nexgate.com",
to: "usr-456@nexgate.com",
conv_id: "conv-789",
body: "Habari",
temp_id: "abc-123",
timestamp: 1719446400
}
.
. (async)
.
[Spring Boot Chat Service]
│ consumes chat.message.inbound
│
├── Authorization check (can A message B?)
├── Resolve message level
├── Write to PostgreSQL (messages table)
├── Write to Redis hot cache (last 50 per conv)
├── Update conversation last_message
│
├── usr-456 online? (check via Ejabberd REST API)
│ YES → DELIVERED receipt after Ejabberd confirms
│ NO → RabbitMQ offline queue + FCM + escalation timer
│
└── Notify sender: tick update
REST API → Ejabberd → WS push to Client A
Client A: ✓✓ (delivered)
Cross-Node Routing — No Redis Pub/Sub Needed
Phase 1 (Spring Boot WS):
Pod 1 holds Client A connection
Pod 2 holds Client B connection
Redis pub/sub needed to bridge pods
Pod 1 publishes → Redis → Pod 2 delivers
Phase 2 (Ejabberd cluster):
Node 1 holds Client A connection
Node 2 holds Client B connection
Erlang distributed messaging bridges nodes
Node 1 → Erlang dist → Node 2 delivers
Redis pub/sub no longer needed for routing
(Redis still used by Chat Service for hot cache)
This is why Ejabberd can do 2M concurrent:
Erlang process per connection (~2KB RAM each)
Native cross-node routing built into the language
No external message bus overhead
9. Voice Calls
Components
Signaling: Ejabberd Jingle (XEP-0166)
coordinates call setup via XMPP stanzas
STUN: Ejabberd built-in (mod_stun_disco)
helps devices find their public IP behind NAT
TURN: Coturn (separate VPS)
relay when P2P impossible (EA carrier NAT)
Transport: WebRTC in mobile app
actual audio stream between devices
Codec: Opus
adaptive 6kbps (2G) → 64kbps (WiFi)
echo cancellation + noise suppression built in
non-negotiable for EA networks
Jingle Signaling Stanzas
<!-- Step 1: Kibuti initiates call to Juma -->
<iq from="kibuti@nexgate.com/android"
to="juma@nexgate.com"
type="set" id="call-001">
<jingle xmlns="urn:xmpp:jingle:1"
action="session-initiate"
sid="session-abc-123"
initiator="kibuti@nexgate.com/android">
<content name="audio">
<description xmlns="urn:xmpp:jingle:apps:rtp:1"
media="audio">
<payload-type id="111" name="opus" clockrate="48000"/>
</description>
<transport xmlns="urn:xmpp:jingle:transports:ice-udp:1"
ufrag="someUfrag"
pwd="somePassword">
<candidate ... /> <!-- Kibuti's ICE candidates -->
</transport>
</content>
</jingle>
</iq>
<!-- Step 2: Juma accepts -->
<iq from="juma@nexgate.com/android"
to="kibuti@nexgate.com/android"
type="set" id="call-002">
<jingle action="session-accept"
sid="session-abc-123">
<!-- Juma's SDP answer + ICE candidates -->
</jingle>
</iq>
<!-- Step 3: Call ends -->
<iq type="set">
<jingle action="session-terminate"
sid="session-abc-123">
<reason><success/></reason>
</jingle>
</iq>
Full Voice Call Flow
[Kibuti — taps Call]
│
▼ GET /chat/calls/turn-credentials
Spring Boot returns:
{
iceServers: [
{ urls: "stun:chat.nexgate.com:3478" },
{ urls: "turn:turn.nexgate.com:3478",
username: "usr-123:1719446400",
credential: "hmac_token" }
],
ttl: 3600
}
│
▼ Initialize WebRTC PeerConnection
Add audio track (Opus codec)
Gather ICE candidates (STUN discovery)
│
▼ Send Jingle session-initiate via Ejabberd WS
Ejabberd routes to Juma
Ejabberd fires RabbitMQ event: chat.call.initiated
│
Spring Boot:
Create call record:
status: RINGING
started_at: now
If Juma offline → FCM HIGH priority:
{ type: INCOMING_CALL, callId, callerName, callType: VOICE }
│
[Juma's phone rings]
Juma taps Answer
│
▼ Juma sends Jingle session-accept via Ejabberd WS
ICE negotiation begins between devices:
│
├── P2P possible? (good network)
│ Direct connection established ✅
│ No Coturn bandwidth used
│
└── P2P blocked? (EA carrier NAT)
Both connect to Coturn relay
Audio flows: Kibuti → Coturn → Juma
│
Call live 🎉
RTCP monitors quality every 200ms:
Good network → Opus 32-64kbps, clear voice
3G → Opus 16kbps, still good
2G → Opus 8kbps, slightly robotic but connected
Very poor → Opus 6kbps, minimum viable
│
Kibuti taps End
│
▼ Jingle session-terminate via Ejabberd WS
Ejabberd fires: chat.call.ended
Spring Boot:
Update call record:
status: COMPLETED
ended_at: now
duration_seconds: calculated
relay_used: true/false
Call State Machine
IDLE
│ user taps Call
▼
INITIATING
│ getting TURN credentials
│ creating WebRTC offer
▼
RINGING ──────────────────────▶ MISSED (45s timeout)
│ Jume answers
▼
CONNECTING
│ ICE negotiation
│ finding best path
▼
CONNECTED ────────────────────▶ RECONNECTING (network drop)
│ call live │ ICE restart
│ │ 10s timeout → FAILED
│ user ends
▼
ENDING
│ Jingle terminate sent
▼
COMPLETED / MISSED / DECLINED / FAILED
Codec Ladder — Opus Adaptive
Network Bitrate Quality
─────────────────────────────────────────────────
WiFi / 4G strong 64 kbps HD voice
4G standard 32 kbps Clear
3G 16 kbps Good enough
2G / Edge 8 kbps Robotic but connected
Barely alive 6 kbps Minimum viable
─────────────────────────────────────────────────
Opus switches automatically based on RTCP feedback
No configuration needed — adaptive by design
10. Video Calls
Same Architecture as Voice + Camera
Everything from voice call applies
Additional components:
Video codec: H.264 (primary)
Hardware accelerated on Tecno, Infinix, Samsung
Low battery drain — GPU handles encoding
Fallback: VP8 (software, more CPU)
Camera: Front camera default (switchable)
Device detects capability at call start
Resolution ladder (adaptive):
─────────────────────────────────────────────────
WiFi 720p 30fps 1.5 Mbps
4G strong 480p 24fps 800 kbps
3G 360p 15fps 400 kbps
2G 240p 10fps 150 kbps
Very poor VIDEO OFF — audio only (Opus)
─────────────────────────────────────────────────
Degradation order (never drops call):
1. Reduce color depth
2. Reduce resolution (720→480→360→240)
3. Reduce frame rate (30→24→15→10fps)
4. Reduce audio bitrate
5. Disable video entirely → audio only
6. Reduce audio to minimum (6kbps Opus)
Device Tier Detection
App detects device capability at call start:
High-end (Pixel, Samsung S series):
H.264 hardware encoder (GPU)
Start at 720p 30fps
Low battery impact
Mid-range (Samsung A series):
H.264 hardware encoder
Start at 480p 24fps
Medium battery impact
Low-end (Tecno Spark, Infinix Hot):
H.264 software encoder (CPU)
Start at 360p 15fps
High battery impact
Show warning: "Video call may drain battery faster"
Auto-disable video after 10min if battery < 20%
Jingle for Video — Additional Content Block
<!-- Video call adds video content block -->
<jingle action="session-initiate" sid="session-xyz">
<!-- Audio block (same as voice) -->
<content name="audio">
<description media="audio">
<payload-type name="opus" clockrate="48000"/>
</description>
<transport .../>
</content>
<!-- Video block (added for video calls) -->
<content name="video">
<description media="video">
<payload-type id="96" name="H264" clockrate="90000"/>
<payload-type id="97" name="VP8" clockrate="90000"/>
</description>
<transport .../>
</content>
</jingle>
11. Coturn — TURN Relay
Why TURN is Mandatory for EA
Direct P2P (ideal):
Both devices negotiate directly
Audio/video flows device-to-device
Ejabberd not involved in media
No bandwidth cost on your servers
EA reality — P2P often blocked:
Vodacom, Airtel, Tigo use CGNAT
Multiple users share one public IP
P2P connection cannot be established
Without TURN → call fails
TURN relay (fallback):
Both devices connect to Coturn
Coturn relays audio/video between them
Call works regardless of carrier NAT
Bandwidth cost on your server (~50KB/min voice)
Coturn Config Highlights
listening-port=3478
tls-listening-port=5349
relay-ip=YOUR_COTURN_VPS_IP
realm=nexgate.com
lt-cred-mech # time-limited credentials
use-auth-secret
static-auth-secret=${COTURN_SECRET} # from Vault
min-port=49152
max-port=65535
TURN Credentials Generation
Credentials are time-limited HMAC tokens
Generated by Spring Boot per call session
Coturn validates them — prevents abuse
Format:
username: {userId}:{expiry_timestamp}
credential: HMAC-SHA1(secret, username)
ttl: 3600 seconds (1 hour per call)
Only NexGate users can use your TURN server
No credential → Coturn rejects connection
Bandwidth Estimation
Voice call via TURN:
Opus 16kbps × 2 directions = ~4KB/min
1 hour call ≈ 240KB per participant
Video call via TURN:
360p H.264 × 2 directions = ~6MB/min
Force 360p max when on relay to control cost
Coturn VPS sizing:
Hetzner CX11 (€4/month, 1vCPU/2GB)
20TB bandwidth included
Handles ~500 concurrent voice relay calls
Upgrade to CX21 at scale
12. MessagePack Encoding
Why Switch from JSON
JSON message frame:
{"type":"MSG_SEND","conv_id":"conv-123456","sender_id":"usr-789012",
"body":"Habari","timestamp":1719446400000,"temp_id":"abc-def-ghi"}
Size: ~140 bytes
Every key repeated as string on every message
Numbers encoded as ASCII characters
Parsing: character by character
MessagePack same message:
[binary representation]
Size: ~50 bytes
Keys encoded as integers (schema registered)
Numbers encoded as actual bytes (int32 = 4 bytes)
Parsing: read fixed byte positions
Result:
60-65% smaller on wire
3-5x faster to parse
Critical for users on 2G/3G with limited data bundles
Migration Strategy (No Breaking Change)
Both formats supported simultaneously:
Client sends header:
Content-Type: application/msgpack → MessagePack
Content-Type: application/json → JSON (default)
Ejabberd detects Content-Type
Routes to appropriate deserializer
Migration flow:
Old app version → sends JSON → works fine
New app version → sends MessagePack → works fine
No forced update required
Gradual migration over 30-60 days
Remove JSON support after 90%+ adoption
13. Broadcast Channels
What They Are
Creator → unlimited followers
One-directional: creator posts, followers receive
Like Telegram channels
No replies from followers (unless creator enables Q&A)
Use cases:
Shop announcement channel ($techstore updates)
Creator content channel (@kibuti posts)
NexGate system channel (platform announcements)
Fan-out Strategy
Small channel (< 10,000 followers):
Write-on-send — Chat Service pushes to each follower
Same as group chat fan-out
Large channel (10,000+ followers):
Lazy fan-out — store message once
Followers fetch on open (read-time delivery)
No per-follower push for casual followers
FCM push only to followers with notifications enabled
Same celebrity bypass pattern as VP Feed:
Hot channels → read-time merge
Normal channels → write-time fan-out
Ejabberd MUC for Channels
Broadcast channel = MUC room with restrictions:
Only owner/admins can send messages
Members are read-only subscribers
mod_muc handles this with role configuration:
Role: moderator → can send
Role: visitor → read only
This means channels are built on the
same MUC infrastructure as group chats
No separate implementation needed
14. MQTT — Mini Apps Foundation
What MQTT Enables
Ejabberd runs MQTT broker on port 1883
No extra infrastructure — already in Ejabberd
Mini Apps subscribe to topics:
orders/{orderId} → real-time order updates
delivery/{trackingId} → GPS delivery tracking
live/{streamId}/viewers → viewer count updates
jiko/{restaurantId} → JikoXpress kitchen events
Spring Boot publishes events:
Order shipped → publish to orders/{orderId}
Mini App receives instantly
No polling needed
MQTT vs XMPP for Mini Apps
XMPP (chat):
Full protocol, complex stanzas
Designed for human conversation
Bidirectional, stateful sessions
Right tool for chat
MQTT (events):
Lightweight pub/sub protocol
Designed for IoT and event streams
Minimal overhead (2-byte header)
Right tool for Mini App events
Works on very limited connections
Both live inside Ejabberd:
Same server, different protocols
Mobile app uses XMPP for chat
Mini Apps use MQTT for events
Zero additional infrastructure
15. Message Interactions
All message interaction features are handled via standard XMPP XEPs. Ejabberd routes the stanzas automatically — Spring Boot handles persistence and business rules via RabbitMQ events.
Overview — All Four Features
Feature XEP Status Ejabberd
────────────────────────────────────────────────────────
Edit message XEP-0308 Stable ✅ auto routed
Delete for everyone XEP-0424 Stable ✅ auto routed
Reactions XEP-0444 Stable ✅ auto routed
Forwarding XEP-0297 Stable ✅ auto routed
Reply to message XEP-0461 Experimental auto routed
Stable stanza IDs XEP-0359 Stable ✅ auto assigned
────────────────────────────────────────────────────────
All routed by Ejabberd
Spring Boot handles: validation, persistence, rules
XEP-0359 — Stable Stanza IDs (Foundation)
Before the features — this XEP is the foundation all others depend on. Every message gets a stable server-assigned ID used by reactions, edits, retractions, and replies to reference the correct message.
<!-- Ejabberd automatically adds stanza-id to every message -->
<message from="kibuti@nexgate.com"
to="juma@nexgate.com"
id="client-generated-id">
<body>Habari</body>
<stanza-id xmlns="urn:xmpp:sid:0"
id="server-stable-id-abc123"
by="nexgate.com"/>
<!-- server-stable-id-abc123 is what reactions/edits reference -->
</message>
Message Editing — XEP-0308
Who can edit: Original sender only
Time window: 15 minutes after send
What: Text body only
Commerce cards: ❌ BLOCKED — financial records are immutable
System messages: ❌ BLOCKED — never editable
<!-- Kibuti edits his message -->
<message from="kibuti@nexgate.com"
to="juma@nexgate.com"
type="chat"
id="edit-002">
<body>Habari yako Juma, vipi biashara?</body>
<replace xmlns="urn:xmpp:message-correct:0"
id="server-stable-id-abc123"/>
<!-- references original message by stanza-id -->
</message>
Flow:
Kibuti edits → stanza sent via Ejabberd WS
Ejabberd routes to Juma (if online)
Ejabberd fires RabbitMQ: chat.message.edited
│
Spring Boot:
Is sender original author? ✅
Within 15 minute window? ✅
Not a commerce/system message? ✅
Update messages.body = new text
Update messages.edited_at = now
Increment messages.edit_count
│
Juma's app:
Receives edit stanza
Updates message in place (same position in thread)
Shows "Edited" label under message
Group chats:
Same stanza sent to MUC room JID
Ejabberd MUC broadcasts to all members
All see updated message simultaneously
Delete for Everyone — XEP-0424
Two delete modes:
Delete for me:
Local filter only
No Ejabberd stanza needed
Spring Boot records: message_deletions (scope: SELF)
Recipient unaffected
Delete for everyone:
XEP-0424 retraction stanza
Ejabberd routes to all recipients
Time window: 15 minutes
Commerce cards: ❌ BLOCKED
System messages: ❌ BLOCKED
<!-- Delete for everyone — retraction stanza -->
<message from="kibuti@nexgate.com"
to="juma@nexgate.com"
type="chat"
id="retract-003">
<apply-to xmlns="urn:xmpp:fasten:0"
id="server-stable-id-abc123">
<retract xmlns="urn:xmpp:message-retract:1"/>
</apply-to>
</message>
Flow:
Kibuti retracts → stanza via Ejabberd WS
Ejabberd routes to Juma
Ejabberd fires RabbitMQ: chat.message.retracted
│
Spring Boot:
Is sender original author? ✅
Within 15 minute window? ✅
Not blocked message type? ✅
Soft delete:
messages.deleted_at = now
messages.deleted_by = usr-kibuti
messages.delete_scope = EVERYONE
body NOT removed (audit trail kept)
│
Juma's app:
Receives retraction stanza
Replaces message with:
"This message was deleted"
Same position in thread
Nothing is ever hard deleted from PostgreSQL:
Legal compliance (EA regulations)
Dispute resolution (order/payment disputes)
Admin investigation (fraud cases)
Soft delete always — hard delete never
Reactions — XEP-0444
Model: One reaction per user per message
Emoji set: Limited set at launch
❤️ 👍 😂 😮 😢 🙏
Change: Send new emoji → replaces old
Remove: Send empty → removes reaction
Commerce cards: ✅ ALLOWED (reactions don't modify content)
System messages: ❌ BLOCKED
<!-- Kibuti reacts 👍 to message -->
<message from="kibuti@nexgate.com"
to="juma@nexgate.com"
type="chat"
id="reaction-001">
<reactions xmlns="urn:xmpp:reactions:0"
id="server-stable-id-abc123">
<reaction>👍</reaction>
</reactions>
</message>
<!-- Kibuti changes to ❤️ -->
<message ...>
<reactions xmlns="urn:xmpp:reactions:0"
id="server-stable-id-abc123">
<reaction>❤️</reaction>
</reactions>
</message>
<!-- Kibuti removes reaction -->
<message ...>
<reactions xmlns="urn:xmpp:reactions:0"
id="server-stable-id-abc123">
<!-- empty = removed -->
</reactions>
</message>
Flow:
Kibuti taps 👍 → reaction stanza via Ejabberd WS
Ejabberd routes to Juma
Ejabberd fires RabbitMQ: chat.message.reaction
│
Spring Boot:
Upsert in message_reactions:
ON CONFLICT (message_id, user_id)
→ update emoji + timestamp
Empty emoji received → delete reaction record
│
Juma's app:
Receives reaction stanza
Updates reaction display below message:
👍 1
Kibuti's own reaction: highlighted
Group chats:
Stanza sent to MUC room
Ejabberd MUC broadcasts to all members
All screens update simultaneously:
👍 3 ❤️ 2 😂 1
Notification:
Reaction on your message → FCM push
"Juma reacted 👍 to your message"
Level: NORMAL (FCM only — no SMS)
Muted conversations → no reaction notification
Message Forwarding — XEP-0297
What it is:
Client creates NEW message in target conversation
Original message wrapped inside as reference
Server never "moves" anything
Forwarded label shown with original sender name
Forward chain tracking:
chain = 1: "↪ Forwarded from Juma Mwangi"
chain = 2-4: "↪ Forwarded"
chain = 5+: "↪ Forwarded many times" (misinformation warning)
Multi-forward: up to 5 conversations per action
Max chain: no hard limit but UI degrades label
Commerce rules:
Product card: ✅ anyone can forward
Custom price offer: ❌ private deal — blocked
Order confirmation: ❌ private record — blocked
Payment record: ❌ private record — blocked
System messages: ❌ blocked
<!-- Kibuti forwards Juma's message to Amina -->
<message from="kibuti@nexgate.com"
to="amina@nexgate.com"
type="chat"
id="fwd-001">
<body>Angalia hii</body>
<forwarded xmlns="urn:xmpp:forward:0">
<delay xmlns="urn:xmpp:delay"
stamp="2026-07-02T10:32:00Z"/>
<!-- original send time preserved -->
<message from="juma@nexgate.com"
to="kibuti@nexgate.com"
type="chat"
id="msg-original-001">
<body>Habari yako rafiki!</body>
</message>
</forwarded>
<!-- NexGate forward metadata -->
<nexgate-forward xmlns="urn:nexgate:forward">
<original_sender_name>Juma Mwangi</original_sender_name>
<forward_chain>1</forward_chain>
</nexgate-forward>
</message>
Flow:
Kibuti taps Forward on Juma's message
Picks Amina's conversation
App creates new message stanza (not routing original)
Sends via Ejabberd WS to Amina
Ejabberd routes normally as new message
Fires RabbitMQ: chat.message.inbound (same as any message)
│
Spring Boot:
Validates forward is allowed (type check)
Creates new messages record:
is_forwarded: true
original_sender_name: "Juma Mwangi"
forward_chain: 1
media_ref: original fileId (no re-upload)
│
Amina's app:
Receives as new message
Renders with forwarded label:
┌────────────────────────────────┐
│ ↪ Forwarded from Juma Mwangi │
│ │
│ Habari yako rafiki! │
│ 10:45 │
└────────────────────────────────┘
Media forwarding:
References original fileId — no re-upload
10 people forward same image
→ 1 file in MinIO, 10 message records
File Thunder serves same file to all
Message Replies — XEP-0461
Reply to a specific message in thread
Like WhatsApp/Telegram quote-reply
Shows original message above reply
Status: Experimental ⚠️
Not yet stable standard
But widely implemented
(Gajim, Monal, many others use it)
Safe to implement — unlikely to change drastically
<!-- Juma replies to Kibuti's specific message -->
<message from="juma@nexgate.com"
to="kibuti@nexgate.com"
type="chat"
id="reply-001">
<body>Nzuri sana, asante!</body>
<reply xmlns="urn:xmpp:reply:0"
to="kibuti@nexgate.com"
id="server-stable-id-abc123"/>
<!-- id references the message being replied to -->
</message>
UI renders:
┌────────────────────────────────┐
│ ┌──────────────────────────┐ │
│ │ Kibuti │ │ ← quoted original
│ │ Habari yako Juma! │ │
│ └──────────────────────────┘ │
│ │
│ Nzuri sana, asante! │
│ 10:47 │
└────────────────────────────────┘
Tap on quote → scroll to original message
Commerce Messages — Interaction Rules Summary
Message type Edit Delete(all) React Forward
────────────────────────────────────────────────────────
Text message ✅ 15m ✅ 15m ✅ ✅
Voice note ❌ ✅ 15m ✅ ✅
Image / Video ❌ ✅ 15m ✅ ✅
Product card ❌ ❌ ✅ ✅
Custom price offer ❌ ❌ ✅ ❌
Order confirmation ❌ ❌ ✅ ❌
Payment confirmation ❌ ❌ ✅ ❌
System message ❌ ❌ ❌ ❌
────────────────────────────────────────────────────────
Why commerce cards are protected:
Immutable negotiation record
Seller cannot change agreed price after the fact
Buyer cannot claim different price was offered
Full audit trail in thread — legally important
RabbitMQ Events — New in Phase 2 for Interactions
Exchange: nexgate.chat (topic) — additions:
Routing Key Fired When
──────────────────────────────────────────────────────
chat.message.edited XEP-0308 received
chat.message.retracted XEP-0424 received
chat.message.reaction XEP-0444 received
chat.message.forwarded XEP-0297 received
chat.message.delete_self delete for me (REST call)
16. Docker Deployment
docker-compose additions for Phase 2
ejabberd:
image: ghcr.io/processone/ejabberd:latest
container_name: ejabberd
restart: unless-stopped
ports:
- "5222:5222" # XMPP TCP
- "5280:5280" # WebSocket + HTTP
- "1883:1883" # MQTT
- "3478:3478/udp" # STUN
volumes:
- ./ejabberd/ejabberd.yml:/home/ejabberd/conf/ejabberd.yml
- ./ejabberd/data:/home/ejabberd/database
- ./ejabberd/logs:/home/ejabberd/logs
environment:
- EJABBERD_BYPASS_WARNINGS=true
depends_on:
- postgres
- rabbitmq
networks:
- nexgate-internal
# Coturn on separate VPS — not in same compose
# Deployed independently on Hetzner CX11
# Connects back to NexGate via internal network
Traefik — WebSocket Routing
# Ejabberd service labels for Traefik
labels:
- "traefik.enable=true"
# App connects here for chat
- "traefik.http.routers.chat.rule=Host(`chat.nexgate.com`)"
- "traefik.http.routers.chat.tls=true"
- "traefik.http.routers.chat.tls.certresolver=letsencrypt"
- "traefik.http.services.chat.loadbalancer.server.port=5280"
# Sticky sessions — CRITICAL for WebSocket
# Same user must always hit same Ejabberd node
- "traefik.http.services.chat.loadbalancer.sticky.cookie=true"
- "traefik.http.services.chat.loadbalancer.sticky.cookie.name=ejabberd_node"
Why sticky sessions:
User connected to Ejabberd Node 1
Next request hits Node 2
→ connection context lost → disconnected
Sticky cookie ensures:
usr-123 always → Node 1
usr-456 always → Node 2
WS sessions stable across load balancer
Ejabberd Cluster Config
# Second node joins cluster
# On node 2's ejabberd.yml:
hosts:
- "nexgate.com"
# Erlang cookie must match on all nodes
# Set via environment variable
# Both nodes discover each other automatically
# Erlang distributed handles the rest
# Result:
# Message to usr-456 arrives on Node 1
# usr-456 connected to Node 2
# Erlang routes internally — transparent
17. Database Schema
calls (new in Phase 2)
calls
─────────────────────────────────────────────
call_id UUID PK
caller_id UUID
receiver_id UUID
conversation_id UUID FK → conversations
type ENUM VOICE / VIDEO
status ENUM RINGING / CONNECTED / COMPLETED /
MISSED / DECLINED / FAILED
started_at TIMESTAMPTZ
answered_at TIMESTAMPTZ
ended_at TIMESTAMPTZ
duration_seconds INT
relay_used BOOLEAN
end_reason ENUM NORMAL / NETWORK / TIMEOUT / DECLINED
call_quality_logs (new in Phase 2)
call_quality_logs
─────────────────────────────────────────────
log_id UUID PK
call_id UUID FK → calls
timestamp TIMESTAMPTZ
direction ENUM OUTBOUND / INBOUND
bitrate_kbps INT
packet_loss_pct DECIMAL
jitter_ms INT
rtt_ms INT
resolution TEXT "360p" "480p" "720p" or null
codec_audio TEXT "opus"
codec_video TEXT "h264" "vp8" or null
broadcast_channels (new in Phase 2)
broadcast_channels
─────────────────────────────────────────────
channel_id UUID PK
owner_id UUID userId or shopId
owner_type ENUM USER / SHOP
name TEXT
description TEXT
avatar_file_id UUID
subscriber_count INT
type ENUM PERSONAL / SHOP / SYSTEM
created_at TIMESTAMPTZ
message_reactions (new in Phase 2)
message_reactions
─────────────────────────────────────────────
id UUID PK
message_id UUID FK → messages
conversation_id UUID FK → conversations
user_id UUID
emoji TEXT "👍" "❤️" "😂" etc
reacted_at TIMESTAMPTZ
Unique constraint: (message_id, user_id)
→ one reaction per user per message
→ upsert on conflict replaces emoji
message_deletions (new in Phase 2)
message_deletions
─────────────────────────────────────────────
id UUID PK
message_id UUID FK → messages
deleted_by UUID userId
scope ENUM SELF / EVERYONE
deleted_at TIMESTAMPTZ
messages table additions (Phase 2)
New columns added to existing messages table:
edited_at TIMESTAMPTZ when last edited
edit_count INT how many times edited
original_body TEXT body before first edit (audit)
deleted_at TIMESTAMPTZ soft delete timestamp
deleted_by UUID who deleted
delete_scope ENUM SELF / EVERYONE
is_forwarded BOOLEAN was this forwarded
forward_chain INT forwarding depth (1,2,3...)
original_sender_name TEXT display name at forward time
original_message_id UUID source message if forwarded
reply_to_id UUID FK → messages (for replies)
stanza_id TEXT Ejabberd XEP-0359 stable ID
18. Commerce Stanzas & Custom Namespaces
The Extensible Part of XMPP
XMPP was designed to be extended by anyone for anything. The "X" in XMPP = Extensible.
Any application can add custom XML elements inside standard XMPP stanzas using their own namespace. Ejabberd routes the entire stanza as-is — it never parses, validates, or modifies custom elements. Spring Boot reads them on the other side.
Standard stanza:
<message from="a@nexgate.com" to="b@nexgate.com">
<body>Habari</body>
</message>
With NexGate custom element:
<message from="a@nexgate.com" to="b@nexgate.com">
<body>Habari</body>
<nexgate-offer xmlns="urn:nexgate:offer:1">
... your custom data here ...
</nexgate-offer>
</message>
Ejabberd:
Routes whole stanza as-is ✅
Never touches nexgate-offer element ✅
Never validates it ✅
Just delivers it ✅
NexGate Namespace Registry
All custom namespaces NexGate defines:
urn:nexgate:commerce:1 product cards
urn:nexgate:offer:1 price offer sessions
urn:nexgate:groupbuy:1 Bei ya pamoja cards
urn:nexgate:event:1 event cards
urn:nexgate:feed:1 VP Feed post cards
urn:nexgate:live:1 live stream cards
urn:nexgate:audio:1 audio space cards
urn:nexgate:system:1 system messages
urn:nexgate:forward forwarding metadata
urn:nexgate:states recording voice note state
urn:nexgate:meta message metadata
Versioning (:1, :2):
Allows schema evolution
Old app sees :1 → renders fine
New app sees :2 → renders richer UI
Old clients fall back to <body> text
No breaking changes
Product Card Stanza
Sent by: Spring Boot via Ejabberd REST API
When: Buyer taps "Chat with Seller" on product page
<message from="system@nexgate.com"
to="techstore@shops.nexgate.com"
type="chat"
id="card-001">
<!-- Fallback for basic clients -->
<body>Mteja anaomba habari: Samsung A15</body>
<nexgate-commerce xmlns="urn:nexgate:commerce:1">
<type>PRODUCT_CARD</type>
<initiated_by>usr-kibuti</initiated_by>
<conv_id>conv-789</conv_id>
<product>
<id>prod-123</id>
<name>Samsung A15</name>
<public_price>450000</public_price>
<currency>TZS</currency>
<image_url>https://cdn.nexgate.com/img.jpg</image_url>
<stock>12</stock>
<shop_name>TechStore</shop_name>
<shop_id>shop-456</shop_id>
<snapshot_at>2026-07-13T08:30:00Z</snapshot_at>
<!-- price frozen at this moment — never changes -->
</product>
</nexgate-commerce>
</message>
Seller's app renders:
┌─────────────────────────────────────┐
│ 📦 Samsung A15 │
│ TZS 450,000 │
│ Inapatikana: Vipande 12 │
│ TechStore │
│ [Jibu] [Angalia Bidhaa] │
└─────────────────────────────────────┘
Custom Price Offer Stanza
Sent by: Seller's app via Ejabberd WebSocket
When: Seller attaches price offer from shop
(both Flow 1 post-negotiation and Flow 2 direct attach)
<message from="techstore@shops.nexgate.com/amina"
to="kibuti@nexgate.com"
type="chat"
id="offer-002">
<body>Bei yako maalum: TZS 400,000</body>
<nexgate-offer xmlns="urn:nexgate:offer:1">
<offer_id>offer-uuid-abc</offer_id>
<conv_id>conv-789</conv_id>
<valid_minutes>30</valid_minutes>
<initiated_by>SELLER</initiated_by>
<product>
<id>prod-123</id>
<name>Samsung A15</name>
<image_url>https://cdn.nexgate.com/img.jpg</image_url>
<shop_name>TechStore</shop_name>
<shop_id>shop-456</shop_id>
</product>
<pricing>
<public_price>450000</public_price>
<offer_price>400000</offer_price>
<currency>TZS</currency>
<discount_amount>50000</discount_amount>
<discount_pct>11</discount_pct>
</pricing>
<!-- Staff who sent offer — not visible to buyer -->
<!-- Buyer always sees "TechStore" not "Amina" -->
<sent_by_staff>usr-amina</sent_by_staff>
</nexgate-offer>
</message>
Buyer's app renders:
┌─────────────────────────────────────┐
│ 💰 Bei Maalum Kwako │
│ Samsung A15 │
│ ~~TZS 450,000~~ │
│ TZS 400,000 (umepunguziwa 50,000) │
│ Inaisha: dakika 30 │
│ Idadi: [─ 1 +] │
│ [Kataa] [Endelea Kulipa →] │
└─────────────────────────────────────┘
Offer Response Stanzas
<!-- Buyer declines offer -->
<message from="kibuti@nexgate.com"
to="techstore@shops.nexgate.com"
type="chat"
id="resp-003">
<body>Nimekataa bei hii</body>
<nexgate-offer xmlns="urn:nexgate:offer:1">
<offer_id>offer-uuid-abc</offer_id>
<response>DECLINED</response>
</nexgate-offer>
</message>
<!-- System sends expiry notification -->
<message from="system@nexgate.com"
to="conv-789-participants"
type="chat"
id="expire-004">
<nexgate-offer xmlns="urn:nexgate:offer:1">
<offer_id>offer-uuid-abc</offer_id>
<response>EXPIRED</response>
<message_id>offer-002</message_id>
<!-- references offer card message to update its UI -->
</nexgate-offer>
</message>
Order Confirmation Stanza
Sent by: Spring Boot via Ejabberd REST API
When: Buyer completes checkout successfully
<message from="system@nexgate.com"
to="conv-789-participants"
type="chat"
id="confirm-005">
<body>Agizo limefanikiwa!</body>
<nexgate-system xmlns="urn:nexgate:system:1">
<type>ORDER_CONFIRMATION</type>
<order_id>ord-xyz-789</order_id>
<conv_id>conv-789</conv_id>
<offer_id>offer-uuid-abc</offer_id>
<summary>
<product_name>Samsung A15</product_name>
<quantity>1</quantity>
<amount_paid>400000</amount_paid>
<currency>TZS</currency>
<status>CONFIRMED</status>
</summary>
</nexgate-system>
</message>
Both buyer and seller see:
┌─────────────────────────────────────┐
│ ✅ Agizo Limethibitishwa │
│ Ord #ORD-XYZ-789 │
│ Samsung A15 × 1 │
│ TZS 400,000 imelipwa │
│ [Fuatilia Agizo] │
└─────────────────────────────────────┘
Bei ya Pamoja Card Stanza
<message from="kibuti@nexgate.com"
to="juma@nexgate.com"
type="chat"
id="gb-006">
<body>Jiunge na group buy hii!</body>
<nexgate-groupbuy xmlns="urn:nexgate:groupbuy:1">
<group_buy_id>gb-xyz</group_buy_id>
<product_id>prod-123</product_id>
<product_name>Samsung A15</product_name>
<product_image>https://cdn.nexgate.com/img.jpg</product_image>
<group_price>350000</group_price>
<public_price>450000</public_price>
<currency>TZS</currency>
<current_participants>7</current_participants>
<target_participants>10</target_participants>
<expires_at>2026-07-13T18:00:00Z</expires_at>
</nexgate-groupbuy>
</message>
Event Card Stanza
<message from="kibuti@nexgate.com"
to="juma@nexgate.com"
type="chat"
id="evt-007">
<body>Jiunge na event hii!</body>
<nexgate-event xmlns="urn:nexgate:event:1">
<event_id>evt-456</event_id>
<title>Dar Tech Summit 2026</title>
<date>2026-08-15T09:00:00Z</date>
<venue>Julius Nyerere ICC, Dar es Salaam</venue>
<ticket_price>25000</ticket_price>
<currency>TZS</currency>
<cover_image>https://cdn.nexgate.com/evt.jpg</cover_image>
<available_tickets>150</available_tickets>
</nexgate-event>
</message>
VP Feed Post Card Stanza
<message from="kibuti@nexgate.com"
to="juma@nexgate.com"
type="chat"
id="post-008">
<body>Angalia post hii</body>
<nexgate-feed xmlns="urn:nexgate:feed:1">
<post_id>post-789</post_id>
<author_name>Kibuti Mwangi</author_name>
<author_avatar>https://cdn.nexgate.com/av.jpg</author_avatar>
<caption>Bidhaa mpya zimefika! 🔥</caption>
<media_url>https://cdn.nexgate.com/post.jpg</media_url>
<media_type>IMAGE</media_type>
<like_count>245</like_count>
</nexgate-feed>
</message>
Spring Boot — How It Handles Custom Stanzas
All stanzas arrive via RabbitMQ: chat.message.inbound
Spring Boot parses XML and routes by namespace:
Namespace detected Handler
──────────────────────────────────────────────────
urn:nexgate:commerce:1 handleProductCard()
urn:nexgate:offer:1 handleOfferSession()
urn:nexgate:groupbuy:1 handleGroupBuy()
urn:nexgate:event:1 handleEventCard()
urn:nexgate:feed:1 handlePostCard()
urn:nexgate:system:1 handleSystemMessage()
none of the above handleTextMessage()
Offer Session — Spring Boot Processing
CUSTOM_PRICE_OFFER received:
Spring Boot:
Create message record (type: CUSTOM_PRICE_OFFER)
Create commerce_offer_sessions record:
offer_id: from stanza
buyer_id: conversation partner
shop_id: sender shop JID
product snapshot: from stanza
offer_price: from stanza (server authoritative)
expires_at: now + valid_minutes
status: PENDING
Schedule RabbitMQ delayed job:
delay: valid_minutes
payload: { offerId, action: EXPIRE }
Send FCM to buyer:
"TechStore amekutumia bei maalum"
Level: IMPORTANT
Buyer taps "Endelea Kulipa":
POST /checkout/initiate { offerId, quantity }
Spring Boot:
Validate: status=PENDING, not expired, buyer matches
Update status: CHECKOUT
Price from DB — never from client ✅
Return: { checkoutUrl, checkoutToken }
Order completes:
Update status: COMPLETED
order_id: linked
Send ORDER_CONFIRMATION stanza to conversation
Expiry fires (RabbitMQ delayed job):
Status still PENDING? → mark EXPIRED
Status already changed? → do nothing
Send OFFER_EXPIRED stanza to conversation
commerce_offer_sessions Table
commerce_offer_sessions
─────────────────────────────────────────────────────
offer_id UUID PK
conv_id UUID FK → conversations
message_id UUID FK → messages
shop_id UUID
buyer_id UUID
sent_by_staff UUID staff who sent (audit only)
product_id UUID
product_name TEXT
product_image_url TEXT
snapshot_json JSONB full product at offer time
public_price BIGINT TZS
offer_price BIGINT TZS (custom — server auth)
currency TEXT TZS
quantity_min INT
quantity_max INT
discount_amount BIGINT
discount_pct DECIMAL
status ENUM PENDING / ACCEPTED /
DECLINED / EXPIRED /
CHECKOUT / COMPLETED /
CANCELLED / ABANDONED
valid_minutes INT
expires_at TIMESTAMPTZ
initiated_by ENUM BUYER / SELLER
notes TEXT
created_at TIMESTAMPTZ
responded_at TIMESTAMPTZ
checkout_at TIMESTAMPTZ
completed_at TIMESTAMPTZ
order_id UUID FK → orders (after completion)
19. Build Order
NexGate chat is built from scratch — no migration, no Phase 1 to carry forward. This is the recommended sequence:
Week 1-2 — Local Experiments
Ejabberd running in Docker locally
Two containers (node1 + node2) clustered
Auth bridge: Spring Boot validates XMPP tokens
Send first message between two test JIDs
Confirm Erlang dist working between nodes
Confirm RabbitMQ events firing to Spring Boot
Week 3-4 — PostgreSQL Schema + Chat Service
All tables created (messages, conversations,
receipts, calls, offer sessions, reactions etc)
Spring Boot Chat Service:
RabbitMQ consumers for all Ejabberd events
Message persistence
Receipt tracking
Notification routing (FCM + Textfy)
Week 5 — Ejabberd Staging Deployment
Deploy to Hetzner staging VPS
Two node cluster live
Traefik sticky sessions configured
Auth bridge connected to Chat Service
Send first real message through staging Ejabberd
Week 6-7 — Mobile SDK + Basic Chat
NexGate Chat SDK (Android + iOS)
Smack / XMPPFramework wrapper
Clean send/receive API
Auto-reconnect + Stream Management
Text messages working end-to-end
Typing indicators
Delivery + read ticks
Presence (online/offline)
Week 8 — Message Interactions
Reactions (XEP-0444)
Edit messages (XEP-0308)
Delete for everyone (XEP-0424)
Forwarding (XEP-0297)
Replies (XEP-0461)
Week 9 — Voice Calls
TURN credentials endpoint in Spring Boot
Coturn deployed (Hetzner CX11)
Jingle signaling through Ejabberd
WebRTC on Android/iOS
Opus audio confirmed on 2G test
Coturn relay confirmed on EA network
Week 10 — Video Calls
H.264 video track added
Adaptive resolution ladder
Resolution ladder tested on 3G
Week 11 — Commerce DMs
Custom namespace stanzas:
PRODUCT_CARD
CUSTOM_PRICE_OFFER
OFFER_DECLINED / OFFER_EXPIRED
ORDER_CONFIRMATION
Offer session lifecycle
Both commerce flows (buyer initiates + seller attaches)
Checkout redirect flow
Shop inbox isolation + access control
Week 12 — Group Chats + Broadcast
MUC rooms (Ejabberd XEP-0045)
Group message reactions
Group typing indicators
Broadcast channels (read-only MUC)
Week 13 — Offline + Notifications
RabbitMQ offline queue
FCM HIGH priority integration
Textfy SMS escalation
Notification levels (NORMAL/IMPORTANT/CRITICAL)
Catch-up banner on reconnect
Week 14 — MessagePack
MessagePack encoding in SDK
Content-Type header detection in Ejabberd
Both JSON + MessagePack supported simultaneously
EA bandwidth savings confirmed
Week 15 — Testing + EA Network Testing
Test on actual Vodacom/Airtel SIM cards
Test on Tecno/Infinix devices
Test on 2G/3G networks
Call quality on Coturn relay confirmed
Commerce flow end-to-end confirmed
Week 16 — Ship 🚀
Production deployment
Two Ejabberd nodes live
All features confirmed
NexGate chat is live
Summary
NexGate chat is built directly on Phase 2 architecture from scratch. No migration. No legacy code. Greenfield build on carrier-grade infrastructure from day one.
Ejabberd Cluster (two Docker containers, same Hetzner VPS at launch) handles all WebSocket connections, XMPP stanza routing, presence, MUC group chats, Jingle voice/video signaling, and 25+ chat features via standard XEPs — all at zero custom code cost. Erlang Distribution connects the two nodes directly, routing messages between them in microseconds without Redis pub/sub.
Spring Boot Chat Service owns all business logic — message persistence, commerce context, offer session lifecycle, shop inbox access control, notification routing, and call records. It communicates with Ejabberd asynchronously via RabbitMQ for all events except auth, which is synchronous HTTP because Ejabberd needs an immediate allow/deny decision.
Custom XMPP Namespaces extend the protocol for NexGate's commerce features. Product cards, custom price offers, offer session responses, Bei ya pamoja cards, event cards, and post cards all travel as custom XML elements inside standard XMPP stanzas. Ejabberd routes them as-is — Spring Boot parses and handles them. Commerce messages are server-authoritative and immutable: offer prices come from the database, not the client. Public product prices are never touched.
WebRTC + Coturn handles voice and video calls. Jingle stanzas through Ejabberd coordinate setup. Opus adapts audio from 64kbps on WiFi to 6kbps on 2G. H.264 hardware acceleration keeps video calls battery-friendly on EA phones. Coturn relay ensures calls work behind EA carrier NAT on Vodacom, Airtel, and Tigo.
MessagePack reduces message frame size 60-65% — real saving for EA users on limited data bundles. Both JSON and MessagePack supported simultaneously during SDK rollout.
The build is 16 weeks from local experiments to production. WhatsApp-class infrastructure. Commerce-aware from day one. EA network optimized throughout.
NexGate Chat Platform — Phase 2: Production Architecture v1.0 QBIT SPARK | Ejabberd · Coturn · WebRTC · Commerce Stanzas · Edit · Delete · React · Forward
VP Live & VP Audio Spaces
Live Streaming Architecture
NexGate / QBIT SPARK | Version 1.0 SRS · HLS · LiveKit · VP Live Video · VP Audio Radio · VP Audio Spaces
Table of Contents
- Overview
- VP Live vs VP Audio — Key Differences
- How Live Streaming Works
- VP Live — Video Streaming
- VP Audio Radio — One Broadcaster Many Listeners
- VP Audio Spaces — Multi Speaker Rooms
- Live Chat — Ejabberd MUC
- Stream Key System
- File Thunder Integration — VOD After Stream
- Codecs & EA Network Strategy
- Docker Deployment
- Database Schema
- Scale Path
1. Overview
VP Live and VP Audio Spaces live under VP Feed — the social pillar of NexGate. They are not separate products. They are the live expression layer of the social platform — where creators, merchants, and communities connect with their audiences in real time.
VP Feed
┌───────────────────────────────────────────────────┐
│ │
│ Social Posts Stories Reels Live │
│ │
│ ┌─────────────────┐ │
│ │ VP Live │ │
│ │ Video Stream │ │
│ ├─────────────────┤ │
│ │ VP Audio │ │
│ │ Radio │ │
│ ├─────────────────┤ │
│ │ VP Audio │ │
│ │ Spaces │ │
│ └─────────────────┘ │
└───────────────────────────────────────────────────┘
All three modes share the same infrastructure foundation: SRS for ingest and transcoding, Cloudflare CDN for delivery, Ejabberd MUC for live chat, File Thunder for VOD processing, and Spring Boot for stream management and business logic.
2. VP Live vs VP Audio — Key Differences
VP Live VP Audio Radio VP Audio Spaces
(Video) (Radio/Podcast) (Twitter Spaces)
──────────────────────────────────────────────────────────────────────
Broadcasters 1 1 Multiple (up to 30)
Viewers Unlimited Unlimited Unlimited listeners
Direction One way One way Multi-speaker
Broadcaster RTMP RTMP audio WebRTC (LiveKit)
transport (video+audio) (audio only)
Listener HLS video HLS audio HLS audio
transport (adaptive) (adaptive) (listeners)
WebRTC (speakers)
Latency 6-15 seconds 6-15 seconds Speakers: <200ms
Listeners: 6-15s
Bandwidth High Very low Low (speakers)
broadcaster (2-4 Mbps) (128 kbps) Very low (listeners)
Bandwidth Medium Very low Very low
listener (300kbps-2Mbps) (32-128 kbps) (32-128 kbps)
Works on 2G? ❌ No ✅ Yes ✅ Listeners yes
Live chat Ejabberd MUC Ejabberd MUC Ejabberd MUC
Raise hand ❌ ❌ ✅
VOD after ✅ File Thunder ✅ File Thunder ✅ File Thunder
New infra SRS SRS SRS + LiveKit
3. How Live Streaming Works
The Core Pattern — RTMP → HLS → CDN
Broadcasting (sending):
Broadcaster's phone records camera + mic
App encodes: H.264 video + AAC audio
App streams via RTMP protocol to SRS server
One stream upload from broadcaster
Processing (server):
SRS receives RTMP stream
FFmpeg transcodes to multiple quality variants
Packages into HLS format (2-second chunks)
Writes chunks to MinIO storage every 2 seconds
Delivery (viewing):
Cloudflare CDN pulls chunks from MinIO
Caches chunks at edge nodes globally
Viewers request HLS playlist → adaptive player picks quality
10,000 viewers = 10,000 CDN requests, NOT 10,000 SRS requests
SRS barely notices the viewer count
Why HLS and not WebRTC for viewers:
WebRTC to viewers: broadcaster uploads N streams (one per viewer)
HLS via CDN: broadcaster uploads 1 stream → CDN serves all
At 10,000 viewers: WebRTC = impossible, HLS = trivial
HLS — What It Actually Is
HLS (HTTP Live Streaming) — Apple's open standard
SRS generates:
master.m3u8 → playlist of all quality variants
360p/playlist.m3u8 → playlist for 360p variant
360p/seg_000.ts → 2-second video chunk
360p/seg_001.ts → next 2-second chunk
720p/playlist.m3u8
720p/seg_000.ts
...
master.m3u8 looks like:
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=640x360
360p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720
720p/playlist.m3u8
Player (ExoPlayer / AVPlayer):
Downloads master.m3u8 first
Measures current network speed
Picks 360p if on 3G → plays seg_000.ts → seg_001.ts → ...
Switches to 720p if network improves → seamless
All automatic — zero app code needed for quality switching
4. VP Live — Video Streaming
Full Architecture
[Broadcaster Phone]
│
│ RTMP stream
│ rtmp://stream.nexgate.com/live/{streamKey}
│ H.264 video + AAC audio
│ ~2-4 Mbps upload
▼
[SRS Media Server]
│
├── Validates stream key:
│ POST /internal/stream/validate
│ { streamKey: "abc123" }
│ Spring Boot: ✅ allow or ❌ reject
│
├── Receives raw RTMP stream
│
├── FFmpeg transcoding (real-time):
│ 1080p H.264 → 3 Mbps (WiFi viewers)
│ 720p H.264 → 1.5 Mbps (4G viewers)
│ 480p H.264 → 600 kbps (3G viewers)
│ 360p H.264 → 300 kbps (2G viewers)
│
├── Package as HLS:
│ Segment every 2 seconds
│ live/{streamKey}/master.m3u8
│ live/{streamKey}/360p/seg_NNN.ts
│ live/{streamKey}/720p/seg_NNN.ts
│
└── Write to MinIO: nexgate-live bucket
New segments every 2 seconds
│
▼
[Cloudflare CDN]
│ Pulls from MinIO automatically
│ Caches at edge (Nairobi edge closest to EA)
│ Short TTL: 10 seconds (live content)
│
▼
[Viewers — ExoPlayer (Android) / AVPlayer (iOS)]
Requests master.m3u8
Player picks quality based on network
Downloads .ts segments every 2 seconds
Seamless adaptive quality switching
Stream Key Validation Flow
Broadcaster taps "Go Live" in app
│
▼ POST /live/start
Spring Boot:
Generate unique stream key
Store in DB:
stream_key: "abc123"
user_id: usr-kibuti
status: PENDING
created_at: now
Return stream key to app
│
App connects RTMP:
rtmp://stream.nexgate.com/live/abc123
│
SRS receives connection
│
▼ POST /internal/stream/validate (SRS webhook)
Spring Boot checks:
Key exists? ✅
User account active? ✅
User has live permission? ✅
No other active stream for this user? ✅
→ 200 OK → SRS allows stream
→ Update DB: status: LIVE, started_at: now
→ Notify followers via FCM:
"Kibuti anastreamu sasa! Tazama live"
→ Create Ejabberd MUC room:
live-abc123@conference.nexgate.com
Broadcaster App — What Mobile Dev Implements
Android library: rtmp-rtsp-stream-client-java
iOS library: HaishinKit (Swift)
Steps for broadcaster app:
1. GET /live/start → receive stream key
2. Initialize camera + microphone
3. Connect RTMP to stream.nexgate.com/live/{key}
4. Start streaming — library handles everything:
H.264 encoding (hardware)
AAC audio encoding
RTMP packet framing
Network reconnection on drop
5. Show: viewer count (from Redis via REST poll)
live comments (from Ejabberd MUC via WS)
duration timer
6. Tap End → POST /live/end → cleanup
Adaptive upload bitrate:
Library monitors upload speed
Reduces video quality if upload struggles
Broadcaster's bad network → lower quality for viewers
Never drops stream if avoidable
Viewer App — What Mobile Dev Implements
Android: ExoPlayer (Google's official video player)
iOS: AVPlayer (built into iOS, zero setup)
Steps for viewer app:
1. GET /live/{streamId}/url
Response: { masterUrl, viewerCount, startedAt }
2. Feed masterUrl to ExoPlayer/AVPlayer
3. Player handles everything automatically:
Downloads master.m3u8
Picks quality based on network
Downloads segments every 2s
Switches quality up/down seamlessly
4. Join Ejabberd MUC room → show live comments
5. Player shows: loading → buffering → playing
That is genuinely all the viewer needs to implement.
HLS + ExoPlayer/AVPlayer is the easiest viewer experience
to build in all of mobile development.
5. VP Audio Radio — One Broadcaster Many Listeners
Why Audio Radio Matters for EA
VP Live video:
Broadcaster needs: 2-4 Mbps upload
Viewer needs: 300kbps minimum
Data cost viewer: ~900MB per hour at 360p
Works on: 4G and strong 3G only
VP Audio Radio:
Broadcaster needs: 64-128 kbps upload
Listener needs: 32 kbps minimum
Data cost listener: ~15MB per hour at 32kbps
Works on: 2G, Edge, any connection
For a farmer in rural Tanzania with 2G:
VP Live video → impossible, too expensive
VP Audio Radio → accessible, affordable
Use cases:
Live podcast / commentary
Religious broadcasts (huge in EA)
Political discussions
Community announcements
Sports commentary
Language learning sessions
Business webinars (audio only)
Architecture — Same SRS, Audio Only
[Broadcaster Phone]
│
│ RTMP audio only (no video track)
│ AAC codec, 128 kbps
│ rtmp://stream.nexgate.com/audio/{streamKey}
▼
[SRS Media Server]
│
├── Same validation flow as VP Live
│
├── FFmpeg transcoding (audio only):
│ AAC 128 kbps → good network listeners
│ AAC 64 kbps → 3G listeners
│ AAC 32 kbps → 2G listeners
│
├── Package as HLS audio:
│ audio/{streamKey}/master.m3u8
│ audio/{streamKey}/128k/seg_NNN.aac
│ audio/{streamKey}/32k/seg_NNN.aac
│
└── Write to MinIO: nexgate-live bucket
│
▼
[Cloudflare CDN]
│
▼
[Listeners — ExoPlayer / AVPlayer]
HLS audio playlist
Adaptive bitrate: 128k → 32k automatically
Same player, same code — just no video surface
Codec Choice — AAC Not Opus
Why AAC for HLS audio radio (not Opus):
Opus is better quality at low bitrates — true
But HLS has a compatibility requirement:
Apple mandates AAC for HLS audio
AVPlayer on iOS does not support Opus in HLS
Using Opus → iOS listeners cannot play
AAC → works on every device, every OS
Opus is used for:
Voice calls (WebRTC — different transport)
Voice notes (file-based, not streaming)
AAC is used for:
VP Live audio track (in video stream)
VP Audio Radio (HLS streaming)
VP Audio Spaces listener HLS output
AAC at 32kbps for EA:
Acceptable speech quality
~15MB per hour
Works on any 2G connection
Universal device support
6. VP Audio Spaces — Multi Speaker Rooms
The Concept
Not one broadcaster → many listeners
Multiple people in a shared audio room
Some speak, many listen
Listeners can raise their hand to speak
Host controls who gets the mic
Like Twitter Spaces, Clubhouse, Discord Stage Channels
Key insight:
Speakers need LOW LATENCY (<200ms)
to have a natural conversation
HLS (6-15s delay) is too slow for speakers
Listeners just need to HEAR clearly
HLS delay is fine — they're not responding
HLS scales to millions via CDN
Solution: TWO transport layers in one room
Speakers → WebRTC (LiveKit SFU) → <200ms
Listeners → HLS via CDN → 6-15s delay → millions scale
LiveKit SFU — What It Is
SFU = Selective Forwarding Unit
Traditional conference (MCU):
Server mixes ALL audio into one stream
Sends mixed stream to everyone
High CPU (server does all mixing)
Simple client
LiveKit SFU approach:
Each speaker sends audio once to LiveKit
LiveKit forwards each speaker's stream
to all other speakers
Speakers' apps mix locally (device CPU)
Much lower server CPU
Lower latency
Better quality (no mixing artifacts)
For listeners:
LiveKit outputs a mixed HLS stream
Goes through SRS → Cloudflare CDN
Listeners get one mixed audio stream
Same HLS pattern as Audio Radio
Who built LiveKit:
The same team that built Twitter Spaces
Then open sourced it
Actively maintained, Docker ready
Official Android + iOS SDKs available
Full Architecture
[Speaker A phone] ──WebRTC──▶┐
[Speaker B phone] ──WebRTC──▶│
[Speaker C phone] ──WebRTC──▶│
▼
[LiveKit SFU]
│
┌──────────┼──────────────┐
│ │ │
WebRTC fwd HLS output Room events
to speakers (mixed audio) to Spring Boot
│ │
[Speakers [SRS receives
hear each HLS from LiveKit]
other live] │
▼
[Cloudflare CDN]
│
▼
[Thousands of listeners
via HLS audio player]
ExoPlayer / AVPlayer
(same as Audio Radio)
Room events (raise hand, join, leave):
LiveKit → Spring Boot via webhook
Spring Boot → Ejabberd MUC → all participants
Ejabberd MUC → Listeners also see events
(who joined as speaker etc)
Raise Hand Flow
Listener wants to speak:
│ taps "Raise Hand" 🖐
│ sends via Ejabberd WS to MUC room:
│ { type: RAISE_HAND, roomId: "space-abc" }
│
▼
Spring Boot:
Records raise hand request
Notifies host via Ejabberd WS:
{ type: HAND_RAISED, userId, displayName }
Host sees list of raised hands in UI
│
Host taps "Allow to speak" on a listener:
│
▼
Spring Boot:
Calls LiveKit API:
Update participant permissions:
canPublish: true ← now allowed to send audio
Generate new LiveKit token for this user
(speaker token, not listener token)
Send token to user via Ejabberd WS:
{ type: SPEAKER_PROMOTED, livekitToken: "..." }
│
Former listener's app:
Receives promotion event
Stops HLS player (was listening at 15s delay)
Connects WebRTC to LiveKit with speaker token
Starts sending audio
Now hears speakers at <200ms latency
Other speakers hear them immediately
│
Host can also:
Lower someone's hand (dismiss)
Mute a specific speaker
Remove speaker (back to listener)
End the space entirely
Speaker vs Listener — Connection Types
┌──────────────────────────────────────────────────────┐
│ Audio Space Room │
│ │
│ Speakers (up to ~20-30): │
│ Connected via WebRTC to LiveKit │
│ Send and receive audio streams │
│ Latency: <200ms (real conversation) │
│ Connection: persistent WebRTC │
│ │
│ Listeners (unlimited): │
│ Connected via HLS to Cloudflare CDN │
│ Receive mixed audio only │
│ Latency: 6-15 seconds (fine — just listening) │
│ Connection: HTTP requests every 2s │
│ Scale: millions — CDN handles it │
│ │
│ All participants: │
│ Connected to Ejabberd MUC room │
│ Text chat, reactions, raise hand events │
│ Room membership awareness │
└──────────────────────────────────────────────────────┘
LiveKit Token System
Spring Boot manages all LiveKit tokens
(LiveKit has official Java SDK)
Host token:
canPublish: true
canSubscribe: true
roomAdmin: true
→ full control, can speak, manage
Speaker token:
canPublish: true
canSubscribe: true
roomAdmin: false
→ can speak, cannot manage room
Listener token:
canPublish: false ← cannot send audio
canSubscribe: true ← can hear speakers
roomAdmin: false
→ receive only
Token generation:
GET /audio-spaces/{spaceId}/join
Spring Boot checks:
Is user the host? → host token
Is user an approved speaker? → speaker token
Otherwise → listener token (gets HLS URL instead)
LiveKit Docker Config
livekit:
image: livekit/livekit-server:latest
container_name: livekit
restart: unless-stopped
ports:
- "7880:7880" # HTTP API (Spring Boot calls here)
- "7881:7881" # WebRTC TCP
- "7882:7882/udp" # WebRTC UDP (primary)
- "50000-60000:50000-60000/udp" # ICE relay ports
volumes:
- ./livekit/livekit.yaml:/etc/livekit.yaml
command: --config /etc/livekit.yaml
# livekit.yaml
port: 7880
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
redis:
address: redis:6379 # reuses existing Redis ✅
turn:
enabled: true
domain: turn.nexgate.com
tls_port: 5349
credential: "${COTURN_SECRET}" # reuses existing Coturn ✅
room:
max_participants: 10000
empty_timeout: 300
LiveKit reuses:
Redis → already deployed ✅
Coturn → already deployed for calls ✅
No new infrastructure beyond LiveKit container itself
7. Live Chat — Ejabberd MUC
All three live modes (VP Live, Audio Radio, Audio Spaces) use Ejabberd MUC rooms for real-time text interaction.
Room Lifecycle
Stream / space starts:
│
Spring Boot → Ejabberd REST API:
POST /api/create_room
{
name: "live-{streamId}",
service: "conference.nexgate.com"
}
Room created: live-abc@conference.nexgate.com
│
Broadcaster / host auto-joined as moderator
│
Viewers / listeners join room as participants:
App connects Ejabberd WS
Sends MUC join stanza:
<presence to="live-abc@conference.nexgate.com/Kibuti">
<x xmlns="http://jabber.org/protocol/muc"/>
</presence>
│
Comments sent as MUC messages:
<message to="live-abc@conference.nexgate.com"
type="groupchat">
<body>Mzuri sana! 🔥</body>
</message>
│
All room members receive instantly
No delay — Ejabberd MUC is real-time
│
Stream / space ends:
Spring Boot → Ejabberd REST API:
POST /api/destroy_room
{ name: "live-abc", service: "conference.nexgate.com" }
Room destroyed, members disconnected
Special Events in Live Chat
Beyond text comments, the MUC room carries:
Reactions (emoji bursts):
{ type: REACTION, emoji: "🔥", userId, displayName }
Client renders floating emoji animation
Gifts:
{ type: GIFT, giftId, giftName, amount, userId, displayName }
Client renders gift animation
Spring Boot processes payment separately
Raise hand (Audio Spaces only):
{ type: RAISE_HAND, userId, displayName }
Host sees in management panel
Speaker promoted (Audio Spaces only):
{ type: SPEAKER_PROMOTED, userId, displayName }
All participants see "Amina joined as speaker"
Viewer count updates:
Broadcast every 30 seconds from Spring Boot
{ type: VIEWER_COUNT, count: 12453 }
Product card dropped by broadcaster:
{ type: PRODUCT_CARD, productId, name, price }
Viewers tap → go to VP Shop product page
Commerce during live ✅
Viewer / Listener Count
Two sources of truth:
1. Ejabberd MUC occupant count:
GET ejabberd REST /api/get_room_occupants_count
{ room: "live-abc", host: "conference.nexgate.com" }
→ exact WebSocket-connected count
2. Redis counter (includes HLS-only listeners):
INCR live:{streamId}:viewers → on HLS playlist request
DECR → on playlist stop / timeout
More accurate for Audio Radio/Spaces
where many listeners never connect WS
Display count = Redis counter (higher, more accurate)
Spring Boot broadcasts to MUC every 30 seconds
8. Stream Key System
Stream Key Design
Stream key = single-use authentication token
Broadcaster uses it to connect RTMP to SRS
SRS validates with Spring Boot before accepting stream
Format: random 32-character alphanumeric string
Example: nx_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4
Lifecycle:
PENDING → generated, not yet used
LIVE → broadcaster connected, stream active
ENDED → stream finished normally
EXPIRED → generated but never used (24h TTL)
REVOKED → manually stopped by admin
One active stream per user at a time
Attempting second stream → rejected by Spring Boot validation
SRS Webhooks to Spring Boot
SRS fires these events to Spring Boot:
on_publish → broadcaster connected RTMP
Spring Boot: validate key, update status LIVE,
notify followers FCM,
create Ejabberd MUC room,
create LiveKit room (if audio space)
on_unpublish → broadcaster disconnected
Spring Boot: update status ENDED,
trigger File Thunder for VOD,
destroy Ejabberd MUC room,
log stream duration + peak viewers
on_play → viewer started watching HLS
Spring Boot: increment Redis viewer counter
on_stop → viewer stopped watching
Spring Boot: decrement Redis viewer counter
9. File Thunder Integration — VOD After Stream
What Happens After Stream Ends
Stream ends (broadcaster taps End / disconnects)
│
SRS fires on_unpublish webhook
│
Spring Boot:
Update stream record: status ENDED
Trigger File Thunder for VOD processing
SRS has saved full recording as .mp4
│
▼
Spring Boot → File Thunder:
POST /api/v1/upload/request (HMAC signed)
{
ownerId: broadcasterId,
domain: POSTS,
context: LIVE_RECORDING,
filename: "stream_{streamId}.mp4",
mimeType: "video/mp4"
}
Returns: presigned MinIO PUT URL
│
Spring Boot pulls recording from SRS
Uploads to MinIO via presigned URL
POST /api/v1/confirm { fileId }
│
▼
File Thunder VideoWheel processes:
HLS transcoding (all quality variants)
Thumbnail extraction (best frame detection)
Watermark: "@{broadcasterUsername}"
NO outro — live recordings are long
NO shortClip — full stream only
Store in nexgate-public bucket
│
▼
File Thunder fires webhook: media ready
Spring Boot:
Creates VOD post on broadcaster's profile
"Watch replay" button appears
Appears in VP Feed for followers
Stream record linked to VOD fileId
New File Thunder Contexts for Live
Existing contexts (unchanged):
SOCIAL_VIDEO regular video posts
DM_ATTACHMENT files sent in DMs
DIGITAL_PRODUCT digital goods in VP Shop
...
New contexts added for live:
LIVE_RECORDING full stream VOD
VideoWheel — no outro, no shortClip
always HLS, always long
AUDIO_RECORDING audio space / radio recording
AudioWheel processes
outputs: .m4a (AAC)
podcast episode on profile
waveform extracted (like voice notes)
nexgate-live MinIO Bucket
Existing buckets:
nexgate-raw temp uploads
nexgate-public social content
nexgate-private DMs and private files
nexgate-digital VP Shop digital products
New bucket:
nexgate-live live stream segments only
Why separate:
SRS writes directly here (not via File Thunder)
Short TTL segments — deleted after stream ends + VOD ready
Different CDN caching rules (10s TTL vs 1 year for VOD)
Different access pattern (SRS writes, CDN reads)
Easy to monitor storage growth separately
Lifecycle:
Stream starts → SRS creates live/{streamKey}/ folder
During stream → .ts segments written every 2 seconds
Stream ends → Spring Boot schedules cleanup job
VOD confirmed → delete nexgate-live/{streamKey}/ folder
Total life: stream duration + ~1 hour buffer
10. Codecs & EA Network Strategy
VP Live Video Codecs
Broadcaster encoding (phone → SRS):
Video: H.264 (hardware encoder — mandatory)
Software H.264 too slow for real-time on phones
H.264 hardware support: every phone since 2013
Audio: AAC 128kbps (RTMP standard)
Container: RTMP (streaming protocol)
SRS transcoding (server-side):
Receives H.264 + AAC
Transcodes to HLS quality ladder:
Quality Video bitrate Audio Resolution EA target
─────────────────────────────────────────────────────────
1080p 3 Mbps 128k 1920×1080 WiFi only
720p 1.5 Mbps 128k 1280×720 4G
480p 600 kbps 64k 854×480 3G
360p 300 kbps 48k 640×360 2G minimum
─────────────────────────────────────────────────────────
ExoPlayer/AVPlayer auto-selects based on network
VP Audio Codecs
Audio Radio (broadcaster → SRS):
Codec: AAC 128kbps
Container: RTMP audio only
Audio Radio (SRS → HLS):
128kbps → WiFi/4G listeners
64kbps → 3G listeners
32kbps → 2G listeners (15MB/hour — affordable)
Audio Spaces (speaker → LiveKit):
Codec: Opus (WebRTC standard)
Adaptive: 32-64kbps per speaker
Echo cancellation: mandatory (multiple people)
Noise suppression: mandatory (EA background noise)
Audio Spaces (LiveKit → HLS for listeners):
LiveKit mixes speaker streams
Outputs mixed audio → SRS → HLS
Same AAC ladder as Audio Radio
Listeners hear all speakers in one stream
Adaptive Streaming — EA Principle
The player always knows the network speed
because it measures how fast segments download
Segment download faster than playback → upgrade quality
Segment download slower than playback → downgrade quality
For a viewer in Dodoma on shaky 3G:
Opens stream → starts at 360p (safe default)
Network good → player tries 480p
Stays stable → tries 720p
Network drops → immediately back to 360p
No rebuffering if switch is fast enough
Buffer strategy:
Player buffers 3-4 segments ahead (6-8 seconds)
Gives time to switch quality before buffer empties
Viewer may notice brief quality dip — never a freeze
NexGate player config recommendation:
Min buffer: 6 seconds
Max buffer: 30 seconds
Quality switch: aggressive downgrade, conservative upgrade
→ Prioritize uninterrupted playback over quality
→ EA networks fluctuate — better to be at 360p than buffering
11. Docker Deployment
Full docker-compose for Live Features
# SRS Media Server
srs:
image: ossrs/srs:5
container_name: srs
restart: unless-stopped
ports:
- "1935:1935" # RTMP ingest (broadcaster connects here)
- "8080:8080" # HTTP API + HLS output
- "1985:1985" # SRS management API
volumes:
- ./srs/srs.conf:/usr/local/srs/conf/srs.conf
- ./srs/logs:/usr/local/srs/logs
- ./srs/recordings:/usr/local/srs/objs/recordings
depends_on:
- chat-service
networks:
- nexgate-internal
# LiveKit SFU (Audio Spaces)
livekit:
image: livekit/livekit-server:latest
container_name: livekit
restart: unless-stopped
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "50000-60000:50000-60000/udp"
volumes:
- ./livekit/livekit.yaml:/etc/livekit.yaml
command: --config /etc/livekit.yaml
depends_on:
- redis
networks:
- nexgate-internal
SRS Config Highlights
listen 1935; # RTMP port
max_connections 1000;
vhost __defaultVhost__ {
# Validate stream key with Spring Boot
http_hooks {
enabled on;
on_publish http://chat-service:8082/internal/stream/validate;
on_unpublish http://chat-service:8082/internal/stream/ended;
on_play http://chat-service:8082/internal/stream/viewer-join;
on_stop http://chat-service:8082/internal/stream/viewer-leave;
}
# HLS output for viewers
hls {
enabled on;
hls_path ./objs/nginx/html;
hls_fragment 2; # 2 second chunks
hls_window 10; # keep last 10 chunks in playlist
}
# FFmpeg transcoding to multiple qualities
transcode {
enabled on;
ffmpeg /usr/bin/ffmpeg;
engine 360p {
enabled on;
vcodec libx264;
vbitrate 300;
vfps 15;
vwidth 640;
vheight 360;
acodec aac;
abitrate 48;
output rtmp://localhost:1935/live360p/{stream};
}
engine 720p {
enabled on;
vcodec libx264;
vbitrate 1500;
vfps 30;
vwidth 1280;
vheight 720;
acodec aac;
abitrate 128;
output rtmp://localhost:1935/live720p/{stream};
}
}
}
Traefik — RTMP Does Not Go Through Traefik
Important: RTMP is TCP port 1935
Traefik handles HTTP/HTTPS only
RTMP port 1935 exposed directly on VPS
What Traefik does handle:
stream.nexgate.com → SRS port 8080 (HLS output)
TLS termination for HLS delivery
RTMP broadcaster connects:
rtmp://stream.nexgate.com:1935/live/{key}
No TLS on RTMP (RTMPS is complex, not needed for launch)
HLS viewers connect via Cloudflare CDN:
https://cdn.nexgate.com/live/{key}/master.m3u8
Cloudflare pulls from SRS port 8080
Traefik handles TLS for this path
12. Database Schema
live_streams
live_streams
─────────────────────────────────────────────
stream_id UUID PK
broadcaster_id UUID FK → users
type ENUM VIDEO / AUDIO_RADIO / AUDIO_SPACE
title TEXT
description TEXT
cover_file_id UUID File Thunder fileId (stream thumbnail)
stream_key TEXT UNIQUE, used for RTMP auth
status ENUM PENDING / LIVE / ENDED / EXPIRED / REVOKED
started_at TIMESTAMPTZ
ended_at TIMESTAMPTZ
duration_seconds INT
peak_viewers INT
total_viewers INT
muc_room_id TEXT Ejabberd MUC room name
vod_file_id UUID File Thunder fileId after processing
created_at TIMESTAMPTZ
audio_spaces
audio_spaces
─────────────────────────────────────────────
space_id UUID PK
stream_id UUID FK → live_streams
livekit_room_id TEXT LiveKit room name
host_id UUID FK → users
title TEXT
status ENUM SCHEDULED / LIVE / ENDED
max_speakers INT default 30
started_at TIMESTAMPTZ
ended_at TIMESTAMPTZ
audio_space_participants
audio_space_participants
─────────────────────────────────────────────
space_id UUID FK → audio_spaces
user_id UUID
role ENUM HOST / SPEAKER / LISTENER
joined_at TIMESTAMPTZ
left_at TIMESTAMPTZ
hand_raised_at TIMESTAMPTZ
promoted_at TIMESTAMPTZ when promoted from listener to speaker
promoted_by UUID host who approved
stream_viewer_stats
stream_viewer_stats
─────────────────────────────────────────────
stat_id UUID PK
stream_id UUID FK → live_streams
timestamp TIMESTAMPTZ
viewer_count INT
quality_360p_pct DECIMAL % of viewers on 360p
quality_720p_pct DECIMAL % of viewers on 720p
avg_watch_seconds INT
13. Scale Path
Current Architecture Limits
Single SRS node (Hetzner CPX31 — €19/month):
Concurrent streams: ~200 (with transcoding)
Concurrent viewers: ~50,000 (before CDN helps)
Bandwidth: 20TB/month included
With Cloudflare CDN:
Concurrent viewers: Unlimited (CDN absorbs it)
SRS only serves cache misses
99%+ cache hit rate → SRS barely loaded
LiveKit single node:
Concurrent spaces: ~500
Speakers per space: up to 30
Listeners per space: Unlimited (HLS via CDN)
This is enough for NexGate launch and
strong early growth — tens of thousands of users
Growth Stage — SRS Horizontal Scale
When 200 concurrent streams is not enough:
SRS Origin node:
Receives RTMP from all broadcasters
Passes stream to Transcode Farm
Transcode Farm (2-3 nodes):
Each node handles FFmpeg transcoding
Horizontal — add nodes as streams grow
CPU-bound work distributed
SRS Edge nodes:
Serve HLS to viewers
Pull from Origin
Multiple edges → load distributed
┌──────────────────────────────────────────┐
│ Broadcaster → SRS Origin │
│ │ │
│ Transcode Farm │
│ (3 nodes, FFmpeg) │
│ │ │
│ ┌──────────┴──────────┐ │
│ SRS Edge 1 SRS Edge 2 │
│ │ │ │
│ Cloudflare CDN ────────────┘ │
│ │ │
│ All viewers (millions) │
└──────────────────────────────────────────┘
WeChat EA Scale — Infrastructure
Broadcaster latency problem:
Current: Broadcaster in Dar → stream goes to Hetzner Germany
150-300ms upload latency
Acceptable but not ideal
At scale: SRS nodes in EA region
Google Cloud Johannesburg
OR AWS Cape Town
Broadcaster → nearby SRS → low latency upload
Better broadcaster experience
Storage cost at scale:
Current: MinIO on Hetzner
At scale: Cloudflare R2
Zero egress cost (unlike AWS S3 which charges per GB)
S3 compatible → zero code change to migrate
At millions of viewer-hours: massive cost saving
Transcoding cost:
CPU-heavy work
At scale: GPU-accelerated FFmpeg nodes
NVIDIA hardware encoding
5-10x faster than CPU
Lower cost per stream transcoded
Summary
VP Live and VP Audio Spaces are the live social layer of NexGate — living under VP Feed alongside regular posts, stories, and reels.
All three modes (VP Live video, VP Audio Radio, VP Audio Spaces) share the same infrastructure foundation. SRS handles all RTMP ingest and transcoding. Cloudflare CDN distributes HLS to unlimited viewers and listeners. Ejabberd MUC powers live chat and room events for all modes. File Thunder processes every stream into a VOD after it ends. Spring Boot manages stream keys, webhooks, room lifecycle, and all business logic.
VP Audio Spaces adds LiveKit SFU for the multi-speaker experience — speakers connect via WebRTC for real-time conversation while listeners receive the same HLS audio stream that Audio Radio uses, scaled to millions via Cloudflare CDN.
The EA network strategy is woven into every decision: HLS adaptive streaming down to 32kbps means VP Audio Radio works on 2G in rural Tanzania. Video quality ladders from 1080p to 360p ensure VP Live is accessible on 3G. The entire viewer and listener experience requires only ExoPlayer or AVPlayer — the simplest possible mobile integration.
For VOD, File Thunder's VideoWheel and new AudioWheel process every recording automatically after the stream ends — creating replay content with thumbnails, watermarks, and adaptive variants, stored in nexgate-public for CDN delivery. The live platform generates permanent content with zero extra work.
NexGate VP Live & VP Audio Spaces — Architecture v1.0 QBIT SPARK | SRS · LiveKit · HLS · Ejabberd MUC · File Thunder VOD
NexGate Chat — Phase 1
Foundation & Local Experiments
NexGate / QBIT SPARK | Version 1.0 Spring Boot WebSocket · Commerce DMs · Offline Delivery · Local Experiments
Table of Contents
- Phase 1 Goals
- What Ships in Phase 1
- Architecture Overview
- The Four Wheels
- Service Design
- Data Flows
- Commerce DM Flows
- Offline Delivery & Notification Escalation
- Message Status System
- Database Schema
- Inbox Model Implementation
- Local Experiments
1. Phase 1 Goals
Phase 1 is about shipping fast and learning.
The goal is not to build the perfect infrastructure from day one. The goal is to get NexGate chat into users' hands as quickly as possible — while running local experiments in parallel that prepare for Phase 2.
Two tracks running simultaneously:
Track A — Production (ship it): Track B — Experiments (learn it):
Spring Boot WebSocket Ejabberd local Docker setup
Text chat + voice notes Spring Boot ↔ Ejabberd auth bridge
Commerce DMs (both flows) WebRTC voice call on emulators
Offline delivery + notifications Coturn TURN relay local test
Message receipts MessagePack encoding test
Isolated shop inbox
Phase 1 architecture is intentionally simpler than Phase 2. Everything designed here carries forward — the schema, the commerce logic, the notification system, the inbox model. Phase 2 only swaps the transport layer.
2. What Ships in Phase 1
Messaging Features
1:1 Personal DMs text, voice notes, media cards
Group chats up to 500 members
Broadcast channels creator → fans (one-way)
Voice notes Opus recorded, waveform rendered
Rich content cards product, custom price, event,
Bei ya pamoja, post, stream link
Commerce DM Features
Buyer initiates chat from product page → product card auto-appears
Seller attaches product WhatsApp-style from inside any conversation
Custom price offer private to buyer, public price unchanged
Proceed to checkout button in chat → redirects to checkout
Order updates in thread confirmation, shipping, delivery
Inbox Features
Isolated shop inbox separate tab per shop
Personal inbox always private, owner only
Shop inbox shared with authorized staff (Pro tier)
Message receipts sent / delivered / read ticks
Typing indicators ephemeral, Redis TTL based
Online presence shown in conversation header
Notification Features
FCM push (Android + iOS) HIGH priority, bypasses Doze mode
Textfy SMS escalation CRITICAL and IMPORTANT messages
Offline queue RabbitMQ holds messages until delivery
Catch-up banner summary on reconnect after offline
3. Architecture Overview
┌─────────────────────────────────────────────────┐
│ NexGate Mobile App │
└──────────────┬──────────────────────────────────┘
│
│ WebSocket (JSON over WS)
│ wss://chat.nexgate.com
│
┌──────────────▼──────────────────────────────────┐
│ Spring Boot Chat Gateway │
│ │
│ Manages WebSocket connections │
│ Validates JWT on connect │
│ Routes incoming frames via Redis pub/sub │
│ Pushes outgoing frames to clients │
│ Registers presence in Redis │
│ Thin — zero business logic │
└──────────────┬──────────────────────────────────┘
│
│ Redis pub/sub
│
┌──────────────▼──────────────────────────────────┐
│ Spring Boot Chat Service │
│ │
│ Message validation + persistence │
│ Conversation + inbox management │
│ Commerce context handling │
│ Receipt tracking │
│ Notification routing │
│ Offline escalation │
│ Shop inbox role enforcement │
└──────┬──────────┬──────────┬────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌───────────────────────┐
│PostgreSQL│ │ Redis │ │ RabbitMQ │
│ │ │ │ │ │
│messages │ │presence│ │ chat.offline.delivery │
│convs │ │hot msgs│ │ chat.notify.push │
│receipts │ │typing │ │ chat.notify.escalation│
│calls │ │pub/sub │ │ chat.commerce.events │
└──────────┘ └────────┘ └───────────┬───────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌─────┐ ┌───────┐ ┌────────┐
│ FCM │ │Textfy │ │ Main │
│push │ │ SMS │ │Backend │
└─────┘ └───────┘ └────────┘
Key Rule
Gateway never touches business logic
Chat Service never manages WS connections
Both communicate only via Redis pub/sub
Gateway → Redis pub/sub → Chat Service (inbound)
Chat Service → Redis pub/sub → Gateway (outbound)
4. The Four Wheels
Just as File Thunder has four processing wheels, the Phase 1 chat engine has four foundational components:
┌─────────────────────────────────────────────────┐
│ NEXGATE CHAT ENGINE │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Wheel 1 │ │ Wheel 2 │ │ Wheel 3 │ │
│ │ │ │ │ │ │ │
│ │ Netty │ │ Redis │ │ RabbitMQ │ │
│ │ WS │ │ State │ │ Queue │ │
│ └──────────┘ └──────────┘ └────────────┘ │
│ │
│ ┌────────────┐ │
│ │ Wheel 4 │ │
│ │ │ │
│ │ Textfy │ │
│ │ SMS │ │
│ └────────────┘ │
└─────────────────────────────────────────────────┘
Wheel 1 — Netty WebSocket Spring Boot uses Netty under the hood for WebSocket connections. Handles connect, disconnect, heartbeat, and frame routing. Scales to 50k+ concurrent connections per pod.
Wheel 2 — Redis State Tracks everything ephemeral and hot: online presence per user, typing indicators (5s TTL), last 50 messages per conversation (capped list), unread counts, cross-pod pub/sub routing, notification escalation timers.
Wheel 3 — RabbitMQ Queue Handles everything async: offline message delivery queue, delayed SMS escalation jobs, commerce event publishing to Main Backend, receipt acknowledgment processing.
Wheel 4 — Textfy SMS Critical and important message fallback. NexGate's own SMS platform — zero third-party cost. Swahili templates per message type. Deep links back to specific conversation. Full delivery audit log.
5. Service Design
Chat Gateway Responsibilities
On WebSocket connect:
Validate JWT token
Register user presence in Redis:
presence:{userId} → TTL 30s (refreshed by heartbeat)
Drain offline queue via RabbitMQ trigger
On frame received (inbound):
Validate session
Publish to Redis: chat:inbound
ACK client immediately with temp_id
On Redis pub/sub message (outbound):
Find client connection for recipient
Push WS frame to device
On WebSocket disconnect:
Remove presence from Redis
Update last_seen_at via RabbitMQ event
Chat Service Responsibilities
On inbound message event (from Redis):
Validate sender is conversation member
Check conversation not blocked/archived
Resolve message level (NORMAL / IMPORTANT / CRITICAL)
Write to PostgreSQL
Write to Redis hot cache
Fan-out to recipients:
Online → Redis pub/sub → Gateway → WS push
Offline → RabbitMQ queue + FCM + escalation timer
On commerce message:
Attach product snapshot (frozen at send time)
Emit commerce event to Main Backend via RabbitMQ
On receipt ack:
Update message_receipts in PostgreSQL
Notify sender (tick update) via Redis pub/sub
On presence event (user online):
Drain RabbitMQ offline queue for user
Send catch-up summary if messages missed
RabbitMQ Exchange Design
Exchange: nexgate.chat (topic)
Routing Key Flow
──────────────────────────────────────────────────────
chat.message.inbound Gateway → Chat Service
chat.message.outbound Chat Service → Gateway
chat.notify.push Chat Service → FCM Worker
chat.notify.escalation Chat Service → SMS Worker
chat.receipts.delivered Gateway → Chat Service
chat.receipts.read App → Chat Service
chat.commerce.initiated Chat Service → Main Backend
chat.commerce.price.attached Chat Service → Main Backend
chat.presence.online Gateway → Chat Service
chat.presence.offline Gateway → Chat Service
6. Data Flows
Text Message — Full Sequence
[Client A] [Gateway] [Chat Service] [Client B]
type "Habari"
tap Send
show pending ⏳
│
│ WS frame:
│ { type: MSG_SEND
│ temp_id: "abc"
│ conv_id: "conv-123"
│ body: "Habari" }
│──────────────────▶│
│ publish Redis:
│ chat:inbound
│ ACK: { temp_id: "abc" }
│◀──────────────────────
│ single tick ✓ ◀───│
│ ...................▶ │ consume Redis
│ validate sender
│ write PostgreSQL
│ write Redis cache
│ check B online? ✅
│ publish outbound
│◀ .................. │
│ push WS to B
│─────────────────────────────────────▶│
│ │ show message
│◀ ......................... DELIVERED ack ──│
│ write receipt
│ notify A:
│ double tick ✓✓ ◀──│◀ ................. │
.
│ [B reads conv] .
│◀─────── READ ack ────────────────────│
│ write receipt
│ blue tick ✓✓ ◀────│◀ ................. │
─── solid line = WebSocket (real-time)
... dotted line = Redis pub/sub (async, cross-pod)
Voice Note — Send Flow
User records audio (Opus codec, 16kHz)
│
▼ Upload to File Thunder
GET /media/upload-request (DM_ATTACHMENT context)
→ presigned MinIO URL returned
Upload .ogg file directly to MinIO
POST /media/confirm { fileId }
│
▼ File Thunder processes:
ClamAV scan
Waveform extraction via FFmpeg
→ amplitude array (50 values for UI bars)
→ waveform.webp thumbnail
Store in nexgate-private/messages/{convId}/{fileId}/
│
▼ File Thunder returns: { fileId, waveformData[], durationSeconds }
│
App sends WS frame:
{ type: MSG_SEND
content_type: VOICE_NOTE
media_ref: fileId
duration_seconds: 15
waveform: [0.2, 0.8, 0.6, ...] } ← embedded for instant UI
│
▼ Same path as text message
Recipient receives message with waveform data
Waveform bars render instantly (no extra request)
Tap play → GET /chat/media/{fileId}/url
→ signed URL (5 min TTL)
→ stream audio progressively
Offline Delivery Flow
Message arrives for offline user B
│
Chat Service:
Check Redis: B online? ❌
│
├──▶ RabbitMQ: chat.offline.delivery
│ { messageId, recipientId, level }
│
├──▶ FCM HIGH priority push
│ { type: NEW_MESSAGE
│ convId, senderName, preview }
│
└──▶ RabbitMQ: chat.notify.escalation
delay: CRITICAL=0min, IMPORTANT=10min
.
. (time passes)
.
Escalation consumer wakes:
Check Redis: B online now? ✅ → cancel, done
❌ → send Textfy SMS
│
Textfy SMS:
"NexGate: Ujumbe mpya kutoka Juma.
nexgate.app/chat/conv-123"
│
User turns on WiFi / opens app:
WS reconnects → Gateway registers presence
Chat Service drains RabbitMQ queue (priority order)
CRITICAL first → IMPORTANT → NORMAL
Show catch-up banner:
"Umekosa: 2 maagizo, 5 ujumbe"
DELIVERED receipts fire for all drained messages
7. Commerce DM Flows
Flow 1 — Buyer Initiates from Product Page
Buyer on product page
│ taps "Chat with Seller"
▼
POST /chat/commerce/initiate
{ productId, shopId }
│
Chat Service:
Find or create DM conversation
conversation.type = COMMERCE
conversation.owner_type = SHOP
conversation.owner_id = shopId
│
Fetch product snapshot from Main Backend:
{ name, price, images[0], stock, shopName }
Snapshot frozen at this exact moment ✅
Public price change later → does not affect this card
│
Create first message automatically:
type: PRODUCT_CARD
context_type: PRODUCT
context_ref_id: productId
snapshot_json: { frozen product data }
│
▼ Seller receives in shop inbox tab
┌─────────────────────────────────┐
│ 📦 Samsung A15 │
│ TZS 450,000 │
│ In stock: 12 units │
│ TechStore │
│ │
│ [Reply] [View Product] │
└─────────────────────────────────┘
│
Negotiation happens in thread
│ Agreement reached
▼
Seller attaches custom price offer:
┌─────────────────────────────────┐
│ 💰 Special Price Offer │
│ Samsung A15 │
│ TZS 400,000 (was 450,000) │
│ Valid for you only │
│ │
│ Quantity: [─ 1 +] │
│ [Proceed to Checkout →] │
└─────────────────────────────────┘
│
Buyer taps Proceed → redirected to checkout
Checkout outside inbox — at negotiated price
Public product price: TZS 450,000 unchanged ✅
│
Order placed → confirmation back in thread:
┌─────────────────────────────────┐
│ ✅ Order Confirmed │
│ Order #ORD-789 │
│ Samsung A15 × 1 │
│ TZS 400,000 paid │
└─────────────────────────────────┘
Flow 2 — Seller Attaches from Inside Chat
Seller inside any conversation
│ taps attach (+)
▼
┌─────────────────────────────────┐
│ Attach │
│ │
│ 📷 Image │
│ 🎵 Voice Note │
│ 📄 File │
│ 🏪 From My Shop ◀──── this one │
│ 📅 Event │
│ 👥 Group Purchase │
└─────────────────────────────────┘
│ taps "From My Shop"
▼
Seller browses their shop products
Picks product
Sets custom price for this buyer (optional)
│
▼ Sends as card in chat
┌─────────────────────────────────┐
│ 🏪 TechStore Offer │
│ Samsung A15 │
│ TZS 400,000 │
│ │
│ Quantity: [─ 1 +] │
│ [Proceed to Checkout →] │
└─────────────────────────────────┘
│
Buyer taps Proceed → checkout outside inbox
Commerce Message Types
message.type values for commerce:
PRODUCT_CARD product shared in chat
CUSTOM_PRICE_OFFER seller's private price for this buyer
EVENT_CARD event shared in chat
GROUP_PURCHASE_CARD Bei ya pamoja shared in chat
POST_CARD VP Feed post shared in chat
ORDER_CONFIRMATION system message after order placed
ORDER_STATUS_UPDATE system message for shipping/delivery
PAYMENT_CONFIRMATION system message after payment
8. Offline Delivery & Notification Escalation
Notification Levels
Message level resolved by Chat Service before fan-out:
CRITICAL:
Order placed / payment received / payment failed
Order status changed / delivery update
→ FCM HIGH + Textfy SMS simultaneously (no waiting)
IMPORTANT:
Commerce DM from buyer
Custom price offer received
Bei ya pamoja threshold reached
→ FCM HIGH immediately
→ Textfy SMS after 10 minutes if no delivery ack
NORMAL:
Regular DMs, group messages
Social cards, post shares
→ FCM HIGH only
→ No SMS escalation
Textfy SMS Templates
Order notification (Swahili):
"NexGate: Agizo jipya kutoka [Buyer]!
Kiasi: TZS [amount]. Kagua: nexgate.app/orders/[id]"
Payment received:
"NexGate: Malipo ya TZS [amount]
yamepokelewa kutoka [Buyer].
nexgate.app/wallet"
Commerce DM:
"NexGate: [Buyer] anakuuliza kuhusu
[Product]. Jibu: nexgate.app/chat/[convId]"
Bei ya pamoja:
"NexGate: Watu [n]/[target] wamejiunga!
nexgate.app/group-buy/[id]"
Notification Delivery Log
All notifications tracked for audit — critical for order disputes:
notification_log
─────────────────────────────────────
id UUID
user_id UUID
message_id UUID
level ENUM (NORMAL/IMPORTANT/CRITICAL)
fcm_status ENUM (SENT/DELIVERED/FAILED)
sms_status ENUM (SENT/DELIVERED/FAILED/SKIPPED)
sms_provider TEXT
sent_at TIMESTAMPTZ
delivered_at TIMESTAMPTZ
opened_at TIMESTAMPTZ
9. Message Status System
Client shows status via tick indicators:
⏳ Pending message on device, not yet sent
(no connection)
✓ Sent server received and persisted
Gateway ACK returned with temp_id
✓✓ Delivered recipient device received
WS delivery ack received by Chat Service
✓✓ Read recipient opened the conversation
READ event sent by recipient app
(blue ticks)
Flow:
[Send] → pending ⏳
[Gateway ACK] → sent ✓
[Recipient WS ack] → delivered ✓✓
[Recipient opens conv] → read ✓✓ (blue)
Typing Indicators
Ephemeral — never persisted to PostgreSQL
User starts typing:
App sends: { type: TYPING_START, convId }
Chat Service sets Redis key:
typing:{convId}:{userId} → TTL 5 seconds
Redis pub/sub notifies conversation members
Recipients see "Juma anaandika..."
User stops typing (or TTL expires):
Key auto-expires after 5 seconds
Chat Service notifies: typing stopped
Indicator disappears
10. Database Schema
conversations
conversations
─────────────────────────────────────────────
id UUID PK
type ENUM DM / GROUP / BROADCAST / COMMERCE
owner_type ENUM USER / SHOP
owner_id UUID userId or shopId
title TEXT for groups and broadcast channels
avatar_file_id UUID File Thunder fileId
status ENUM ACTIVE / ARCHIVED / BLOCKED
created_by UUID userId
created_at TIMESTAMPTZ
conversation_members
conversation_members
─────────────────────────────────────────────
conversation_id UUID FK → conversations
user_id UUID
role ENUM MEMBER / ADMIN / OWNER
joined_at TIMESTAMPTZ
last_read_at TIMESTAMPTZ
last_read_seq BIGINT sequence ID (for gap detection)
is_muted BOOLEAN
muted_until TIMESTAMPTZ
messages
messages
─────────────────────────────────────────────
id UUID PK
conversation_id UUID FK → conversations
sender_id UUID
seq BIGINT monotonic per conversation
type ENUM TEXT / IMAGE / VIDEO / VOICE_NOTE /
FILE / PRODUCT_CARD / CUSTOM_PRICE_OFFER /
EVENT_CARD / GROUP_PURCHASE_CARD /
POST_CARD / ORDER_CONFIRMATION /
ORDER_STATUS_UPDATE / PAYMENT_CONFIRMATION /
SYSTEM
body TEXT
media_ref UUID File Thunder fileId
context_type ENUM PRODUCT / ORDER / PAYMENT / EVENT / GROUP_PURCHASE
context_ref_id UUID ref to relevant entity
snapshot_json JSONB frozen context data at send time
reply_to_id UUID FK → messages (thread replies)
status ENUM SENT / DELIVERED / READ / FAILED
level ENUM NORMAL / IMPORTANT / CRITICAL
created_at TIMESTAMPTZ
edited_at TIMESTAMPTZ
deleted_at TIMESTAMPTZ
message_receipts
message_receipts
─────────────────────────────────────────────
message_id UUID FK → messages
user_id UUID
status ENUM DELIVERED / READ
device_id TEXT
timestamp TIMESTAMPTZ
calls
calls
─────────────────────────────────────────────
call_id UUID PK
caller_id UUID
receiver_id UUID
conversation_id UUID FK → conversations
type ENUM VOICE / VIDEO
status ENUM RINGING / CONNECTED / COMPLETED /
MISSED / DECLINED / FAILED
started_at TIMESTAMPTZ
answered_at TIMESTAMPTZ
ended_at TIMESTAMPTZ
duration_seconds INT
relay_used BOOLEAN was TURN relay used?
end_reason ENUM NORMAL / NETWORK / TIMEOUT / DECLINED
shop_conversation_access
shop_conversation_access
─────────────────────────────────────────────
shop_id UUID
user_id UUID staff member
role ENUM MANAGER / SUPPORT_AGENT / READ_ONLY
granted_by UUID owner userId
granted_at TIMESTAMPTZ
revoked_at TIMESTAMPTZ
11. Inbox Model Implementation
Conversation Ownership
Personal DM:
owner_type = USER
owner_id = usr-kibuti
Access: only usr-kibuti
Shop DM:
owner_type = SHOP
owner_id = shop-techstore
Access: anyone with role in shop_conversation_access
for shop-techstore
Tab Resolution (App Side)
App fetches inbox tabs on load:
GET /chat/inbox/tabs
Response:
[
{ type: PERSONAL, label: "Personal", unread: 3 },
{ type: SHOP, shopId: "shop-techstore",
label: "TechStore", unread: 12 },
{ type: SHOP, shopId: "shop-clothinghub",
label: "ClothingHub", unread: 0 }
]
Chat Service resolves tabs by:
1. User's own personal conversations
2. All shops where user has role in
shop_conversation_access
Access Control Check
Every request to open a shop conversation:
Does requesting user own the shop?
YES → allow
NO → check shop_conversation_access:
user_id = requester
shop_id = conversation.owner_id
revoked_at IS NULL
Found? → allow with their role
Not found? → 403 Forbidden
12. Local Experiments
These run in parallel with Phase 1 production work. All throwaway code — not NexGate quality. Goal is understanding, not production output.
Experiment 1 — Ejabberd Local Docker
Goal: get Ejabberd running, send one message
Steps:
docker run -d --name ejabberd \
-p 5222:5222 \
-p 5280:5280 \
-p 5285:5285 \
ghcr.io/processone/ejabberd
Open: http://localhost:5280/admin
Create two test users: alice, bob
Install Conversations app on Android
Connect to localhost:5222 as alice
Send message to bob
See it arrive
What you learn:
How Ejabberd config works
What XMPP stanzas look like in logs
How the dashboard shows connections
What errors look like and how to fix them
Success: message delivered between two test users
XMPP Stanzas — What They Look Like
When Alice sends "Habari" to Bob, this travels over the wire:
<!-- Alice sends message to Bob -->
<message from="alice@localhost"
to="bob@localhost"
type="chat"
id="msg-001">
<body>Habari</body>
<active xmlns="http://jabber.org/protocol/chatstates"/>
</message>
<!-- Bob's typing indicator back -->
<message from="bob@localhost"
to="alice@localhost"
type="chat">
<composing xmlns="http://jabber.org/protocol/chatstates"/>
</message>
<!-- Presence — Alice comes online -->
<presence from="alice@localhost/android">
<show>available</show>
<status>Ninafanya kazi</status>
</presence>
<!-- Message delivery receipt (XEP-0184) -->
<message from="bob@localhost" to="alice@localhost">
<received xmlns="urn:xmpp:receipts"
id="msg-001"/>
</message>
These stanzas are what Ejabberd routes. In Phase 2, NexGate wraps its own data inside custom XMPP stanzas.
Experiment 2 — Spring Boot Auth Bridge
Goal: Ejabberd calls Spring Boot to validate users
Setup:
Simple Spring Boot app (H2 in-memory DB)
One endpoint: POST /internal/ejabberd/auth
receives: { username, token }
returns: 200 (allow) or 401 (deny)
ejabberd.yml:
auth_method: http
auth_opts:
url: "http://host.docker.internal:8080/internal/ejabberd/auth"
Test:
Connect Conversations app to Ejabberd
Ejabberd calls Spring Boot for auth
Spring Boot validates → returns 200
Connection allowed
What you learn:
Auth flow between Ejabberd and Spring Boot
How fast Spring Boot must respond (< 200ms)
What happens when auth fails
How to structure the internal endpoint
Success: Ejabberd rejects unknown users,
allows users Spring Boot approves
Experiment 3 — WebRTC Voice Call on Emulators
Goal: voice call between two Android emulators
Setup:
Two Android Studio emulators running
Simple Android app — just two buttons:
[Call] and [Answer]
WebRTC library: io.getstream:stream-webrtc-android
No UI — just logcat output
What to test:
Create PeerConnection on both
Exchange SDP offer/answer manually (copy-paste between logs)
Exchange ICE candidates
Hear audio between emulators
What you learn:
How WebRTC PeerConnection works in practice
What SDP looks like
What ICE candidates look like
How long negotiation actually takes
What errors appear and how to fix them
Success: audio heard between two emulators
SDP offer example (what WebRTC generates):
v=0
o=- 123456 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
m=audio 9 UDP/TLS/RTP/SAVPF 111
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:someRandomString
a=ice-pwd:anotherRandomString
a=fingerprint:sha-256 AA:BB:CC:...
a=rtpmap:111 opus/48000/2 ← Opus codec negotiated here
a=fmtp:111 minptime=10;useinbandfec=1
Experiment 4 — Coturn TURN Relay
Goal: force audio through TURN relay, confirm it works
Setup:
docker run -d --network=host coturn/coturn \
-n --log-file=stdout \
--min-port=49152 --max-port=65535 \
--lt-cred-mech \
--user=test:password123 \
--realm=nexgate.com
Test:
Disable P2P in WebRTC config (force TURN only)
Run voice call experiment
Confirm audio still flows through relay
What you learn:
How Coturn logs relay connections
Bandwidth used per call (check with iftop)
How to generate HMAC credentials (not plain text)
What happens when Coturn is unavailable
Success: audio heard between emulators via TURN relay
Coturn logs show relay traffic
Experiment 5 — MessagePack Encoding
Goal: compare JSON vs MessagePack on same message
Simple Spring Boot test:
Serialize same chat message object
Once as JSON
Once as MessagePack
Measure:
Byte size comparison
Serialization speed
Deserialization speed
Expected result:
JSON: ~180-200 bytes per message
MessagePack: ~60-70 bytes per message
~65% size reduction confirmed
Success: numbers prove EA bandwidth saving is real
Experiment Success Criteria Summary
Experiment 1 — Ejabberd local:
✅ Message delivered between two users
✅ Dashboard shows live connections
Experiment 2 — Auth bridge:
✅ Ejabberd rejects unknown tokens
✅ Ejabberd allows Spring Boot approved users
Experiment 3 — WebRTC emulators:
✅ Audio heard between two emulators
✅ SDP and ICE flow understood
Experiment 4 — Coturn relay:
✅ Audio heard via forced TURN relay
✅ Bandwidth per call measured
Experiment 5 — MessagePack:
✅ Size reduction confirmed
✅ Serialization speed measured
All five done → ready to write Phase 2 doc
→ ready to build NexGate chat Phase 2
Summary
Phase 1 is the foundation. It ships real features — text chat, voice notes, commerce DMs, offline delivery, the isolated shop inbox — using a clean Spring Boot WebSocket architecture that runs on existing infrastructure.
Everything designed here carries directly into Phase 2. The schema stays. The commerce logic stays. The notification system stays. The inbox model stays. Only the transport layer swaps — Spring Boot WS gateway out, Ejabberd in.
The five local experiments running in parallel are not wasted time. They are the insurance policy that makes Phase 2 a confident execution rather than a risky exploration. Every surprise Ejabberd, WebRTC, and Coturn have in store — you want to find them in a throwaway experiment, not in your production chat system.
NexGate Chat Platform — Phase 1: Foundation & Local Experiments v1.0 QBIT SPARK | Spring Boot WebSocket · Commerce DMs · Offline Delivery
NexGate Messaging — Product Requirements & Feature Flows
NexGate / QBIT SPARK | Version 1.0 What the messaging platform does — rules, flows, permissions, scenarios
Table of Contents
- Document Purpose
- User Identity & Discovery
- Contact Sync
- Inbox Model
- Messaging Permissions
- Call Permissions
- 1:1 Messaging Flows
- Group Chat Flows
- Commerce DM Flows
- Offer Session Flows
- Shareable Content
- Message Interactions
- Voice & Video Call Flows
- Group Call Flows
- Offline & Notification Flows
- Privacy & Safety
- Shop Inbox & Staff Access
- Notification Settings
- Edge Cases & Scenarios
1. Document Purpose
This document defines WHAT NexGate messaging does — the rules, user flows, permissions, and scenarios for every messaging feature.
It is the product reference document. It does not describe HOW features are built technically. For technical architecture see:
- Doc 3: Phase 2 Production Architecture
- Doc 5: Private Chat & Calls Deep Dive
Audience: Product, mobile developers, QA, design.
2. User Identity & Discovery
Identity on NexGate
Every NexGate user has:
@username unique, chosen at registration
shown publicly on VP Feed
used for messaging: @kibuti
Display name full name shown in conversations
can differ from username
Phone number used for registration + auth
NOT shown publicly by default
discoverable only with user permission
NexGate ID internal UUID
never shown to users
How Users Find Each Other
Discovery mechanism Requires contact sync?
──────────────────────────────────────────────────────
VP Feed follow ❌ No
VP Shop (buyer/seller) ❌ No
Username search (@kibuti) ❌ No
QR code scan ❌ No
Shareable profile link ❌ No
Group chat membership ❌ No
Phone number search ❌ No (type manually)
Contact sync (phonebook) ✅ Yes (optional)
NexGate has multiple discovery paths. Contact sync is one option — not the only one.
Discoverability Settings
Two independent settings:
"Sync my contacts"
Find NexGate users in my phonebook
Default: OFF (user must opt in)
"Let others find me via phone number"
If someone has my number → can find me
Default: ON (can turn off)
These are INDEPENDENT:
I can sync contacts without being findable
I can be findable without syncing contacts
3. Contact Sync
Rules
✅ Optional — never mandatory
✅ Can be enabled/disabled anytime
✅ Can delete synced data anytime from settings
✅ Only finds users who have discoverability ON
❌ Not required to use messaging
❌ Not required for commerce DMs
❌ Contacts data never shared with third parties
❌ Non-NexGate contacts never stored on server
Privacy-Preserving Implementation
How it works when user accepts:
Step 1: Phone numbers hashed ON DEVICE
sha256(+255712345678) → hash
Actual numbers never leave the device
Step 2: Hashes uploaded to NexGate server
Not real phone numbers
Server cannot reverse hashes
Step 3: Server matches hashes
Against registered users who have
"Let others find me via phone number" = ON
Returns: which hashes are NexGate users
Step 4: App shows matched users
"3 of your contacts are on NexGate"
[Connect] buttons shown
Non-NexGate contacts:
Their hashes deleted from server immediately
Never stored
Never tracked
First Launch Prompt
Shown once at first launch after onboarding:
┌──────────────────────────────────────────────┐
│ 📱 Pata Marafiki Wako NexGate │
│ │
│ Ruhusu NexGate kutumia contacts zako │
│ kupata marafiki wanaotumia NexGate tayari │
│ │
│ ✓ Contacts zako hazitashirikiwa na mtu │
│ ✓ Unaweza kufuta ruhusa wakati wowote │
│ ✓ Nambari za simu zinabadilishwa kuwa │
│ msimbo kabla ya kupakiwa │
│ │
│ [Ruhusu Contacts] [Sasa Hivi Sio] │
└──────────────────────────────────────────────┘
If declined:
App works fully ✅
Remind once after 7 days
Never prompt again after second decline
Always accessible in Settings
Sync Scenarios
Scenario 1 — User accepts sync:
Hashes uploaded
Matched users shown
User can message/follow matched users
Sync runs again when contacts change
Scenario 2 — User declines sync:
App works fully
Can still find people via username/QR/VP Feed
No contacts uploaded
Scenario 3 — User accepts then disables:
Settings → Privacy → Contact Sync → OFF
All hash data deleted from server
Matched contacts remain as connections
(connections not removed — only future sync stops)
Scenario 4 — User wants to delete contact data:
Settings → Privacy → Delete Contacts Data
All hashes deleted from server immediately
Confirmation shown: "Contacts data imefutwa"
Scenario 5 — Contact not on NexGate:
Their hash deleted from server
User sees option: "Invite Juma to NexGate"
Tap → share NexGate invite link via SMS/WhatsApp
4. Inbox Model
Two Separate Inboxes
Every NexGate user has TWO completely separate inboxes:
Personal Inbox:
1:1 DMs with other users (personal)
Group chats (personal)
Friend conversations
Private — only the account owner sees this
NEVER accessible to shop staff
Shop Inbox (one per shop owned):
All customer conversations for that shop
Commerce DMs with buyers
Shared with authorized shop staff
Completely separate from personal inbox
Customer sees shop identity — not personal name
Inbox Tabs
User with 2 shops sees:
┌─────────────────────────────────────────────┐
│ 💬 Inbox │
│ │
│ [Personal ●3] [TechStore ●12] [ClothingHub]│
│ │
│ Personal tab: │
│ Regular DMs + group chats │
│ Private — owner only │
│ │
│ TechStore tab: │
│ Customer commerce conversations │
│ Shared with assigned staff │
│ │
│ ClothingHub tab: │
│ Separate shop conversations │
│ Different staff assigned │
└─────────────────────────────────────────────┘
Staff member (Amina assigned to TechStore):
┌─────────────────────────────────────────────┐
│ [Personal] [TechStore ●12] │
│ │
│ ClothingHub: NOT visible (no access) │
│ Owner personal: NEVER visible │
└─────────────────────────────────────────────┘
Conversation Types
Type Owner Participants
────────────────────────────────────────────────────
DM USER 2 users
GROUP USER 3-500 users
COMMERCE SHOP 1 shop + 1 buyer
BROADCAST USER or SHOP 1 sender + N followers
5. Messaging Permissions
Who Can Message Who
Relationship Can message? How
────────────────────────────────────────────────────────────
Saved contacts (phone) ✅ Always Direct
Mutual followers (VP Feed) ✅ Always Direct
Commerce relationship ✅ Always Direct (shop inbox)
Group chat members ✅ Always Via group only
Strangers (no relationship) ⚠️ Request Message request first
Blocked users ❌ Never Blocked
Message Request System
Stranger (no relationship) wants to message you:
They can send ONE message request:
┌─────────────────────────────────────────┐
│ 📩 Maombi ya Ujumbe │
│ │
│ Juma Mwangi anataka kukutumia ujumbe │
│ @juma_mwangi · 245 wafuasi │
│ │
│ "Habari, nilikuona kwenye VP Shop..." │
│ │
│ [Kubali] [Kataa] [Zuia] │
└─────────────────────────────────────────┘
If Kubali (Accept):
Full conversation opens
Juma can now message freely
Can call after conversation established
If Kataa (Decline):
Juma cannot send another request
One attempt only per account
Juma sees: "Ujumbe haukukubaliwa"
If Zuia (Block):
Juma blocked immediately
Cannot contact again in any way
Juma sees: nothing (as if you don't exist)
If Ignored (no response):
Request stays in request folder
Juma cannot send more messages
No call attempt allowed
Request expires after 30 days
Message Request Limits
Per account per day:
Max 10 message requests sent
Prevents spam
Per account to same person:
1 request only (ever)
If declined → cannot request again
Request folder:
Separate from main inbox
User checks it voluntarily
No notification for low-priority requesters
(unless they have mutual connections)
6. Call Permissions
Permission Tiers
Tier Voice call Video call
────────────────────────────────────────────────────────
Saved contacts ✅ ✅
Mutual followers ✅ ✅
Commerce relationship ✅ (shop) ✅ (shop)
Active conversation partner ✅ ✅
(message request accepted)
Group chat member ✅ (group) ✅ (group)
Strangers ❌ ❌
Blocked users ❌ ❌
Commerce Call Rules
Buyer can call:
The SHOP JID — not the owner personally
techstore@shops.nexgate.com
Any available staff answers as TechStore
Seller (shop) can call:
Buyers they have active commerce DM with
Call goes from shop JID to buyer
Buyer sees "TechStore" calling — not a personal name
Protecting personal contact:
Owner's personal phone/JID never revealed
Commerce calls always through shop identity
Staff cannot reveal personal number to customers
Anti-Harassment Call Rules
Call rate limiting:
Max 3 unanswered calls to same person in 24 hours
4th attempt → blocked for 24 hours automatically
Resets daily
Silence unknown callers:
Setting: "Pumzisha simu kutoka kwa wasio contacts"
Calls from non-contacts ring silently
Notification appears after — not during
First call warning:
First EVER call from this person:
┌───────────────────────────────────────┐
│ 📞 Simu ya kwanza kutoka │
│ Juma Mwangi (@juma_mwangi) │
│ │
│ [Jibu] [Kataa] [Zuia] │
└───────────────────────────────────────┘
Quick block during call:
Report button visible during call
One tap → ends call + reports + blocks
Privacy Settings for Calls
Who can call me:
○ Mutual followers + contacts (default)
○ My contacts only
○ Nobody
User can change anytime in Settings → Privacy → Calls
Call Permission Check Flow
Kibuti tries to call Juma:
│
Spring Boot permission check:
Is Juma blocked by Kibuti? ❌ → reject immediately
Is Kibuti blocked by Juma? ❌ → reject immediately
Are they saved contacts? ✅ → allow
Are they mutual followers? ✅ → allow
Active commerce relationship? ✅ → allow
Active accepted conversation? ✅ → allow
Does Juma's privacy setting allow? ✅ → allow
None of above → ❌ reject
│
If rejected:
App shows: "Huwezi kupiga simu mtu huyu"
No Jingle stanza sent
No TURN credentials issued
If allowed:
TURN credentials generated
Jingle session-initiate sent via Ejabberd
Juma's device rings
7. 1:1 Messaging Flows
Send Text Message
Happy path:
User types message
Taps send
Message shows: pending ⏳
Server receives → single tick ✓
Recipient receives → double tick ✓✓
Recipient reads → blue tick ✓✓
Offline path:
User taps send
Single tick ✓ (server received)
Recipient offline → queued
FCM push fires to recipient device
Recipient comes online → message delivered
Double tick ✓✓ appears
Send Voice Note
User holds mic button:
"Kibuti anarekodia..." shown to recipient (if online)
Recording in progress
Timer shown: 0:01, 0:02...
User releases:
Voice note sent
Waveform shown in thread
Duration shown: 0:15
User swipes up while holding:
Lock mode — records without holding
Release sends
User swipes left while holding:
Cancel recording — nothing sent
Limits:
Basic users: max 2 minutes
Pro users: max 15 minutes
File size: max 16MB
Send Media
Images:
Max 10 images per message
Formats: JPG, PNG, WebP, HEIC
Max 16MB per image
Auto-compressed for EA networks
Videos:
Max 1 video per message
Max 2 minutes (short video)
Max 64MB
Auto-transcoded to HLS by File Thunder
Files/Documents:
Max 100MB per file
Formats: PDF, DOCX, XLSX, ZIP, etc
ClamAV scanned before delivery
Available for 30 days then archived
8. Group Chat Flows
Create Group
User creates group:
Tap new group
Add members (min 2, max 500)
Set group name (required)
Set group photo (optional)
Tap create
Group created:
Members notified: "Kibuti amekuongeza kwenye kikundi"
All members see welcome message
Creator is automatically OWNER
Group Roles & Permissions
Role Add members Remove Send msg Delete msg Change info
──────────────────────────────────────────────────────────────────
OWNER ✅ ✅ ✅ Any msg ✅ ✅
ADMIN ✅ ✅ ✅ Any msg ✅ ✅
MEMBER ❌ ❌ ✅ Own only ✅ ❌
Group Size Limits
Free group: max 50 members
Pro group: max 500 members
At 500 members:
Consider using Broadcast Channel instead
Broadcast = one way (better for large audiences)
Group = two way (better for community)
Leave / Remove
Member leaves:
"Juma ameacha kikundi" shown in thread
No longer receives messages
Admin removes member:
"Amina ameondolewa na Kibuti" shown
Member notified: "Umeondolewa"
Deleted group (owner only):
All members removed
All messages deleted (for everyone)
Cannot be undone
9. Commerce DM Flows
Flow 1 — Buyer Initiates from Product Page
Step 1: Buyer on product page
Sees: [Chat na Muuzaji] button
Tap button
Step 2: Conversation opens
New DM opens OR existing conversation if already had one
Conversation type: COMMERCE
Conversation owner: SHOP (not personal)
Product card auto-appears:
┌──────────────────────────────────────┐
│ 📦 Samsung A15 │
│ TZS 450,000 │
│ Inapatikana: Vipande 12 │
│ TechStore │
│ [Jibu] [Angalia Bidhaa] │
└──────────────────────────────────────┘
NOTE: Price in card is frozen at this moment
Public price changes later → does NOT affect this card
Step 3: Negotiation
Buyer and seller exchange text messages
Normal conversation — no restrictions on text
Step 4: Agreement reached
Seller taps attach (+) → From My Shop
Selects product
Sets custom price for THIS buyer ONLY
Public product price: unchanged ✅
Sends price offer card
Step 5: Buyer receives offer
┌──────────────────────────────────────┐
│ 💰 Bei Maalum Kwako │
│ Samsung A15 │
│ ~~TZS 450,000~~ │
│ TZS 400,000 (-TZS 50,000) │
│ Inaisha: dakika 28 │
│ Idadi: [─ 1 +] │
│ [Kataa] [Endelea Kulipa →] │
└──────────────────────────────────────┘
Step 6: Buyer taps "Endelea Kulipa"
Redirected to checkout flow (OUTSIDE inbox)
Checkout at custom offer price
Quantity confirmed in checkout
Step 7: Order placed
Confirmation message appears in same thread:
┌──────────────────────────────────────┐
│ ✅ Agizo Limethibitishwa │
│ Ord #ORD-XYZ-789 │
│ Samsung A15 × 1 │
│ TZS 400,000 imelipwa │
│ [Fuatilia Agizo] │
└──────────────────────────────────────┘
Flow 2 — Seller Attaches from Inside Any Chat
Step 1: Seller inside any DM conversation
Taps attach (+) button
Menu appears:
📷 Picha
🎵 Sauti
📄 Faili
🏪 Kutoka Dukani ← this one
📅 Tukio
👥 Ununuzi wa Pamoja
Step 2: Seller taps "Kutoka Dukani"
Their shop product list opens
Seller browses and selects product
Sets custom price (optional)
Sets quantity limit (optional)
Adds note (optional): "Bei hii ni leo tu"
Taps Send
Step 3: Buyer receives offer card
Same offer card UI as Flow 1
Same checkout redirect
Same order confirmation in thread
Commerce DM Rules
✅ Buyer can initiate from any product page
✅ Seller can attach from inside any conversation
✅ Custom price is private to this buyer only
✅ Public product price never changes
✅ Checkout always happens outside inbox
✅ Order confirmation appears in thread
✅ Multiple offers allowed in one conversation
❌ Buyer cannot request specific price (only negotiate via text)
❌ Offer cannot be edited after sending (send new one instead)
❌ Offer cannot be forwarded to other conversations
❌ Order confirmation cannot be deleted or edited
10. Offer Session Flows
Offer Lifecycle
PENDING → offer sent, waiting for buyer response
│
├──▶ DECLINED buyer tapped "Kataa"
│ seller notified
│ seller can send new offer
│
├──▶ EXPIRED timer ran out
│ both parties notified in thread
│ seller can send new offer
│
▼
ACCEPTED → buyer tapped "Endelea Kulipa"
│ buyer enters checkout
▼
CHECKOUT → buyer in payment flow
│
├──▶ ABANDONED buyer left checkout without paying
│ offer returns to PENDING? NO
│ offer marked ABANDONED
│ seller must send new offer
│
▼
COMPLETED → order placed successfully
│ cannot be reversed here (order system handles)
▼
CANCELLED → order cancelled (handled by order system)
offer marked CANCELLED
Multiple Offers — Same Conversation
Seller can send multiple offers:
Each is independent session
Previous offers remain in thread with their status
Thread shows history:
┌──────────────────────────────────────┐
│ 💰 Bei Maalum: TZS 430,000 │
│ ❌ Ilikataliwa │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ 💰 Bei Maalum: TZS 410,000 │
│ ⏰ Imeisha muda │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ 💰 Bei Maalum: TZS 400,000 │
│ ✅ Imekubaliwa │
└──────────────────────────────────────┘
Offer Expiry Scenarios
Scenario 1 — Expires while buyer is reading:
Offer card shows live countdown timer
Timer hits 0:00
Card updates: "Imeisha muda"
Proceed button disabled automatically
Buyer sees: [Omba Bei Mpya] button
Scenario 2 — Expires while buyer in checkout:
Buyer was on checkout page when offer expired
Checkout validates offer at payment time
If expired: payment rejected
Buyer returned to conversation
"Bei yako imeisha muda. Omba bei mpya"
Scenario 3 — Seller sends same price again:
Allowed — new offer session created
New 30-minute timer
Both offers visible in thread (old + new)
Scenario 4 — Buyer tries to use expired offer link:
Deep link from notification opens expired offer
Shows: "Bei hii imeisha muda"
[Rudi kwa Mazungumzo] button
Price Security Rules
✅ Offer price stored server-side only
✅ Checkout validates price from server (not client)
✅ Client cannot manipulate price
✅ Offer is single-use (cannot complete twice)
✅ Offer belongs to specific buyer (others cannot use)
❌ Seller cannot change price after offer sent
(must send new offer)
❌ Buyer cannot change price
❌ Public product price never affected
11. Shareable Content
What Can Be Shared Into Any DM or Group
Content Who can share Restrictions
────────────────────────────────────────────────────────────
Text message Anyone None
Voice note Anyone Max 2 min (basic)
Image/Video Anyone Size limits
Product card Anyone Shows public price
Custom price offer Seller only Private — NOT forwardable
Event card Anyone None
Bei ya pamoja card Anyone None
VP Feed post Anyone None
VP Live stream Anyone None
Audio Space Anyone None
File/Document Anyone Max 100MB
Forwarding Rules
Forward chain tracking:
chain 1: "↪ Imetumwa kutoka Juma Mwangi"
chain 2-4: "↪ Imetumwa"
chain 5+: "↪ Imetumwa mara nyingi"
(misinformation warning — different icon)
Multi-forward limit:
Max 5 conversations per forward action
Prevents spam broadcasting
Cannot forward:
Custom price offers (private deal)
Order confirmations (private record)
Payment confirmations (private record)
System messages
12. Message Interactions
Editing Messages
Rules:
✅ Only original sender can edit
✅ Text messages only (not media captions yet)
✅ Within 15 minutes of sending
✅ Shows "Imehaririwa" label after edit
✅ Original send time stays the same
✅ Works in group chats (sender edits their own)
❌ Commerce cards cannot be edited (immutable)
❌ System messages cannot be edited
❌ Voice notes cannot be edited
❌ After 15 minutes: edit option disappears
UI:
Long press message → [Hariri] option (within 15 min)
After edit: message updates in place
"Imehaririwa" label appears below message
Position in thread unchanged (no jump to bottom)
Deleting Messages
Delete for Me:
✅ Any message, any time, no limit
✅ Only removed from your view
✅ Recipient still sees it
✅ Works on any message type
Delete for Everyone:
✅ Only original sender
✅ Within 15 minutes of sending
✅ Removed from all screens
✅ Shows: "Ujumbe huu umefutwa"
✅ Works in group chats
❌ Commerce cards: NOT allowed
❌ System messages: NOT allowed
❌ After 15 minutes: option disappears
Nothing is ever permanently deleted:
Soft delete only
For legal compliance + dispute resolution
Admin can view deleted messages for support cases
Users cannot recover deleted messages
Reactions
Rules:
✅ Any message can be reacted to
✅ One reaction per user per message
✅ Change reaction: send new emoji (replaces old)
✅ Remove reaction: tap same emoji again
✅ Works on commerce cards (reactions don't modify content)
❌ System messages: no reactions
Available emojis at launch:
❤️ 👍 😂 😮 😢 🙏
Expand to full emoji keyboard: Phase 3
Group reaction display:
1 type: "👍 3"
2 types: "👍 3 ❤️ 2"
3+ types: "👍 3 ❤️ 2 +2 zaidi"
Tap reaction to see who reacted:
Bottom sheet opens
List of names per emoji
Replying to Messages
Rules:
✅ Anyone in conversation can reply to any message
✅ Reply shows quoted original above new message
✅ Tap quote → scrolls to original
✅ Works in group chats
✅ Works on any message type
❌ Cannot reply to deleted messages
(shows: "Ujumbe umefutwa")
UI:
Swipe right on message → reply mode
OR long press → [Jibu] option
Quote appears in input field
Send normally
Forwarding
Rules:
✅ Anyone can forward allowed content
✅ Max 5 conversations per forward action
✅ Forward chain tracked and shown
✅ Original sender name shown (chain 1 only)
✅ Media: references original file (no re-upload)
❌ Custom price offers: not forwardable
❌ Order/payment records: not forwardable
❌ System messages: not forwardable
UI:
Long press message → [Tuma] option
Conversation picker opens
Select up to 5 conversations
Tap send
13. Voice & Video Call Flows
Initiating a Call
Kibuti taps call button on Juma's profile or conversation:
│
Permission check:
Relationship exists? ✅
Juma's privacy allows? ✅
Kibuti not blocked? ✅
│
TURN credentials generated (server)
│
Juma's phone rings:
┌──────────────────────────────────────┐
│ │
│ 📞 Simu Inayoingia │
│ │
│ [Kibuti Mwangi] │
│ @kibuti │
│ │
│ [❌ Kataa] [✅ Jibu] │
│ │
└──────────────────────────────────────┘
Works even if:
App is closed (FCM HIGH wakes it)
Screen is locked (full screen notification)
App is in background
During a Call — Controls
Voice call controls:
🔇 Mute/unmute microphone
🔊 Speaker on/off
📷 Enable camera (upgrade to video)
❌ End call
Video call controls:
🔇 Mute/unmute microphone
📷 Camera on/off
🔄 Switch camera (front/rear)
🖥️ Share screen
🔊 Speaker on/off
❌ End call
Switching Audio ↔ Video
Voice → Video:
Kibuti taps camera button during voice call
Juma sees: "Kibuti anataka kuongeza video"
Auto-accepted (based on Juma's settings)
OR Juma taps Accept
Video starts — same call session continues
Audio uninterrupted during upgrade
Video → Audio (manual):
Kibuti taps camera OFF
Video stops immediately for both
Audio continues
No renegotiation needed
Video → Audio (automatic):
Network degrades below video threshold
Video disabled automatically
Banner shown: "Video imezimwa — mtandao dhaifu"
Audio continues
Video resumes when network improves
Screen Sharing
Start screen share:
Tap screen share icon during video call
System permission dialog appears (Android/iOS)
"Ruhusu NexGate kunasa skrini yako?"
User accepts
Screen share starts
Other party sees your screen
During screen share:
Your camera: small PiP (picture in picture)
Their view: your screen (large) + your face (small)
Your view: normal call view + "Unaonyesha skrini" banner
Stop screen share:
Tap stop button
Returns to normal video call
Call Quality Indicators
Signal bars shown during call:
████ Excellent (WiFi / 4G strong)
███░ Good (4G)
██░░ Fair (3G) — may show quality banner
█░░░ Poor (2G) — video disabled, audio only
░░░░ Very poor — "Mtandao dhaifu sana" banner
Quality banner examples:
"Ubora wa sauti umepungua kwa sababu ya mtandao"
"Video imezimwa — data ndogo"
"Unaunganika tena..."
Call End Scenarios
Normal end:
One party taps end
Other party sees call ended
Duration shown in conversation: "Simu ya dakika 4:32"
Declined:
Juma taps Kataa
Kibuti sees: "Simu ilikataliwa"
Missed call notification NOT sent (was declined)
No answer (timeout 45 seconds):
Kibuti sees: "Hakujibu"
Juma sees: "Simu iliyokosekana kutoka Kibuti" notification
Network failure:
Both lose connection
App attempts reconnect (10 seconds)
If reconnect fails:
Call marked as FAILED
"Simu ilikatizwa" shown to both
Kibuti can redial
14. Group Call Flows
Starting a Group Call
From a group chat:
Tap call icon in group header
Choose: Voice only OR Video
All group members receive incoming call notification
Members who join → enter call
Members who don't → miss it (missed call shown)
From a 1:1 conversation:
Not supported directly
Must create group first
OR use "Add person" button during active 1:1 call
Add person to active 1:1 call:
During call → tap "Ongeza Mtu"
Pick from allowed contacts
They receive group call invitation
They join → 1:1 becomes group call
Group Call Limits
Voice only group call:
Max 8 participants (comfortable for 3G)
Up to 12 possible but discouraged on EA networks
Video group call:
Max 4 video feeds shown simultaneously
5th person onwards: audio only tile shown
Active speaker highlighted (larger tile)
Tap any tile to pin/feature them
Layout options:
Grid view: all tiles equal size
Speaker view: active speaker large, others small
Auto: switches based on who is talking
Group Call Permission Rules
Within a group chat:
Any member can start group call ✅
Any member can join ✅
No extra permission check needed
(group membership = call permission)
Adding someone outside the group:
Same call permission check as 1:1
Must have relationship (contact/follower/commerce)
If no relationship → cannot add
15. Offline & Notification Flows
Message Notification Levels
Level When FCM SMS after
──────────────────────────────────────────────────────────────
CRITICAL Order placed/paid/failed ✅ 0 min
Delivery update
Payment confirmation
IMPORTANT Commerce DM from buyer ✅ 10 min
Custom price offer received
Bei ya pamoja threshold reached
Call missed (commerce context)
NORMAL Regular DM ✅ Never
Group message
Reaction
Follow notification
Offline Message Delivery
User offline — message sent to them:
│
├── FCM HIGH priority push fired immediately
│ (all message levels)
│
├── CRITICAL: Textfy SMS sent simultaneously
│
├── IMPORTANT: Textfy SMS after 10 min
│ (if FCM not acknowledged)
│
└── Message queued in RabbitMQ
User comes back online:
│
├── WS connects → presence registered
├── Queued messages drained (priority order)
│ CRITICAL first → IMPORTANT → NORMAL
│
└── Catch-up banner shown:
"Umekosa: maagizo 2, ujumbe 8"
[Angalia Maagizo] [Ona Ujumbe]
Textfy SMS Templates
Order placed (to seller):
"NexGate: Agizo jipya kutoka [Buyer]!
TZS [amount]. Kagua: nexgate.app/orders/[id]"
Payment received:
"NexGate: Malipo ya TZS [amount]
yamepokelewa kutoka [Buyer].
nexgate.app/wallet"
Commerce DM (to seller):
"NexGate: [Buyer] anakuuliza kuhusu
[Product]. Jibu: nexgate.app/chat/[id]"
Bei ya pamoja:
"NexGate: Watu [n]/[target] wamejiunga!
nexgate.app/group-buy/[id]"
All SMS:
Swahili first ✅
Deep link included ✅
Shop name shown (not staff name) ✅
16. Privacy & Safety
Privacy Settings Matrix
Setting Options Default
────────────────────────────────────────────────────────────────
Who can message me Everyone / Followers Followers
+ Contacts / Contacts + Contacts
/ Nobody
Who can call me Followers+Contacts / Contacts
Contacts / Nobody only
Last seen Everyone / Contacts / Contacts
Nobody
Profile picture Everyone / Contacts / Everyone
Nobody
Be found by phone number On / Off On
Contact sync On / Off Off
Read receipts (blue ticks) On / Off On
(off = others see delivered but not read)
Block System
User A blocks User B:
User B cannot:
❌ See User A's profile
❌ Send messages to User A
❌ Call User A
❌ See User A in search
❌ See User A's VP Feed posts
❌ See User A's online status
User B sees:
Profile: "Mtumiaji huyu hayupo"
Messages: appear sent but never delivered
User A can:
✅ Unblock anytime from Settings
✅ See User B's profile still (A blocked B — not reverse)
Group chats:
If in same group: messages visible but
cannot DM or call each other directly
Admin can remove either from group
Report System
Report options:
Spam
Harassment / Vitisho
Inappropriate content
Fake account
Scam / Udanganyifu
Other
Report flow:
Long press message → [Ripoti]
OR profile → [...] → [Ripoti]
Choose reason
Optional: add description
Submit
After report:
User not notified they were reported
Report goes to NexGate moderation queue
Automatic temporary restrictions may apply
for high-volume reporters
Block on report:
"Ripoti na Zuia" option available
Blocks immediately + sends report
Safety for Women — Specific Features
This is important for EA platform trust:
Silence unknown callers:
ON by default for new accounts
Calls from non-contacts ring silently
User sees missed call — no disruption
Call rate limiting:
3 unanswered calls per day to same person
Automatic block after 4th attempt
Quick block during call:
One tap visible during any call
Ends + reports + blocks in single action
Message request system:
Strangers cannot freely DM
Must send request first
User controls who enters their inbox
Default privacy settings:
New accounts: strict defaults
Users open up if they choose
Better to protect by default
17. Shop Inbox & Staff Access
Shop Tiers
Basic shop (free):
Owner manages inbox alone
No staff assignment
One active stream at a time
Standard features
Pro shop (paid):
Staff roles unlocked
Multiple staff in inbox
Advanced analytics
Priority support
Audit logs
Staff Roles
Role Inbox Products Analytics Settings Staff mgmt
──────────────────────────────────────────────────────────────────
OWNER ✅ ✅ ✅ ✅ ✅
MANAGER ✅ ✅ ✅ ❌ ❌
SUPPORT_AGENT ✅ ❌ ❌ ❌ ❌
READ_ONLY 👁️ view ❌ ❌ ❌ ❌
Staff Identity Rules
Staff responds to customer:
Customer sees: "TechStore" (shop name)
Customer NEVER sees: "Amina" (staff name)
Staff identity is internal only
Audit log (internal):
Which staff member responded
Timestamp
Visible to OWNER and MANAGER only
Not visible to customers
Staff cannot:
❌ Reveal personal phone number to customers
❌ Switch conversation to personal DM
❌ Access owner's personal inbox
❌ Access other shops they're not assigned to
Multiple Shops — Inbox Separation
Owner with 3 shops:
Each shop = separate inbox tab
Each shop = separate conversation records
Each shop = separate staff assignments
Staff for Shop A cannot see Shop B
Owner sees all (as owner of all)
Commerce DM routing:
Buyer messages about Product A (TechStore product)
→ Goes to TechStore inbox
→ NOT to ClothingHub inbox
→ NOT to owner's personal inbox
Correctly routed by product ownership
18. Notification Settings
Per-Conversation Settings
Each conversation has:
Notifications:
○ All messages
○ Mentions only (groups)
○ Muted (until: 8 hours / 1 week / forever)
Media auto-download:
○ WiFi only
○ WiFi + Mobile data
○ Never
Global Notification Settings
Message notifications: On / Off
Call notifications: On / Off
Reaction notifications: On / Off
Group notifications: On / Off
Commerce notifications: On / Off (cannot turn off CRITICAL)
Sound: Default / Custom / Silent
Vibration: On / Off
In-app preview: Show / Hide content
CRITICAL Notifications — Always On
These cannot be turned off by user:
Order placed (seller receives)
Payment confirmed (buyer receives)
Payment failed (buyer receives)
Order cancelled (both receive)
Reason:
Financial events
User could miss critical money information
Platform liability without guaranteed delivery
Textfy SMS fallback ensures they always arrive
19. Edge Cases & Scenarios
Messaging Edge Cases
User deletes their account:
Their messages remain visible in conversations
Name shows: "Mtumiaji aliyefuta akaunti"
Profile picture: default avatar
Cannot be messaged or called
User changes username:
All conversations update automatically
Old username links still work (redirect)
No broken references
Very long message (>4000 chars):
Truncated in thread: first 200 chars + "...Soma zaidi"
Tap to expand full message
Not split into multiple messages
Same message sent twice (duplicate):
Detected by temp_id
Only one stored in DB
User sees single message (not duplicate)
Message sent to blocked user:
Appears sent (single tick)
Never delivered (stays at single tick)
User not informed of block
Commerce Edge Cases
Product goes out of stock while offer pending:
Offer still valid (was frozen at send time)
Checkout validates stock at payment time
If out of stock at payment:
Payment rejected
"Bidhaa hii imeisha"
Buyer returned to conversation
Seller notified to resend offer or cancel
Product price changes while offer pending:
Offer price locked at creation ✅
Public price change does NOT affect offer
Buyer pays the offer price always
Seller deletes product while offer pending:
Offer still valid (snapshot frozen)
Checkout validates product existence
If product deleted at payment:
Payment rejected
"Bidhaa hii haipatikani tena"
Two buyers get offers for last item:
Both offers exist simultaneously
First to complete checkout gets the item
Second buyer's payment rejected:
"Bidhaa hii imeisha"
Standard e-commerce race condition handling
Seller sends offer to wrong person:
Cannot retract offer
Offer expires naturally
Seller can contact NexGate support if urgent
Buyer in checkout when offer expires:
Payment attempt fails
"Bei yako imeisha muda"
Must request new offer from seller
Call Edge Cases
Call drops mid-conversation:
WebRTC detects loss
Auto-reconnect attempted (10 seconds)
If reconnect success: call continues
If reconnect fails: call ended
Both see: "Simu ilikatizwa"
Kibuti can redial immediately
Both call each other simultaneously:
Race condition — both see "Simu inayoingia"
System picks one (first to reach server)
Other cancelled automatically
One call established
Call during active call:
Second call: goes to missed calls
Busy signal not sent (no UX for this)
After first call: notification of missed call
Phone runs out of battery during call:
WebRTC detects disconnect
Same as network drop
Other party sees: "Simu ilikatizwa"
Someone calls during Do Not Disturb (DND):
DND on device: depends on device DND rules
NexGate mute: call goes to missed calls silently
CRITICAL commerce calls: bypass mute (configurable)
Group Chat Edge Cases
Owner leaves group:
Must transfer ownership first
OR system auto-assigns to oldest admin
OR if no admins: oldest member becomes owner
Last person leaves group:
Group archived automatically
Messages preserved for 30 days
Then permanently deleted
Adding member who blocked you:
Cannot add blocked users to groups
System rejects silently
No error shown (privacy)
Member blocked in group:
They stay in group (cannot remove via block)
Cannot DM each other
Can both still see group messages
Admin can remove either from group
Summary
NexGate messaging is built for East African social commerce — where every conversation can become a transaction, and every transaction has a conversation behind it.
The permission system is designed to be safe by default: contacts and mutual followers can communicate freely, strangers go through message requests, and calls require an established relationship. Commerce contexts create natural communication permissions — buyers and sellers can always reach each other through shop identities that protect personal privacy.
Contact sync is optional, privacy-preserving (hashed on device), and exists alongside multiple other discovery mechanisms. NexGate does not need contact sync to function — it is one convenience feature among many.
Commerce offer sessions are the unique heart of NexGate messaging — a formal negotiated deal between a specific buyer and seller, with a locked price, expiry timer, and full lifecycle tracking. The public product price is never affected. Checkout always happens outside the inbox. Everything is immutable for legal and dispute reasons.
The safety system protects users (especially women) through strict defaults, rate limiting, quick block options, and a message request system that gives users control over who enters their inbox.
NexGate Messaging — Product Requirements & Feature Flows v1.0 QBIT SPARK | Rules · Flows · Permissions · Scenarios · Edge Cases