npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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).

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 the InputView/InfoView response shapes. Qrios's richer features (ChooserView, MerchantPaymentProcess, cross-app Redirect) are deliberately not exposed through this adapter — see the doc comment in src/adapters/qrios.ts for 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 called ctx.end().
  • "external" — the aggregator told you the session closed from outside your handler logic. Your server glue code needs to call router.endExternalSession(sessionId, reason) when this happens — for Qrios, that's the /ussdSessionEvent/close and /abort webhooks (their documented reason.type values — End, Abandon, Timeout — map directly to the reason argument).
  • "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.sessionStore defaults to MemorySessionStore; options.onSessionEnd reports session duration/cost data; options.idleTimeoutMs (default 180_000, or false to disable) and options.sweepIntervalMs control 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 call ctx.reply(); tells the router which state handles the next input. Omitting it throws MissingTransitionError rather 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