Loading repository data…
Loading repository data…
alikhalidsherif / repository
Self-hosted SMS + USSD gateway for Huawei E5331/E5-series modems, designed as an adapter layer for n8n automation hubs.
A transparent discovery signal based on current public GitHub metadata.
This score does not audit code, security, maintainers, documentation quality, or suitability. Verify the repository and its current documentation before adoption.
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).
git clone https://github.com/alikhalidsherif/smsgate.git
cd smsgate
cp .env.example .env
.env and set at least:ADMIN_KEYROUTER_PASSdocker compose up -d --build
curl -s http://127.0.0.1:5000/routes
curl -s -H "X-Admin-Key: <ADMIN_KEY>" http://127.0.0.1:5000/health/modem
Optional: run n8n on the same Docker network:
docker compose -f docker-compose.n8n.yml up -d
Open n8n at http://127.0.0.1:5678.
/sms reads directly from the modem storage (live view, limited by modem inbox/sent slots)./sms/history reads from SQLite (DB_PATH) and represents your long-term history.In practice:
/sms when you need current modem state./sms/history for 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"
}
POST).WEBHOOK_URL (in .env or POST /config).POST each new event payload to n8n.Delivery reports are classified in code using a heuristic:
delivered, not delivered, delivery, failed to deliver<= 10 characters (typically short code / service sender)This is intentionally heuristic and may need adjustment for your carrier format.
SMSGate supports three USSD interaction models.
POST /ussd/send)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:
423 if another USSD session is active504 if no network response before timeout502 for modem/API errorsPOST /ussd/session)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 ..."
}
]
}
GET /ussd/live)Use this when menus branch dynamically and you want interactive control.
Server behavior:
{"status":"ready"}{"status":"busy","error":"Another USSD session is active"}Client message formats:
{"code":"*804#"} start session{"input":"1"} reply to menu{"action":"ping"} keepalive{"action":"cancel"} cancel sessionServer message formats:
{"status":"ready"}{"menu":"..."}{"status":"pong"}{"status":"cancelled"}{"status":"timeout","error":"..."}{"error":"..."}wscat example:
wscat -c ws://127.0.0.1:5000/ussd/live
Example 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())
POST /device/reboot)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:
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 thresholdGET /routes - endpoint discoveryGET /ussd/live - WebSocket USSD (no header auth; secure by network boundary)X-Admin-Key)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/rebootPOST /configRequest:
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": {}
}
POST /sms/sendRequest:
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
}
POST /sms/mark-read/<index>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"
}
POST /ussd/sendRequest:
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..."
}
POST /ussd/sessionRequest:
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 ..."
}
]
}
POST /device/rebootRequest:
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.jsonImport steps:
CHANGE_ME_ADMIN_KEY+2519XXXXXXXX)http://smsgate:5000 with reachable host URLX-Admin-Key or key is wrong. Add the header to every protected endpoint request and ensure it matches ADMIN_KEY./ussd/send, /ussd/session, and /ussd/live share the same modem session lock). Wait for current session to finish, or cancel the active live WebSocket session.http://smsgate:5000 only works when both containers are on smsgate-net. If n8n runs outside Docker (or different network), use host-reachable URL instead, e.g. http://<host-ip>:5000./health/modem shows consecutive_failures > 0, SMS poll interval is temporarily increased by backoff. This is expected resilience behavior, not a gateway crash. Track last_backoff_seconds, last_poll_success_at, and status for recovery.Required:
ADMIN_KEYROUTER_PASSCommonly 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_THROTTLESee .env.example for full defaults.
Syntax check:
uv run python -m py_compile gateway.py discover.py setup.py introspect.py
Build/run:
docker compose up -d --build
The container runs with Gunicorn (wsgi:app) instead of Flask dev server.
Logs:
docker logs -f smsgate
.envADMIN_KEY if exposed