ussd-router-plus
v0.0.1
Published
Express-style state router for USSD applications, with adapters for Africa's Talking and Qrios (confirmed against real API docs — see README for what's verified vs. not).
Maintainers
Readme
ussd-router
Express-style state router for USSD applications, with adapters for Africa's Talking and Qrios — the two aggregators whose developer docs were actually verifiable at the time this package was written.
import { UssdRouter, africasTalkingAdapter } from "ussd-router-plus";
import express from "express";
const router = new UssdRouter();
router.state("root", (ctx) => {
ctx.reply("Welcome\n1. Check balance\n2. Buy airtime");
ctx.next({ "1": "balance", "2": "buyAirtime" });
});
router.state("balance", async (ctx) => {
const bal = await getBalance(ctx.phoneNumber);
ctx.end(`Your balance is NGN ${bal}`);
});
router.state("buyAirtime", (ctx) => {
ctx.reply("Enter amount:");
ctx.next({ "*": "buyAirtime.amount" });
});
router.state("buyAirtime.amount", (ctx) => {
ctx.end(`You bought NGN ${ctx.input} airtime`);
});
const app = express();
app.use(express.json());
app.post("/ussd", async (req, res) => {
const normalized = africasTalkingAdapter.parseRequest(req.body);
const outcome = await router.handle(normalized);
res.type("text/plain").send(africasTalkingAdapter.formatResponse(outcome, normalized));
});
app.listen(3000);Why this exists, given ussd-builder and ussd-router (yes, same name) already exist
They do — this isn't an unclaimed idea. Research before building this turned
up at least four prior npm packages doing essentially the same thing
(ussd-builder, the original ussd-router, ussd-menu-builder, Ananse),
none of them released in the last 12+ months. The pattern itself (a
state-machine router for USSD menus) is well-trodden — this package doesn't
claim to have invented something new there.
What none of those four cover: Qrios, whose protocol is structurally
different from the Africa's Talking-style plain-text CON/END model every
existing package is built around. Qrios sends structured JSON across four
separate webhook endpoints (new/continue/close/abort) instead of one
endpoint with an accumulated text history. Supporting both under one
ctx.reply()/ctx.end() API — rather than making you learn either
protocol's raw shape — is the actual value-add here.
What's verified vs. not
This package's adapters are built directly against each aggregator's published, checkable API documentation — not guessed from marketing pages:
africasTalkingAdapter— matches the request/response shape documented at developers.africastalking.com/docs/ussd/overview and consistent across Africa's Talking's own engineering blog and multiple independent tutorials.qriosAdapter— matches deep.qrios.com's published developer guide exactly, including the four-endpoint session lifecycle and theInputView/InfoViewresponse shapes. Qrios's richer features (ChooserView,MerchantPaymentProcess, cross-appRedirect) are deliberately not exposed through this adapter — see the doc comment insrc/adapters/qrios.tsfor why, and call the Qrios API directly for flows that need them.
VAS2Nets, BergenHorn, and Shortcode Nigeria are Nigerian VAS aggregators
that came up during research as real, licensed alternatives to Africa's
Talking — but none had public developer API documentation this package could
verify. VAS2Nets has a GitHub SDK (VAS2NetsTechnologies/b2bapi-sdk) for
airtime/VTU/validation services, but no confirmed USSD-session request/
response schema was found. Rather than ship an adapter built on guessed field
names — which would compile, run, and silently misbehave — none is included.
If you have access to actual developer docs for any of these (or another
aggregator), see "Writing your own adapter" below; it's a small surface.
Writing your own adapter
An adapter is two functions:
import type { UssdAdapter } from "ussd-router-plus";
export const myAdapter: UssdAdapter<MyRawRequest, MyRawResponse> = {
parseRequest(rawBody) {
return {
sessionId: /* ... */,
phoneNumber: /* ... */,
isNewSession: /* ... */,
input: /* the single latest input token, not a full history */,
};
},
formatResponse(outcome, normalizedRequest) {
// outcome.shouldEnd tells you CON-vs-END (or your protocol's equivalent)
return /* whatever your aggregator expects back */;
},
};Everything else — routing, session state, handler logic — works unchanged once you have those two functions.
Session storage
MemorySessionStore (the default) is an in-process Map — fine for local
dev or a single server instance. Note that a Qrios integration specifically
requires server-side session state (its continue events only carry the
latest input, not a history), unlike a pure Africa's Talking integration,
which could in principle stay fully stateless by working off the accumulated
text field alone. If you run more than one server instance, implement
SessionStore against Redis (or similar) so all instances see the same
session:
import type { SessionStore, StoredSession } from "ussd-router-plus";
class RedisSessionStore implements SessionStore {
async get(sessionId: string): Promise<StoredSession | null> { /* ... */ }
async set(sessionId: string, session: StoredSession): Promise<void> { /* ... */ }
async delete(sessionId: string): Promise<void> { /* ... */ }
}
const router = new UssdRouter({ sessionStore: new RedisSessionStore() });Handling abandoned sessions
Not every session ends cleanly via ctx.end() or an aggregator's close
webhook — users abandon USSD flows constantly (call dropped, they got
distracted, low battery). Africa's Talking's protocol has no explicit
close/timeout notification at all, so without something actively cleaning
up, abandoned sessions using MemorySessionStore would just accumulate in
memory indefinitely.
UssdRouter handles this with a built-in idle-timeout sweep, on by default:
const router = new UssdRouter({
idleTimeoutMs: 180_000, // this IS the default -- shown for clarity
onSessionEnd(info) {
if (info.endedBy === "expired") {
console.log(`Session ${info.sessionId} went idle and was cleaned up`);
}
},
});Every ~idleTimeoutMs / 2 (minimum 10s), the router checks for sessions
that haven't seen a request in over idleTimeoutMs and reports them via
onSessionEnd with endedBy: "expired" before removing them.
Be clear-eyed about what this default actually is: 180 seconds (3
minutes) is a deliberately generous safety-net value, not a number confirmed
from any MNO or aggregator spec — research for this package found rough
figures in the 90–180 second range for actual USSD session timeouts, but
nothing consistent enough to treat as ground truth. This default errs long,
so it won't prematurely expire a session that's still legitimately in
progress; it trades slightly delayed cleanup for that safety. Tune
idleTimeoutMs down if you've confirmed your real sessions never run that
long and want tighter memory usage.
For a custom SessionStore (e.g. Redis): implement the optional
listStale(maxIdleMs) method to opt into this same sweep mechanism, or skip
it entirely and use your store's native expiry instead (Redis's EXPIRE, for
example) — a legitimate choice, but one tradeoff to know: onSessionEnd
won't fire for sessions your store expires on its own, since that happens
outside the router's visibility. Pass idleTimeoutMs: false to disable the
router's own sweep if you're relying on store-native expiry instead.
Call router.dispose() during graceful shutdown (or in tests) to stop the
sweep timer — though it's set to not keep your process alive by itself
(verified: a script that never calls dispose() still exits normally), so
this is about tidiness, not a requirement for scripts to exit.
Monitoring session cost
USSD aggregators bill in time-based ticks, so session duration directly maps
to cost. UssdRouter tracks this for you and reports it via onSessionEnd:
import { UssdRouter, estimateSessionCost } from "ussd-router-plus";
const router = new UssdRouter({
onSessionEnd(info) {
// info: { sessionId, phoneNumber, durationMs, endedBy, reason? }
const cost = estimateSessionCost(info.durationMs, {
tickSeconds: 20, // <-- confirm this on YOUR aggregator's contract/dashboard
costPerTick: 6.98, // <-- confirm this too — don't trust a number from a blog post
});
console.log(`Session ${info.sessionId} ran ${info.durationMs}ms, ~₦${cost.estimatedCost}`);
},
});estimateSessionCost deliberately ships with no default rate. Research
for this package turned up inconsistent figures across sources (~₦6.98 per
20-second tick was one; the exact structure varies by aggregator and
contract) — baking an unverified number in as a default would produce
confident-looking cost estimates that might just be wrong. Get the real
numbers from your aggregator and pass them in explicitly.
info.endedBy distinguishes three ways a session ends:
"handler"— a state handler calledctx.end()."external"— the aggregator told you the session closed from outside your handler logic. Your server glue code needs to callrouter.endExternalSession(sessionId, reason)when this happens — for Qrios, that's the/ussdSessionEvent/closeand/abortwebhooks (their documentedreason.typevalues —End,Abandon,Timeout— map directly to thereasonargument)."expired"— the router's own idle-timeout sweep concluded the session, since some aggregators (Africa's Talking) never send a close notification at all. See "Handling abandoned sessions" above for how this works and what its default timeout actually means.
Handlers can also read ctx.elapsedMs mid-session — useful for deciding
whether a slow lookup is still worth attempting given the ~90–180 second
hard session cap MNOs generally enforce (that figure itself varies by
network/aggregator; confirm it rather than hardcode against it).
API
new UssdRouter(options?)—options.sessionStoredefaults toMemorySessionStore;options.onSessionEndreports session duration/cost data;options.idleTimeoutMs(default180_000, orfalseto disable) andoptions.sweepIntervalMscontrol automatic abandoned-session cleanup (see above).router.dispose()— stops the idle-timeout sweep timer. Safe to call multiple times or when sweeping was never started.router.state(name, handler)— registers a handler for a named state.router.handle(normalizedRequest)— returns{ message, shouldEnd }.router.endExternalSession(sessionId, reason?)— call from your server glue code when the aggregator reports a session closing outside handler logic (see above).ctx.reply(message)— continue the session.ctx.end(message)— close the session.ctx.next({ input: stateName, "*": fallbackStateName })— required whenever you callctx.reply(); tells the router which state handles the next input. Omitting it throwsMissingTransitionErrorrather than silently stranding the session.ctx.data— plain object, persists across requests within a session.ctx.elapsedMs— milliseconds since the session started.ctx.phoneNumber/ctx.input/ctx.sessionId/ctx.isNewSession.estimateSessionCost(durationMs, { tickSeconds, costPerTick, freeSeconds? })— turns a duration into a cost estimate using rates you supply.
See src/types.ts for full type definitions.
License
MIT
