9.9 KiB
USSD Gateway (Sprint 2)
Africa Alert's USSD gateway lets Econet/NetOne subscribers pull grades, fees, and attendance from their handset — no smartphone, no internet.
Wire format
Econet and NetOne USSD gateways POST the same shape; we accept either
JSON or form-encoded bodies. The response is always plain text
(Content-Type: text/plain; charset=utf-8).
Request
| Field | Type | Notes |
|---|---|---|
sessionId |
string | Gateway-assigned conversation id. Same id reused across the whole dialogue. |
phoneNumber |
string | MSISDN. Stored on the session for audit; not validated against the user's registered phone. |
serviceCode |
string | e.g. *123#. Not used by the flow but logged. |
text |
string | Cumulative text the user has typed during the session, separated by *. The state machine reads the last segment only. |
Response
Plain text. Two response types:
CON <message>— continue the session; await the user's next input.END <message>— terminate the session; the gateway closes the dialogue.
State transitions
(initial)
┌──────────────────────────────────────────────────┐
│ ▼
[start] ── dial ──▶ [awaitingStudentId] ── valid id ──▶ [awaitingPin]
▲ │ │
│ │ invalid id │ correct PIN
│ ▼ ▼
└────── END "Invalid Student ID." [authenticated] ── 4 ──▶ END "Goodbye."
│
├─ 1 ──▶ END <grades text>
├─ 2 ──▶ END <fees text>
├─ 3 ──▶ END <attendance text>
│
└─ other ──▶ CON "Invalid option." + main menu
Within [authenticated], every menu choice ends the session
(terminal END). This keeps each query to one round-trip on the wire.
PIN security
- 4 digits exactly (validated by
/^\d{4}$/). - Stored as a bcrypt hash (
users.ussd_pin). - After 3 consecutive wrong PIN attempts,
users.ussd_locked_untilis set tonow() + 30 minutes. Subsequent attempts during the lock window returnEND Account is locked. Try again later.. - Counters reset on a successful PIN.
Demo seed: every existing student with role='student' and
is_deleted=0 gets the default PIN 1234 (bcrypt-hashed) from
migration 2026072900000000_ussd_pin.js.
Session management
In-memory Map<sessionId, SessionState> in
server/src/services/ussdSessionStore.js. Sessions auto-expire
after USSD_SESSION_TIMEOUT_MS (default 180000 ms = 3 min) of
inactivity, swept by a 60-second setInterval().unref().
Session state survives across requests within the timeout. After END, the session is destroyed and the next request with the same sessionId starts a fresh session.
Sessions are process-local. Server restarts drop in-flight sessions — the user just dials again. This is acceptable because USSD sessions are short-lived and per-conversation.
Authentication
If USSD_GATEWAY_SECRET env var is set, the controller rejects any
request whose X-Gateway-Secret header doesn't match. Empty value
= open endpoint (dev only).
Performance
- All handlers return in 3–10 ms in local dev (well under the 800 ms budget).
- One DB query per state transition; no N+1.
- bcrypt verify is the slow part (~30 ms) — still well inside the budget; only triggered on PIN attempts.
Example conversation
[Student dials *123#]
CON Welcome to NextGen Learning.
Enter Student ID:
[Types: 6]
CON Hello Nyasha.
Enter your 4-digit PIN:
[Types: 1234]
CON Main Menu:
1. Grades
2. Fees
3. Attendance
4. Exit
[Types: 1]
END Latest grades:
Mathematics: 98% (test)
Mathematics: 95% (test)
Mathematics: 92% (test)
[Types: 2 — new session]
END Fees balance: $0.00
0 overdue
[Types: 3 — new session]
END Last 30 days:
19/25 present (76%)
[Types: 4 — new session]
END Goodbye.
Postman / curl recipes
The text field is the cumulative input the user has typed. The state
machine parses the last segment, so for a 3-step flow you send three
separate POSTs with the same sessionId and accumulating text.
Initial session
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":""}'
# → CON Welcome to NextGen Learning.\nEnter Student ID:
Student ID
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":"6"}'
# → CON Hello Nyasha.\nEnter your 4-digit PIN:
PIN
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":"6*1234"}'
# → CON Main Menu:\n 1. Grades\n 2. Fees\n 3. Attendance\n 4. Exit
Grades
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":"6*1234*1"}'
# → END Latest grades:\n Mathematics: 98% (test)\n ...
Fees
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"def456","phoneNumber":"+263772222222","serviceCode":"*123#","text":"6*1234*2"}'
# → END Fees balance: $0.00\n 0 overdue
Attendance
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"ghi789","phoneNumber":"+263773333333","serviceCode":"*123#","text":"6*1234*3"}'
# → END Last 30 days:\n 19/25 present (76%)
Wrong PIN (1st of 3)
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"lock1","phoneNumber":"+26377","serviceCode":"*123#","text":"6*0000"}'
# → CON Wrong PIN. 2 attempt(s) left.\nEnter your 4-digit PIN:
Locked account
After 3 wrong PIN attempts in a row, the student's
ussd_locked_until is set to now() + 30 minutes. Subsequent
attempts return:
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"lock4","phoneNumber":"+26377","serviceCode":"*123#","text":"6*1234"}'
# → END Account is locked. Try again later.
Invalid menu option
curl -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"badmenu","phoneNumber":"+26377","serviceCode":"*123#","text":"6*1234*5"}'
# → CON Invalid option.\n 1. Grades\n 2. Fees\n 3. Attendance\n 4. Exit
Gateway-secret check (when USSD_GATEWAY_SECRET is set)
# Missing header → 401
curl -i -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" \
-d '{"sessionId":"x","text":""}'
# → HTTP/1.1 401 Unauthorized
# Wrong header → 401
curl -i -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" -H "X-Gateway-Secret: wrong" \
-d '{"sessionId":"x","text":""}'
# → HTTP/1.1 401 Unauthorized
# Correct header → 200 with USSD body
curl -i -X POST http://localhost:3001/api/ussd/callback \
-H "Content-Type: application/json" -H "X-Gateway-Secret: $USSD_GATEWAY_SECRET" \
-d '{"sessionId":"x","text":""}'
# → HTTP/1.1 200 OK, body: CON Welcome to NextGen Learning.\nEnter Student ID:
Ops endpoints
GET /api/ussd/_stats — returns { active_sessions, timeout_ms, auth_required }.
Useful for dashboards; not security-sensitive.
File map
| File | Purpose |
|---|---|
server/src/database/migrations/knex/2026072900000000_ussd_pin.js |
Schema: users.ussd_pin, _failed_attempts, _locked_until, _pin_set_at. Backfills 1234 for every demo student. |
server/src/services/ussdSessionStore.js |
In-memory session map + 60s sweep. |
server/src/services/ussdStateMachine.js |
Pure functional state machine; lookup-table dispatch. |
server/src/services/ussd.service.js |
DB façade — student lookup, PIN verify, grades/fees/attendance summaries. |
server/src/controllers/ussd.controller.js |
POST /api/ussd/callback + GET /api/ussd/_stats. |
server/src/index.js |
Mounts the controller at /api/ussd. |
Env vars
| Var | Default | Purpose |
|---|---|---|
USSD_SESSION_TIMEOUT_MS |
180000 |
Session inactivity timeout (ms). |
USSD_GATEWAY_SECRET |
empty | If set, requires matching X-Gateway-Secret header. |
Assumptions
- Student ID =
users.id(numeric). We chose numeric over roll-number / UID for uniqueness. Each school would need a printed card with the student's id, or a parent-friendly mapping (id-by-class). - One round-trip per query. Grades/fees/attendance are terminal ENDs. Pagination (top-N) is the response-length guard.
- No phone-number verification. The session's
phoneNumberis captured for audit but not matched againstusers.phone. Adding that would require parents to dial from the registered phone. - Process-local sessions. Restarting the server drops in-flight sessions; the user just dials again. Persistent session storage would add DB latency for no product value.
- No student self-service PIN reset. Admins reset PINs via direct SQL or a future admin endpoint. For dev, the seed migration sets
1234for every student. - English only. No localization yet.
- Locked = 30 minutes. Hardcoded; not configurable in this version.