SMSGate is a self-hosted SMS + USSD gateway for Huawei E5331/E5-series modems.
Use it as an adapter layer: n8n orchestrates your automations, and SMSGate handles the modem-level work (SMS, USSD, retries, polling, persistence, and health telemetry).
- SMS send API with delivery-report-aware history
- Live modem inbox reads and persistent SQLite history
- Three USSD modes: single-shot, automated multi-step, and live WebSocket
- Background polling and cleanup workers
- Webhook push for inbound SMS and delivery reports
- Modem health endpoint for monitoring and alerting
- Clone and configure:
git clone https://github.com/alikhalidsherif/smsgate.git
cd smsgate
cp .env.example .env- Edit
.envand set at least:
ADMIN_KEYROUTER_PASS
- Start SMSGate:
docker compose up -d --build- Smoke test:
curl -s http://127.0.0.1:5000/routes
curl -s -H "X-Admin-Key: <ADMIN_KEY>" http://127.0.0.1:5000/health/modemOptional: run n8n on the same Docker network:
docker compose -f docker-compose.n8n.yml up -dOpen n8n at http://127.0.0.1:5678.
/smsreads directly from the modem storage (live view, limited by modem inbox/sent slots)./sms/historyreads from SQLite (DB_PATH) and represents your long-term history.
In practice:
- Use
/smswhen you need current modem state. - Use
/sms/historyfor reporting, automation, auditing, and searchable historical data.
Set WEBHOOK_URL to receive inbound events from SMSGate.
SMSGate sends an HTTP POST when the poller processes a new unread modem message:
type: "sms_received"for regular inbound messagestype: "delivery_report"when the message matches delivery-report heuristic rules
{
"type": "sms_received",
"id": 101,
"phone": "+251911234567",
"content": "hello from customer",
"date": "2026-04-17 09:30:45",
"sms_type": "1"
}{
"type": "delivery_report",
"id": 102,
"phone": "994",
"content": "Delivered to +251911234567",
"date": "2026-04-17 09:31:05",
"sms_type": "7"
}- In n8n, add a Webhook node (method
POST). - Copy its production URL.
- Set that URL as
WEBHOOK_URL(in.envorPOST /config). - Activate workflow; SMSGate will
POSTeach new event payload to n8n.
Delivery reports are classified in code using a heuristic:
- Content contains one of:
delivered,not delivered,delivery,failed to deliver - Sender address length is
<= 10characters (typically short code / service sender)
This is intentionally heuristic and may need adjustment for your carrier format.
SMSGate supports three USSD interaction models.
Best for one request/one response interactions.
Request:
curl -s -X POST http://127.0.0.1:5000/ussd/send \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "Content-Type: application/json" \
-d '{"code":"*804#"}'Success response:
{
"code": "*804#",
"response": "Your menu text..."
}Busy/timeout/error examples:
423if another USSD session is active504if no network response before timeout502for modem/API errors
Provide an ordered steps array. SMSGate sends each step and waits for response before next.
Ethio Telecom style example (*804# then choose option 1):
curl -s -X POST http://127.0.0.1:5000/ussd/session \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "Content-Type: application/json" \
-d '{"steps":["*804#","1"]}'Example response:
{
"steps_run": 2,
"history": [
{
"step": 1,
"input": "*804#",
"response": "Welcome ..."
},
{
"step": 2,
"input": "1",
"response": "Your balance is ..."
}
]
}Use this when menus branch dynamically and you want interactive control.
Server behavior:
- Only one active USSD session at a time
- New connection receives
{"status":"ready"} - If busy:
{"status":"busy","error":"Another USSD session is active"} - Idle timeout is 120 seconds -> server sends timeout message and ends session
- On disconnect/error, lock is released automatically
Client message formats:
{"code":"*804#"}start session{"input":"1"}reply to menu{"action":"ping"}keepalive{"action":"cancel"}cancel session
Server message formats:
{"status":"ready"}{"menu":"..."}{"status":"pong"}{"status":"cancelled"}{"status":"timeout","error":"..."}{"error":"..."}
wscat example:
wscat -c ws://127.0.0.1:5000/ussd/liveExample exchange:
< {"status":"ready","message":"Send {\"code\": \"*XXX#\"} to begin"}
> {"code":"*804#"}
< {"menu":"Welcome ..."}
> {"input":"1"}
< {"menu":"Your balance is ..."}
> {"action":"cancel"}
< {"status":"cancelled"}
Minimal Python WebSocket client example:
import asyncio
import json
import websockets
async def main():
async with websockets.connect("ws://127.0.0.1:5000/ussd/live") as ws:
print(await ws.recv())
await ws.send(json.dumps({"code": "*804#"}))
print(await ws.recv())
await ws.send(json.dumps({"input": "1"}))
print(await ws.recv())
await ws.send(json.dumps({"action": "cancel"}))
print(await ws.recv())
asyncio.run(main())Use this only for recovery operations. It requires explicit confirmation header and will make modem unreachable briefly.
curl -s -X POST http://127.0.0.1:5000/device/reboot \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "X-Confirm: yes"Response:
{
"result": "OK",
"note": "Modem will be unreachable for ~30 seconds"
}The cleanup worker runs in the background and protects modem storage.
What it does each cycle:
- Reads modem messages
- Stores messages into SQLite first
- Deletes from modem by age and/or overflow thresholds
Controls:
CLEANUP_INTERVAL-> run frequency (seconds)MODEM_MESSAGE_MAX_AGE-> delete modem messages older than N daysMODEM_MAX_THRESHOLD-> if modem still over limit, delete oldest until under threshold
GET /routes- endpoint discoveryGET /ussd/live- WebSocket USSD (no header auth; secure by network boundary)
GET /configPOST /configGET /health/modemGET /smsGET /sms/historyGET /sms/unread/countGET /sms/<index>POST /sms/sendGET /sms/sentPOST /sms/mark-read/<index>DELETE /sms/<index>DELETE /sms/inbox/allPOST /ussd/sendPOST /ussd/sessionGET /device/infoPOST /device/reboot
Request:
curl -s -X POST http://127.0.0.1:5000/config \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "Content-Type: application/json" \
-d '{"poll_interval":20,"webhook_url":"http://n8n:5678/webhook/smsgate-events"}'Response:
{
"updated": {
"poll_interval": 20,
"webhook_url": "http://n8n:5678/webhook/smsgate-events"
},
"errors": {}
}Request:
curl -s -X POST http://127.0.0.1:5000/sms/send \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "Content-Type: application/json" \
-d '{"to":"+2519XXXXXXXX","message":"hello from smsgate","delivery_report":true}'Response:
{
"result": "OK",
"to": ["+2519XXXXXXXX"],
"message": "hello from smsgate",
"delivery_report": true
}Request:
curl -s -X POST http://127.0.0.1:5000/sms/mark-read/20052 \
-H "X-Admin-Key: <ADMIN_KEY>"Response:
{
"index": 20052,
"result": "OK"
}Request:
curl -s -X POST http://127.0.0.1:5000/ussd/send \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "Content-Type: application/json" \
-d '{"code":"*804#"}'Response:
{
"code": "*804#",
"response": "Your menu text..."
}Request:
curl -s -X POST http://127.0.0.1:5000/ussd/session \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "Content-Type: application/json" \
-d '{"steps":["*804#","1"]}'Response:
{
"steps_run": 2,
"history": [
{
"step": 1,
"input": "*804#",
"response": "Welcome ..."
},
{
"step": 2,
"input": "1",
"response": "Your balance is ..."
}
]
}Request:
curl -s -X POST http://127.0.0.1:5000/device/reboot \
-H "X-Admin-Key: <ADMIN_KEY>" \
-H "X-Confirm: yes"Response:
{
"result": "OK",
"note": "Modem will be unreachable for ~30 seconds"
}Starter files in n8n-workflows/:
01-health-monitor-alert.json02-send-sms-webhook.json03-inbox-sync.json
Import steps:
- n8n UI -> Workflows -> Import from File
- Pick one JSON file
- Replace
CHANGE_ME_ADMIN_KEY - Replace phone placeholders (
+2519XXXXXXXX) - If n8n is outside Docker network, replace
http://smsgate:5000with reachable host URL - Save, run once manually, then activate
- 401 Unauthorized: request is missing
X-Admin-Keyor key is wrong. Add the header to every protected endpoint request and ensure it matchesADMIN_KEY. - 423 Busy: another USSD session is already active (
/ussd/send,/ussd/session, and/ussd/liveshare the same modem session lock). Wait for current session to finish, or cancel the active live WebSocket session. - Connection timeout from n8n to gateway:
http://smsgate:5000only works when both containers are onsmsgate-net. If n8n runs outside Docker (or different network), use host-reachable URL instead, e.g.http://<host-ip>:5000. - Poller backoff looks like "slow polling": if
/health/modemshowsconsecutive_failures > 0, SMS poll interval is temporarily increased by backoff. This is expected resilience behavior, not a gateway crash. Tracklast_backoff_seconds,last_poll_success_at, andstatusfor recovery.
Required:
ADMIN_KEYROUTER_PASS
Commonly tuned:
APP_HOST,APP_PORTDB_PATHROUTER_URL,ROUTER_USERWEBHOOK_URLPOLL_INTERVAL,CLEANUP_INTERVALMODEM_MAX_THRESHOLD,MODEM_MESSAGE_MAX_AGEMODEM_CONNECT_TIMEOUT,MODEM_READ_TIMEOUTMODEM_CONNECT_RETRIES,MODEM_RETRY_BACKOFFMODEM_FORCE_CONNECTION_CLOSEPOLL_BACKOFF_MAX,POLL_ERROR_LOG_THROTTLE
See .env.example for full defaults.
Syntax check:
uv run python -m py_compile gateway.py discover.py setup.py introspect.pyBuild/run:
docker compose up -d --buildThe container runs with Gunicorn (wsgi:app) instead of Flask dev server.
Logs:
docker logs -f smsgate- Never commit
.env - Rotate
ADMIN_KEYif exposed - Keep gateway private (VPN/Tailscale/reverse proxy)
- Treat modem management endpoints as privileged operations
MIT. See LICENSE.