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

@omoyolab/ussdkit

v0.3.0

Published

Build USSD apps in Node. Declarative screens, session handling, gateway adapters, a terminal phone simulator and a test helper, with zero dependencies.

Readme

ussdkit

Build USSD apps in Node without the boilerplate.

Declarative screens, session handling, gateway adapters, a phone simulator in your terminal and a test helper. Zero dependencies.

import { createApp, menu, prompt, end, createNodeHandler } from "@omoyolab/ussdkit";
import { createServer } from "node:http";

const app = createApp()
  .screen(
    "home",
    menu("Welcome to PayCo\n1. Send money\n2. Balance", { "1": "amount", "2": "balance" }),
  )
  .screen(
    "amount",
    prompt("Enter amount", (input) => {
      const amount = Number(input);
      if (!(amount > 0)) return { retry: "Enter a valid amount." };
      return { goto: "done", data: { amount } };
    }),
  )
  .screen("balance", end("Your balance is NGN 12,500"))
  .screen(
    "done",
    end(({ data }) => `Sent NGN ${data.amount}. Thank you.`),
  );

createServer(createNodeHandler(app)).listen(3000);

npm CI License: MIT


Why

USSD is how most of Africa banks, buys airtime and pays bills: dial *737#, get a text menu, press numbers. Every request from the network is stateless, so every team writing a USSD app ends up hand-rolling the same things: remembering where each phone number is in the menu, parsing the gateway's format, validating input, handling "0 to go back", and deploying to a server just to test it from a real handset.

ussdkit does those parts once, properly:

  • Screens, not state machines. Describe menus and prompts. Navigation, retries, back and home are handled for you.
  • Sessions that survive restarts. In-memory by default, Redis when you scale out, and automatic replay from the gateway's input history when a session is lost.
  • Gateway adapters. Africa's Talking today. Adding one is a 30-line file.
  • A phone in your terminal. Click through your flow while you build it. Dial any USSD endpoint, yours or someone else's, with ussdkit dial.
  • A test helper. testPhone(app) drives your app in Vitest or Jest with no HTTP.
  • Zero dependencies. Node 20+ and nothing else.

For a whole service built this way, see the mobile money use case: 27 screens, a PIN step, a mini statement, and what it found in ussdkit along the way.

Install

npm install @omoyolab/ussdkit

Quick start

Try the bundled example before writing your own:

git clone https://github.com/omoyolab/ussdkit && cd ussdkit && pnpm install && pnpm build
node examples/paybank.mjs --simulate
ussdkit simulator (local app)
Dialling *384*7000# from +2348012345678…
Type an answer and press Enter. Ctrl+C hangs up, like Cancel on a phone.

┌──────────────────────┐
│ Welcome to PayBank   │
│ 1. Send money        │
│ 2. Check balance     │
│ 3. Buy airtime       │
│ 4. Choose bank       │
└──────────────────────┘
> 1

┌──────────────────────┐
│ Enter amount to send │
└──────────────────────┘
> 2500

Type 0 to go back and 00 to go home. Ctrl+C ends the call, as Cancel does on a phone.

Or serve it and dial it over HTTP, exactly the way Africa's Talking would:

node examples/paybank.mjs                                  # terminal 1
npx @omoyolab/ussdkit dial http://localhost:3000/ussd      # terminal 2

Writing screens

A screen has render (what the phone shows) and optionally handle (what to do with the user's input). Five helpers cover almost everything:

import { menu, prompt, end, info, list, lines } from "@omoyolab/ussdkit";

// Numbered choices. Give a list and the menu numbers itself.
// Unknown input re-renders with "Invalid choice."
menu("Main menu", [
  ["Send", "send"], // go to a screen
  ["Balance", (ctx) => ({ end: "..." })], // or run code
]);

// Or write the text yourself and map each key.
menu(lines("Main menu", "1. Send", "*. Help"), { "1": "send", "*": "help" });

// Free text. Return where to go next, or retry with a message.
prompt("Enter amount", (input, ctx) => {
  const amount = Number(input);
  if (!(amount > 0)) return { retry: "Enter a valid amount." };
  return { goto: "confirm", data: { amount } };
});

// Final screen. The session ends after it is shown.
end(({ data }) => `Sent NGN ${data.amount}.`);

// Text that waits, with Back and Home still working. For About or Help.
info("PayCo moves money between any two Nigerian numbers.");

// A long list to choose from, in pages. See Long lists.
list("Choose your bank", BANKS, (bank) => ({ goto: "account", data: { bank } }), { filter: true });

render can be a string or a function of the context, sync or async, so you can hit your database or an API before showing a screen.

What a handler can return

| Return | Effect | | ------------------------------ | ---------------------------------------------------- | | { goto: "id", data?: {...} } | Move to a screen. data is merged into the session. | | { retry: "message" } | Show the same screen again with the message on top. | | { stay: true } | Show the same screen again, for turning a page. | | { end: "message" } | Show the message and end the session. | | { back: true } | Go to the previous screen. | | { home: true } | Go to the home screen and clear history. |

The context

Every render and handle receives:

{
  input: string;        // latest input, trimmed by menu() and prompt()
  phone: string;        // "+2348012345678"
  serviceCode: string;  // "*384*7000#"
  network?: string;     // network code when the gateway sends one
  screen: string;       // current screen id
  data: Partial<YourData>;        // survives across screens; mutate it freely
  session: Session;
  replaying: boolean;   // true while a lost session is being rebuilt, see Sessions
  room: number;         // characters left for this screen's own text
}

Room on the screen. ctx.room is the length limit less what ussdkit adds: the back and home hints, a retry message, and a menu's options. A screen with a long answer can choose a shorter form before it is too long:

render: (ctx) => {
  const full = withSeatNames(members);
  return full.length <= ctx.room ? full : withoutSeatNames(members);
},

Typed session data

Say what your screens store and ctx.data is typed everywhere, with no casts:

interface Flow {
  amount: number;
  recipient: string;
}

const app = createApp<Flow>()
  .screen(
    "amount",
    prompt("Enter amount", (input) => ({ goto: "to", data: { amount: Number(input) } })),
  )
  .screen(
    "to",
    prompt(({ data }) => `Send NGN ${data.amount} to which number?`, handle),
  );

Back and home

By default 0 goes back one screen and 00 returns home. Change or disable them:

createApp({ backKey: "#", homeKey: false });

The back key is only intercepted when there is somewhere to go back to, so a 0 on the home screen reaches your handler like any other input.

Tell the user. backHint adds a line to every screen the back key works on, so you do not write it on each one:

createApp({ backHint: "0. Back", homeHint: "00. Home" });

homeHint shows on screens two or more steps from home, where Back alone would take several presses. It shares the back hint's line: 0. Back 00. Home.

When 0 is an answer. A prompt that needs 0 as input turns the back key off for itself:

prompt("How many children do you have?", handle, { back: false });

Steps to pass through once. A PIN prompt should not be somewhere Back returns to. Mark it transient and Back from the next screen skips it:

prompt("Enter your PIN", checkPin, { transient: true });

Before the first screen

onStart runs once when a session begins. End the session at once for someone who should not get the menu, or start them somewhere other than home:

createApp({
  onStart: async ({ phone }) => {
    if (!(await isRegistered(phone))) return { end: "This number is not registered." };
    if (await hasPendingLoan(phone)) return { goto: "loan.status" };
  },
});

Long lists

Screens are limited to about 182 characters on most networks. list() shows a long list in numbered pages: 9 for more, 8 for the previous page. With filter: true the user can also type the first letters of any word to narrow it, and one match is chosen at once. Items can be loaded when the screen is drawn:

.screen(
  "lga",
  list(
    ({ data }) => `${data.state}: choose your LGA`,
    ({ data }) => api.lgas(data.state),
    (lga) => ({ goto: "result", data: { lga } }),
    { perPage: 6, filter: true },
  ),
)

The page and the filter belong to the screen. Back from the next screen returns to the same page; arriving from anywhere else starts on page one.

For a list you lay out yourself, paginate() splits it into pages with numbered items and More/Previous keys:

.screen("banks", {
  render: ({ data }) => `Choose a bank\n${paginate(BANKS, { page: data.page ?? 0 }).text}`,
  handle: ({ input, data }) => {
    const page = paginate(BANKS, { page: data.page ?? 0 });
    if (page.isNext(input)) { data.page = page.page + 1; return { stay: true }; } // 9
    if (page.isPrev(input)) { data.page = page.page - 1; return { stay: true }; } // 8
    const bank = page.select(input);
    return bank ? { end: `You chose ${bank}.` } : { retry: "Pick a number." };
  },
})

The previous-page key is 8, not 0, because 0 is the app's back key. For a list people read but do not pick from, such as a statement, pass numbered: false.

ussdkit warns (via onWarning) whenever a rendered screen exceeds maxLength.

Calling an API

render and handle can both be async, so a screen can wait on a database or an API. Two things matter on USSD that do not on the web:

  • Time. A network gives each reply only a few seconds. ussdkit warns when answering a request takes longer than slowMs, 3000 by default. Time out your own calls well inside that, and cache what changes rarely.
  • Failure. Load in the handler before moving on, so a failed call can end politely with { end: "Sorry, ..." }. For anything that still throws, onError turns the error into a closing message instead of a failed request:
createApp({
  slowMs: 2500,
  onError: (error) => {
    log(error);
    return "Sorry, something went wrong. Please dial again in a few minutes.";
  },
});

Return nothing from onError to let an error through. Without onError, errors reach the gateway handler as before.

Serving it

createNodeHandler(app) returns a (req, res) function. It reads form-encoded or JSON bodies itself, and uses req.body when a framework already parsed it.

// node:http
createServer(createNodeHandler(app)).listen(3000);

// Express
app.post("/ussd", createNodeHandler(ussdApp));

// Fastify, Hono, Koa: hand it the raw req/res, or call app.handle() directly

For anything else, app.handle(request) takes a plain UssdRequest and returns { text, end }. Use a gateway's parse and format to translate.

Handler errors never reach the phone as a stack trace. The user sees a friendly END message and onError receives the exception.

Sessions

Sessions live in memory by default, which is fine for one process. For more than one server, or to survive restarts, use Redis:

import Redis from "ioredis";
createApp({ store: createRedisStore(new Redis(process.env.REDIS_URL)) });

// node-redis
createApp({ store: createRedisStore(client, { client: "node-redis" }) });

Sessions expire after ttl seconds of inactivity (default 180). Any object with get, set and delete can be a store, so Postgres, DynamoDB or SQLite adapters are a few lines.

Replay. Africa's Talking resends every input the user has typed on each request. If ussdkit cannot find a session, it rebuilds one by replaying those inputs, so a redeploy or an expired session in the middle of a flow does not dump the user back to the home screen.

Replaying runs your handlers again for inputs they have already handled once. Anything that must happen only once has to check ctx.replaying:

prompt("Enter your PIN", (input, ctx) => {
  // During a replay, check the PIN without counting a wrong attempt a second time.
  const ok = ctx.replaying ? wallet.isPin(ctx.phone, input) : wallet.verifyPin(ctx.phone, input);
  return ok ? { goto: "menu" } : { retry: "Wrong PIN." };
});

The input the user has just typed is never a replay, so money moves and messages send exactly once.

Testing

import { testPhone } from "@omoyolab/ussdkit";
import { app } from "../src/app";

test("sends money", async () => {
  const phone = testPhone(app, { phone: "+2348012345678" });
  await phone.dial();
  expect(phone.screen).toContain("Welcome");
  await phone.type("1", "2500", "08012345678");
  expect(phone.screen).toMatch(/Send NGN 2500/);
  await phone.send("1");
  expect(phone.ended).toBe(true);
});

No server, no mocks, no gateway. It runs the same code path a real request would.

Two more things the test phone does:

await phone.loseSession(); // as a restart would. The next input makes ussdkit replay.
console.log(phone.transcript()); // the session so far, drawn as the simulator draws it

The ussdkit dial command

A phone in your terminal that speaks Africa's Talking's wire format to any URL:

npx @omoyolab/ussdkit dial http://localhost:3000/ussd --code "*384*1234#" --phone +254700000000

It works against any server built for that gateway, not only ussdkit apps, so you can use it to poke at an existing service too. Pipe input for scripted runs:

printf '1\n2500\n' | npx @omoyolab/ussdkit dial http://localhost:3000/ussd

Gateways

| Gateway | Request | Response | | ---------------- | -------------------------------------------------------------- | ------------------ | | Africa's Talking | form-encoded sessionId, serviceCode, phoneNumber, text | CON … or END … |

A gateway is an object with parse(request) → UssdRequest and format(response) → wire. See src/gateways/africastalking.ts, it is the whole file. Hubtel, Nalo, Arkesel and direct telco integrations are on the roadmap and marked as good first issues.

Roadmap

  • More gateways: Hubtel (Ghana), Nalo, Arkesel, and a generic JSON gateway.
  • Shared steps: one PIN or confirm screen used by several flows.
  • Secret inputs: keep PINs out of the simulator and transcripts.
  • Browser simulator with a phone frame you can share with non-developers.
  • ussdkit lint: flag screens over the length limit and unreachable screens.
  • SMS fallback: send the end screen as an SMS when a session times out.
  • Session analytics hooks: where do users drop off.
  • Python port.

Vote on these or propose others in Discussions.

Contributing

Gateway adapters, store adapters and real-world gotchas from specific networks are the most valuable contributions. See CONTRIBUTING.md.

License

MIT. ussdkit is not affiliated with Africa's Talking or any network operator.