A small translation bridge between Andrews & Arnold (A&A/AAISP) SMS delivery and 3CX's Generic SMS provider webhook.
A&A and 3CX don't speak the same language for SMS: A&A POSTs plain
form-encoded fields (oa, da, ud, scts), while 3CX's Generic SMS
provider expects a specific JSON event shape (message.received) for
inbound, and wants a Bearer-token-style API key for outbound — which A&A's
username/password gateway doesn't provide. This bridge sits in the middle
and translates both directions.
Inbound: A&A --(form POST)--> bridge --(3CX JSON)--> 3CX webhook
Outbound: 3CX --(send request)--> bridge --(username/password POST)--> A&A
- Node.js 18+ (uses
crypto.randomUUID) - An A&A VoIP number with SMS enabled, and an outgoing password set on the A&A Control Pages
- A 3CX instance with a Generic SMS provider added under Admin Console → Messaging → Add Provider → Generic
- A publicly reachable place to run this (a VPS, a Pi, a Home Assistant add-on host, etc.) with HTTPS in front of it — A&A and 3CX both need to reach it over the internet
git clone <this repo>
cd aa-3cx-sms-bridge
npm install
cp .env.example .env
# edit .env with your real values
npm startSee .env.example for every setting. At minimum you need:
THREECX_WEBHOOK_URL— the webhook URL 3CX generated for your Generic SMS providerAA_USERNAME/AA_PASSWORD— your A&A VoIP number and its outgoing SMS passwordSHARED_SECRET— recommended. A&A's inbound POST has no signature or auth of its own, so this is your only real gatekeeping. Generate one with:Generate your own random value — never reuse an example secret you've seen elsewhere (including in this README's own examples), and never commit a real secret tonode -e "console.log(require('crypto').randomBytes(16).toString('hex'))".envor anywhere else in the repo. The.gitignorekeeps.envout of git, but double-check before pushing if you ever hardcode it anywhere for testing.
- Run the bridge somewhere with a public HTTPS URL, e.g.
https://your-server/aa-sms-in?key=yoursecret - On the A&A Control Pages, set that as the SMS delivery target for your
number. A&A's delivery target field is space-separated (not comma),
and multiple targets can be listed together, e.g.:
The
you@example.com +https://your-server/aa-sms-in?key=yoursecret+prefix makes A&A send E.123-formatted numbers (+44...) and ISO8601 timestamps to that target, which this bridge expects. - Text your A&A number. The bridge logs the incoming payload, reshapes it, and forwards it to your 3CX webhook.
Point 3CX's Generic SMS provider Provider URL at your bridge's
outbound endpoint (e.g. https://your-server/cx-sms-out?key=yoursecret)
instead of A&A's gateway directly, since 3CX wants to authenticate with an
API key and A&A doesn't have one.
Note: 3CX's exact outbound webhook payload shape isn't publicly
documented. extractOutbound() in src/server.js makes a best-effort
guess at where the destination number and message text live in the
request body. If outbound sends aren't working, hit the bridge's /debug
endpoint from 3CX first (or check the bridge's logs — every outbound
request is logged in full) and adjust extractOutbound() to match what
you actually see.
The ha-addon/aa-3cx-sms-bridge/ folder packages this as an installable
HA Supervisor add-on — no SSH access to the host required.
- Push this repo to GitHub (public or private, either works)
- In Home Assistant: Settings → Add-ons → Add-on Store → ⋮ → Repositories,
add your repo's URL (the one containing
repository.yaml) - The "AA-3CX SMS Bridge" add-on should appear in the store — install it
- Go to the add-on's Configuration tab and fill in:
threecx_webhook_url— from 3CX's Generic SMS provideraa_username/aa_password— your A&A VoIP number and outgoing SMS passwordshared_secret— a random string only you know, since A&A's POST has no built-in auth. Generate one with:(any machine with Node works — this doesn't need to run on the HA box itself). Paste the result in here, and remember it — you'll need it again below as thenode -e "console.log(require('crypto').randomBytes(16).toString('hex'))"?key=...part of the URLs you give to A&A and 3CX.- leave
aa_sms_url,inbound_path,outbound_pathas default unless you have a reason to change them
- Start the add-on, check its Log tab for
AA <-> 3CX SMS bridge listening on port 3000
A&A and 3CX both need to reach this add-on from the internet — your Home Assistant instance can't just be reachable on your local network. You need some form of public HTTPS exposure pointed at port 3000 on your HA host. A few common options:
- Cloudflare Tunnel (covered in detail below) — no router config or open ports needed
- Nginx Proxy Manager — if you already run a reverse proxy with a domain and TLS cert
- Nabu Casa — Home Assistant's official remote access subscription
Whichever you use, the end result is the same: a public HTTPS URL that
forwards to your-ha-host-ip:3000.
Since you're already tunneling HA, add the bridge as a second public hostname on the same tunnel:
- Cloudflare Zero Trust dashboard → Networks → Tunnels → your existing tunnel → Public Hostnames → Add a public hostname
- Pick a subdomain, e.g.
sms-bridge.yourdomain.com - Service type HTTP, URL
homeassistant.local:3000(or your HA host's LAN IP if.localdoesn't resolve forcloudflared, e.g.192.168.1.50:3000) - Save — no router port-forwarding needed,
cloudflaredmakes the outbound connection
Your URLs to hand out become:
- A&A delivery target:
+https://sms-bridge.yourdomain.com/aa-sms-in?key=yoursecret - 3CX Provider URL:
https://sms-bridge.yourdomain.com/cx-sms-out?key=yoursecret
Note on the shared secret: the add-on's run.sh parses /data/options.json
with grep/cut rather than a JSON parser, to avoid adding jq as a
dependency in the Alpine base image — fine for the simple flat values here,
but if you add option values containing commas or quotes later, switch it
to jq.
GET /health— confirms the bridge is upPOST /debug— logs and echoes back whatever is POSTed to it; handy for pointing 3CX or A&A at temporarily to see their raw payload shape- Every inbound and outbound request is logged to stdout
- A&A's inbound POST is
multipart/form-data, notapplication/x-www-form-urlencodedas A&A's own docs might suggest at first read. The bridge usesmulterto parse it. - 3CX's outbound webhook payload is a flat JSON object:
{"from":"+44...","to":"+44...","text":"..."}— confirmed from live traffic,extractOutbound()handles this shape. - A&A's
!target prefix still isn't documented anywhere public — if you need it, ask A&A support directly.
MIT