A self-hosted proxy enabling two-way SMS in Groundwire via AAISP VoIP numbers. It:
- Translates AAISP's plain-text SMS API into Groundwire-compatible XML for outbound messages
- Receives inbound SMS webhooks from AAISP and stores them for Groundwire to fetch
- Sends push notifications to Groundwire via Acrobits' push service when a message arrives
AAISP credentials are kept server-side and never sent to the app.
Requirements:
- An Andrews & Arnold (AAISP) VoIP number with SMS enabled
- Groundwire — available for iOS and Android
- A server to host the proxy (Docker + a reverse proxy such as Caddy)
Outbound: Groundwire → index.php → AAISP SMS API → recipient
Inbound: sender → AAISP → receive.php → SQLite → fetch.php → Groundwire
Push: receive.php → pnm.cloudsoftphone.com → iOS notification → Groundwire
Token reg: Groundwire → push_register.php → SQLite
Heartbeat: cron → heartbeat.php → pnm.cloudsoftphone.com → notification → Groundwire
Apply the same URLs to each SIP account in Groundwire. The %account[username]% placeholder is automatically replaced by Groundwire with the SIP username (your AAISP number), so no per-account customisation is needed.
Settings > Account > Web Services > SMS Sender
| Field | Value |
|---|---|
| URL | https://your-domain.example.com/index.php?token=YOUR_SMS_TOKEN&account=%account[username]%&da=%sms_to%&ud=%sms_body% |
| Method | GET |
| Everything else | leave blank |
Settings > Account > Web Services > SMS Fetcher
| Field | Value |
|---|---|
| URL | https://your-domain.example.com/fetch.php?token=YOUR_SMS_TOKEN&account=%account[username]%&last_known_sms_id=%last_known_sms_id% |
| Method | GET |
| Everything else | leave blank |
Groundwire polls this automatically. Observed behaviour:
- Active use: polls every ~30–60 seconds
- Idle: backs off to ~3 minute intervals
- Immediate poll: opening the Groundwire messages screen triggers a fetch right away
Note:
fetch.phpdeletes messages after returning them. Usestatus.phpfor diagnostics instead (see below).
Settings > Account > Web Services > Push Token Reporter
| Field | Value |
|---|---|
| URL | https://your-domain.example.com/push_register.php?token=YOUR_SMS_TOKEN&account=%account[username]%&selector=%selector%&push_token=%pushTokenOther%&push_appid=%pushappid_other% |
| Method | GET |
| Everything else | leave blank |
Groundwire calls this on startup to register its push token. Once registered, receive.php will send a push notification via pnm.cloudsoftphone.com whenever a message arrives, waking the app. On iOS 13+, the notification will appear as an alert even if the app is backgrounded.
Set the inbound SMS target per number in the AAISP control panel. Use a separate token per number so each has independent credentials:
Number 1: https://your-domain.example.com/receive.php?token=YOUR_RECEIVE_TOKEN_1
Number 2: https://your-domain.example.com/receive.php?token=YOUR_RECEIVE_TOKEN_2
Important: Configure this at the per-number level only, not at the account level. Configuring it at both levels causes AAISP to deliver each message twice, resulting in duplicate push notifications.
AAISP retries webhook delivery on a fixed schedule (~30s, 30s, then longer). The proxy deduplicates by checking for identical sender, recipient, and message content within a 2-minute window, so retries are silently discarded.
-
Clone the repo and copy the env file:
git clone git@github.com:dannymcc/aaisp-sms-proxy.git cd aaisp-sms-proxy cp .env.example .env # Edit .env with your credentials and tokens
-
Create the data directory with correct permissions:
mkdir -p data sudo chown www-data:www-data data sudo chmod 750 data
-
Build and start:
docker compose up -d --build
-
Configure your reverse proxy (e.g. Caddy) to forward your domain to the container on port 80.
-
Open Groundwire to trigger push token registration, then send a test SMS.
The container has
restart: unless-stoppedso it will recover automatically from crashes and host reboots. The SQLite tables are created automatically on first use. Push tokens are re-registered by Groundwire the next time the app is opened.
-
Edit
.envand add:AAISP_44XXXXXXXXXX_USERNAME=+44XXXXXXXXXX AAISP_44XXXXXXXXXX_PASSWORD=thepassword RECEIVE_TOKEN_3=another-random-tokenNote: the key is the number without the leading + (e.g. 447911000000 for +447911000000)
-
Recreate the container to load the new env:
docker compose up -d --force-recreate -
In Groundwire, add the account and apply the same URLs above — no changes to the URLs needed.
-
In the AAISP control panel, set the per-number inbound SMS target using the new token.
health.php confirms the container and SQLite database are up:
https://your-domain.example.com/health.php
Returns {"status":"ok"} (HTTP 200) when healthy, or {"status":"error"} (HTTP 500) if the database is unreachable. Suitable for use with uptime monitors.
status.php shows messages currently queued in SQLite without deleting them:
https://your-domain.example.com/status.php?token=YOUR_SMS_TOKEN
Returns a JSON array of up to 20 pending messages. If a message appears here but not in Groundwire, wait for the next poll or open the Groundwire messages screen to trigger one immediately.
Rate limited to 10 requests per IP per minute.
A prune.php script deletes messages older than 14 days as a safety net for any that were never fetched. It is run nightly via cron:
0 3 * * * docker exec aaisp-sms-proxy sh -c 'php /var/www/html/prune.php >> /var/www/data/prune.log 2>&1'
It also clears stale rate_limit entries older than 1 day. Under normal operation, fetch.php deletes messages immediately after returning them to Groundwire, so the pruner should rarely find anything to remove.
Note on the redirect: the redirect (
>>) runs inside the container viash -c, not on the host. Thedata/directory on the host is owned bywww-data:www-data(mode 750), so a host-side redirect from a cron running as a regular user fails silently — the shell aborts beforedocker execruns, and nothing executes. Writing inside the container sidesteps this. The log file still appears at/home/danny/docker/aaisp-sms-proxy/data/prune.logon the host via the bind mount.
heartbeat.php sends a push notification to Groundwire once a day to confirm the server is up and running. It uses the NotifyGenericTextMessage verb, which delivers a single notification directly without writing to the database or triggering a poll — so no inbox entry is created and no badge count is shown.
The heartbeat only runs during daytime hours to avoid disturbing you at night. Defaults are 08:00–21:00 in the container's local timezone (Europe/London); adjust with HEARTBEAT_START_HOUR / HEARTBEAT_END_HOUR in .env. Set HEARTBEAT_ENABLED=false to disable entirely.
Run via cron at a fixed daytime hour (the script does its own window check as a safety net):
0 13 * * * docker exec aaisp-sms-proxy sh -c 'php /var/www/html/heartbeat.php >> /var/www/data/heartbeat.log 2>&1'
Same sh -c pattern as the pruner — see the note above for why the redirect runs inside the container.
The target account is auto-detected from the first AAISP_*_USERNAME in the environment. Override with HEARTBEAT_ACCOUNT=+44XXXXXXXXXX if needed. A push token must be registered (i.e. Groundwire must have been opened at least once) for the heartbeat to send.
The container mounts a custom Apache log format that strips query strings from access logs. This prevents tokens from appearing in log output. The format logs the request path (%U) without the query string, keeping everything else (IP, method, status, etc.) intact.
See .env.example for the full template.
Inbound messages are stored in SQLite only until Groundwire fetches them. Once delivered, they are deleted immediately. A nightly cron job prunes any messages older than 14 days as a safety net. No SMS content is retained indefinitely.
/
├── Dockerfile
├── docker-compose.yml
├── apache.conf custom log format (strips query strings from access logs)
├── php.ini disables display_errors, enables error logging
├── .env (not committed — copy from .env.example)
├── .env.example
├── config/
│ ├── index.php outbound SMS (Groundwire → AAISP)
│ ├── receive.php inbound webhook (AAISP → proxy, deduplicates retries)
│ ├── fetch.php message fetcher (Groundwire polls this — deletes on read)
│ ├── push_register.php stores Groundwire push tokens per account
│ ├── status.php read-only diagnostic view of pending messages (rate limited)
│ ├── health.php liveness check — returns {"status":"ok"} if DB is reachable
│ ├── prune.php deletes messages older than 14 days (run via cron)
│ └── heartbeat.php sends a daily push notification to confirm the server is running (run via cron)
└── data/ (not committed — created at runtime)
└── messages.db SQLite store for messages and push tokens
MIT. Use for anything, attribution appreciated. No warranty — use at your own risk.