- JavaScript 95.8%
- Shell 4.2%
| app | ||
| asterisk | ||
| nginx | ||
| systemd | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| package.json | ||
| README.md | ||
Pixels Chat Calling API
Places outbound phone-verification calls through an existing FreePBX 17 / Asterisk 21 system over a BulkVS SIP trunk, speaking a verification code via local TTS (Piper).
This version runs on the same machine as Asterisk and uses Asterisk call files instead of AMI/ARI — no manager credentials, no extra network service. The API drops a small text file into Asterisk's spool directory; Asterisk originates the call itself and reports the outcome by moving that file into a "done" folder with a status line, which the API polls.
1. Architecture
Client --HTTPS--> Pixels Chat Calling API --call file--> Asterisk/FreePBX --BulkVS--> PSTN
The API authenticates the request, validates and normalizes the phone number and
code, generates a TTS audio file with Piper, writes a .call file naming a small
custom dialplan context, and moves it into /var/spool/asterisk/outgoing/.
Asterisk picks it up immediately, dials out through BulkVS, and on answer runs
the dialplan context that plays the audio and hangs up.
2. Requirements
- Node.js 18+
ffmpegon PATH- Piper (local TTS)
- FreePBX 17 / Asterisk 21 already installed with a working
BulkVStrunk, on this same machine - Root/sudo access to configure Asterisk and set up the service user
3. Installing the API
sudo useradd --system --home /opt/pixels-chat-calling --shell /usr/sbin/nologin pixelschat
sudo mkdir -p /opt/pixels-chat-calling
sudo cp -r app tests package.json /opt/pixels-chat-calling/
cd /opt/pixels-chat-calling
sudo npm install --omit=dev
sudo cp /path/to/.env.example /opt/pixels-chat-calling/.env
sudo mkdir -p /var/lib/pixels-chat-calling/audio /var/lib/pixels-chat-calling/callfiles
sudo chown -R pixelschat:pixelschat /opt/pixels-chat-calling /var/lib/pixels-chat-calling
sudo chmod 750 /var/lib/pixels-chat-calling /var/lib/pixels-chat-calling/audio /var/lib/pixels-chat-calling/callfiles
Edit /opt/pixels-chat-calling/.env:
API_KEYS— one or more comma-separated long random secrets (generate withopenssl rand -hex 32). Clients send one asAuthorization: Bearer <key>.ASTERISK_TRUNK— must match the trunk name FreePBX shows for BulkVS.OUTBOUND_DID— the number presented as caller ID. Never accepted from clients.TTS_PROVIDER=local, plusPIPER_BINARY/PIPER_MODEL_PATH(section 8).
4. Give the API user access to the Asterisk spool
Asterisk's spool directories are normally owned by the asterisk user/group.
Add pixelschat to that group so it can drop files in without running as root
or as asterisk itself:
sudo usermod -aG asterisk pixelschat
sudo mkdir -p /var/spool/asterisk/outgoing_done
sudo chown asterisk:asterisk /var/spool/asterisk/outgoing_done
sudo chmod 770 /var/spool/asterisk/outgoing
sudo chmod 770 /var/spool/asterisk/outgoing_done
Restart the service (once installed) for the new group membership to apply.
5. systemd Service
sudo cp systemd/pixels-chat-calling.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pixels-chat-calling
sudo systemctl status pixels-chat-calling
The unit runs as pixelschat:asterisk and only allows writes to
/var/lib/pixels-chat-calling, /var/spool/asterisk/outgoing, and
/var/spool/asterisk/outgoing_done — everything else on the filesystem is
read-only to it.
Logs: journalctl -u pixels-chat-calling -f
6. Nginx Reverse Proxy (optional, still worth it even on localhost)
sudo cp nginx/pixels-chat-calling.conf /etc/nginx/sites-available/pixels-chat-calling.conf
sudo ln -s /etc/nginx/sites-available/pixels-chat-calling.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Edit server_name and the TLS certificate paths first. With Nginx in front,
keep APP_HOST=127.0.0.1 in .env so the Node process itself is never
directly internet-reachable.
7. FreePBX / Asterisk Configuration
7.1 Custom dialplan context
Append the contents of asterisk/extensions_custom.conf to your PBX's
existing /etc/asterisk/extensions_custom.conf (append — never edit
extensions_additional.conf, which FreePBX regenerates):
sudo cat asterisk/extensions_custom.conf | sudo tee -a /etc/asterisk/extensions_custom.conf
sudo asterisk -rx "dialplan reload"
This creates a [pixels-chat-calling] context reachable only by a call file
that explicitly names it — it has no inbound route, so nothing on the PSTN
side can reach it.
7.2 Confirm the trunk name
In FreePBX GUI: Connectivity → Trunks, confirm the BulkVS trunk's PJSIP
Trunk Name matches ASTERISK_TRUNK in .env. The call file dials
<ASTERISK_CHANNEL_TECH>/<number>@<ASTERISK_TRUNK>, e.g.
PJSIP/12145551234@BulkVS, which FreePBX resolves using that trunk's own
registration/auth — the API never touches SIP credentials.
7.3 Confirm the spool module is loaded
Call files require pbx_spool.so, which is loaded by default:
sudo asterisk -rx "module show like pbx_spool"
7.4 Manual test, bypassing the API entirely
cat <<EOF | sudo tee /var/spool/asterisk/outgoing/manual-test.call
Channel: PJSIP/12145551234@BulkVS
CallerID: "Pixels Chat Calling" <12145559876>
Context: pixels-chat-calling
Extension: s
Priority: 1
WaitTime: 30
Archive: yes
Set: PCC_AUDIO_FILE=/var/lib/pixels-chat-calling/audio/some-existing-file
Set: PCC_CALL_ID=manual-test
Set: PCC_REPEAT=0
EOF
(Swap in a real audio file path first — the pixels-chat-calling context will
log a Playback warning and hang up if it's missing, but the call itself
should still ring.) Check the result:
ls /var/spool/asterisk/outgoing_done/
cat /var/spool/asterisk/outgoing_done/manual-test.call
You'll see a Status: Completed (answered and ran the dialplan), Status: Expired
(no answer/busy/timeout), or Status: Failed (channel/trunk problem) line appended.
8. TTS Setup (local Piper)
sudo apt install -y python3-pip python3-venv ffmpeg
sudo python3 -m venv /opt/piper-venv
sudo /opt/piper-venv/bin/pip install piper-tts
sudo ln -s /opt/piper-venv/bin/piper /usr/local/bin/piper
sudo mkdir -p /opt/piper/voices
cd /opt/piper/voices
sudo /opt/piper-venv/bin/python3 -m piper.download_voices en_US-amy-medium
sudo chown -R pixelschat:pixelschat /opt/piper
Test it standalone first:
echo "This is a test" | piper --model /opt/piper/voices/en_US-amy-medium.onnx --output_file /tmp/test.wav
aplay /tmp/test.wav
In .env:
TTS_PROVIDER=local
TTS_VOICE=en_US-amy-medium
PIPER_BINARY=/usr/local/bin/piper
PIPER_MODEL_PATH=/opt/piper/voices/en_US-amy-medium.onnx
Piper's raw output is converted with ffmpeg to 8 kHz mono 16-bit PCM WAV, the
format Asterisk plays reliably.
9. Making a Call
curl -H "Authorization: Bearer YOUR_API_KEY" \
"http://127.0.0.1:8443/api/v1.0/phone?tn=12145551234&code=58392014"
{
"success": true,
"message": "Call queued",
"call_id": "550e8400-e29b-41d4-a716-446655440000",
"destination": "12145551234"
}
Check its status:
curl -H "Authorization: Bearer YOUR_API_KEY" \
"http://127.0.0.1:8443/api/v1.0/phone/550e8400-e29b-41d4-a716-446655440000"
status moves queued → originating → completed / no_answer / failed
(polled from the spool every ASTERISK_POLL_INTERVAL_MS, 2 seconds by default).
Interactive docs: http://127.0.0.1:8443/docs
10. Testing
npm test
runs the validation unit tests (phone number/code normalization and rejection).
BASE_URL=http://127.0.0.1:8443 \
API_KEY=your_key \
TEST_TN=12145551234 \
bash tests/manual_test.sh
exercises health, auth, validation, a real call, status polling, and rate limiting. Use a number you actually control.
11. Firewall Recommendations
- Only expose the API's port if you actually need remote access; for a
same-machine-only setup, keep
APP_HOST=127.0.0.1and nothing needs to be opened externally at all. - If you do put Nginx in front for remote access, only open 443 — the app itself should stay bound to loopback.
- No AMI/ARI port is used by this deployment, so there's nothing extra to firewall on the Asterisk side.
12. Troubleshooting
| Symptom | Check |
|---|---|
503 from /health, checks.asterisk_spool: false |
pixelschat isn't in the asterisk group yet, or spool dir permissions/ownership are off — recheck section 4 |
Call stuck in originating forever |
Confirm pbx_spool.so is loaded (asterisk -rx "module show like pbx_spool") and the .call file actually left /var/spool/asterisk/outgoing/ (Asterisk should pick it up within seconds) |
Call goes failed |
Check the archived file in outgoing_done/ for its Status: line, and asterisk -rx "pjsip show endpoint BulkVS" for trunk registration |
| No audio on answer | Confirm PCC_AUDIO_FILE path exists and is readable by the asterisk user; check tail -f /var/log/asterisk/full during the call |
401 Unauthorized on valid-looking key |
Confirm no trailing whitespace/newline in the key, and it's listed in API_KEYS |
429 on first request |
MAX_CONCURRENT_CALLS/DAILY_CALL_LIMIT may be exhausted from earlier testing — check the calls table in the SQLite DB |
Asterisk-side logs: asterisk -rvvv or tail -f /var/log/asterisk/full.
API logs: journalctl -u pixels-chat-calling -f — verification codes and API
keys are never written to logs; destinations are masked (+1*******1234).
13. Known Limitations
- Call-file status reporting only distinguishes
Completed/Expired/Failed(mapped here tocompleted/no_answer/failed) — there's no separatebusyorringingstate without AMI/ARI, since Asterisk doesn't expose that level of detail through the spool mechanism. - Status is polled from the filesystem every
ASTERISK_POLL_INTERVAL_MS(default 2s), not pushed instantly. - Rate limiting is in-process (in-memory); running multiple API instances
would need a shared store (e.g. Redis) for
express-rate-limit. - The included Piper integration assumes a working
piperbinary and voice model already installed; only the invocation is wired up here.
14. Security Notes
- API keys are compared with
crypto.timingSafeEqualagainst SHA-256 hashes, never stored or logged in plaintext. - The verification code is never persisted — only its SHA-256 hash, used solely for duplicate-request detection.
- The destination is stored as both a SHA-256 hash (for lookups) and a masked display form; the full number is never persisted in the audit table.
tnandcodeare strictly validated before touching the filesystem, TTS engine, or Asterisk — the code is checked against^[0-9]+$before it is ever passed to a subprocess or written into a call file, and no user input is concatenated into a shell string (execFileuses argument arrays; the call file'sSet:lines only ever contain the validated code/call ID/path).- Generated audio files use random UUID filenames, never the code or number.
- The outbound caller ID is fixed from server-side config; there is no request parameter that can influence it.
- The custom dialplan context is only reachable via a call file that explicitly names it — it has no inbound route.
- The service account only has write access to its own data directory and
the two Asterisk spool folders (systemd
ReadWritePaths+ProtectSystem=strict).