MIKODES VOX docs
v2.0.0
Live demo Get help
● Ship · Server and telephony

Deployment
reachable, and safe.

Put a TLS reverse proxy in front of VOX, point your phone number's webhooks at it, run it as a service and back up its data. Nothing else faces the internet.

01What faces the internet

PortWhatExposed?
8765Media server: calls, texts and provider webhooksOnly through the proxy, only the paths below
8080SaaS web app: public site, /app/, /op/, /api/, /stripe/webhookOnly through the proxy (SaaS mode)
8766Single-user consoleNever. SSH tunnel only: ssh -L 8766:127.0.0.1:8766 your-server

Behind a proxy run the media server on loopback: --serve-host 127.0.0.1. Leave it on 0.0.0.0 only when a firewall closes port 8765.

02Reverse proxy

The media server speaks plain HTTP. TLS comes from the proxy. Paths must reach VOX unchanged: Twilio signs over https://<VOX_PUBLIC_HOST><path>.

PathToCalled by
/ws (WebSocket)8765Twilio media stream of a call
/voice, /voice/pin8765inbound call, PIN entry (Twilio)
/sms, /sms/status8765inbound SMS/WhatsApp, delivery status (Twilio)
/call/status8765Twilio call status callback (billing of minutes in SaaS)
/ws/telnyx, /call/telnyx, /sms/telnyx8765Telnyx media stream, call events, texts
/telegram8765Telegram commands (only with the Telegram channel)
everything else8080 (SaaS) · 404 (single-user)browsers, Stripe

Ready single-user files: deploy/Caddyfile.example (automatic HTTPS) and deploy/nginx-vox.conf.example. They forward only the media routes and answer 404 to everything else; both were validated locally (caddy validate, nginx -t, WebSocket upgrade, 403 on unsigned webhooks).

SaaS mode with Caddy

vox.example.com {
	@media path /ws /ws/telnyx /sms /sms/status /sms/telnyx /voice /voice/pin /call/status /call/telnyx /telegram
	handle @media {
		request_body {
			max_size 64KB
		}
		reverse_proxy 127.0.0.1:8765
	}
	handle {
		request_body {
			max_size 2MB
		}
		reverse_proxy 127.0.0.1:8080
	}
}

SaaS mode with nginx

map $http_upgrade $vox_connection_upgrade {
    default upgrade;
    ''      close;
}
server {
    listen 443 ssl;
    server_name vox.example.com;
    ssl_certificate     /etc/letsencrypt/live/vox.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/vox.example.com/privkey.pem;
    server_tokens off;

    location = /ws {
        proxy_pass http://127.0.0.1:8765;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $vox_connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_buffering off;
    }
    location = /ws/telnyx {
        access_log off;        # the URL carries the call's one-time secret
        proxy_pass http://127.0.0.1:8765;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $vox_connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_buffering off;
    }
    location ~ ^/(sms|sms/status|sms/telnyx|voice|voice/pin|call/status|call/telnyx|telegram)$ {
        client_max_body_size 64k;
        proxy_pass http://127.0.0.1:8765;
        proxy_set_header Host $host;
    }
    location / {
        client_max_body_size 2m;
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
    }
}

Plus the usual port-80 server for the ACME challenge and the redirect to HTTPS, as in deploy/nginx-vox.conf.example. The 2 MB limit covers a logo upload (≤ 512 KB) and Stripe webhooks (≤ 1 MB).

Unverified

The SaaS proxy blocks are derived from the routes in the code and the validated single-user examples. The SaaS variants themselves have not been run.

The web app sets a strict Content-Security-Policy, HSTS (unless --insecure-http) and no-store on API answers by itself. It serves no third-party scripts, fonts or trackers.

03The Twilio number

Twilio Console → Phone Numbers → your number:

SettingValueMethod
Voice — "A call comes in"https://<host>/voiceHTTP POST
Voice — "Call status changes"https://<host>/call/statusHTTP POST
Messaging — "A message comes in"https://<host>/smsHTTP POST
  • /call/status is required for billing inbound minutes in SaaS mode. Calls VOX places attach it automatically.
  • /sms/status and /voice/pin are set by VOX per message and per call; nothing to configure.
  • Every webhook must carry a valid X-Twilio-Signature for https://VOX_PUBLIC_HOST<path> and your Account SID; anything else gets 403.
  • In SaaS mode one number serves all customers. An inbound call or SMS belongs to the customer who verified the sending number (a number can be verified by one customer only: 409 NUMBER_IN_USE).
  • Trial Twilio accounts call only verified numbers; the destination country must be enabled in Voice geographic permissions.

Telnyx setup: Configuration → Telnyx.

04One machine, one data directory

dial writes the call plan to app/data/calls/plans/, and the media server finds it there when the stream connects. Both must run over the same data directory. The server hangs up without a word on a stream it did not place (for example a call started from the Twilio console).

05Run it as a service

Docker restarts the container by itself. Without Docker use the systemd units in deploy/systemd/ (Installation). Stopping sends SIGINT; up and saas stop every service cleanly.

06Backups

app/data/ holds the profile, calls, the SaaS database (saas/vox.db), every customer and secrets.env. Protect a backup like the keys.

# Docker: archive the vox-data volume
docker run --rm -v mikodes-vox_vox-data:/d -v "$PWD":/b debian:bookworm-slim tar czf /b/vox-data.tgz -C /d .
Careful

docker compose down keeps the data. docker compose down -v deletes the volume with the profile, calls and keys, irreversibly.

07Before users: the first call

  1. Checkpython -m voice check exits 0.
  2. Verify your own numberIn the console (how).
  3. Call yourselfpython -m voice dial --purpose "test". Listen: voice quality, barge-in, the time until it answers.
  4. Then a third-party testTo a second phone you own. The disclosure must play in full before anything else.
  5. Only thenopen sign-up or hand it to a user.