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-interpreter-x

v1.0.1

Published

Stateless USSD session interpreter with async pipeline support

Readme

ussd-interpreter

npm license standard

A stateless USSD session interpreter. Turns a raw input string like "1*0724000001*500*1" into a resolved session — current state, captured data, and the screen to render — with zero dependencies.

npm install ussd-interpreter

Contents


How it works

USSD gateways send the full history of what a user has typed on every request, as a single *-separated string. The interpreter replays that string through your state graph on each request to reconstruct exactly where the user is and what they have entered — no session store, no database, no cache.

"1*0724000001*500*1"
 │  │           │   └─ confirmed
 │  │           └───── entered amount
 │  └───────────────── entered account number
 └──────────────────── selected Transfer

Quick start

import { UssdInterpreter } from 'ussd-interpreter'

// Define states bottom-up — leaf states first, menus last

const success = UssdInterpreter.terminalState({
  id:     'success',
  render: (data) => `KES ${data.amount} sent to ${data.accountNumber}.`
})

const enterAmount = UssdInterpreter.dataState({
  id:         'enter_amount',
  captureKey: 'amount',
  render:     () => 'Enter amount (KES):',
  validate:   (token) => {
    const n = Number(token)
    if (isNaN(n) || n <= 0) return 'Enter a valid amount.'
    if (n > 150000) return 'Exceeds daily limit.'
    return true
  },
  transform: (token) => Number(token),
  next: success
})

const enterAccount = UssdInterpreter.dataState({
  id:         'enter_account',
  captureKey: 'accountNumber',
  render:     () => 'Enter recipient account number:',
  validate:   (token) => /^\d{10}$/.test(token) ? true : 'Must be 10 digits.',
  next:       enterAmount
})

const mainMenu = UssdInterpreter.navigationState({
  id:          'main_menu',
  render:      () => 'Welcome\n1. Send Money',
  allowBack:   false,
  transitions: { '1': enterAccount }
})

const interpreter = new UssdInterpreter(mainMenu)

app.post('/ussd', (req, res) => {
  const session = interpreter.resolve(req.body.text)
  res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})

Africa's Talking note: The CON/END prefix is your responsibility — the interpreter exposes session.isEnded so your handler can decide. This keeps the library gateway-agnostic.


State types

navigationState — menu screens

Handles option selection. "0" triggers back, "00" jumps to root (both configurable via allowBack and allowRoot).

const mainMenu = UssdInterpreter.navigationState({
  id:          'main_menu',
  render:      () => 'Welcome\n1. Transfer\n2. Balance',
  transitions: { '1': transferFlow, '2': balanceFlow },
  allowBack:   false,   // root menu — disable back
  allowRoot:   false,
  label:       () => 'User opened the main menu.',               // optional — see Session stories
  onArrive:    async ({ gateway, session }) => ({ ... })         // optional — see Async data
})

transitions can also be a lazy function (token, data) => state | null. See Circular references and lazy transitions.

dataState — free-text input

Captures raw user input. "0" is never treated as back here — it is always data.

const enterPin = UssdInterpreter.dataState({
  id:         'enter_pin',
  captureKey: 'pin',                    // stored in session.data.pin
  render:     () => 'Enter your 4-digit PIN:',
  validate:   (token) => /^\d{4}$/.test(token) ? true : 'PIN must be 4 digits.',
  transform:  (token) => token,         // optional — runs before storing
  next:       confirmState,             // or a function: (value, data) => state
  label:      (data) => 'PIN entered.', // optional — see Session stories
  onArrive:   async ({ gateway, session }) => ({ ... }) // optional — see Async data
})

confirmationState — yes/no prompt

const confirm = UssdInterpreter.confirmationState({
  id:           'confirm_transfer',
  render:       (data) => `Send KES ${data.amount} to ${data.accountNumber}?\n1. Confirm\n2. Cancel`,
  on:           { confirm: successState, cancel: cancelledState },
  confirmToken: '1', // default
  cancelToken:  '2', // default
  label:        (data) => `Reviewed transfer of KES ${data.amount}.`, // optional
  onArrive:     async ({ gateway, session }) => ({ ... })               // optional
})

terminalState — end of session

The session ends here. session.isEnded is true, your handler sends END. onArrive() is especially useful on terminal states for fetching receipt data before the final screen.

const transferSuccess = UssdInterpreter.terminalState({
  id:       'transfer_success',
  render:   (data) => `Done! Ref: ${data.ref}`,
  label:    (data) => `Transfer of KES ${data.amount} completed.`, // optional
  onArrive: async ({ gateway, session }) => ({
    ref: await generateTransactionRef(session.accountNumber, session.amount)
  })
})

routerState — silent branching

Routes to a different state based on captured data without consuming a user token. Resolved automatically whenever it reaches the top of the navigation stack — the user sees nothing and presses nothing. Does not support label or onArrive.

const accountTypeRouter = UssdInterpreter.routerState({
  id:    'account_type_router',
  route: (data) => data.accountType === 'savings' ? savingsMenu : currentMenu
})

paginationState — long content

Splits long text across multiple screens with automatic page indicators and navigation. See Pagination for full documentation.

const terms = UssdInterpreter.paginationState({
  id:      'terms',
  content: 'Full terms of service text here...',
  pageSize: 160,
  on:      { accept: nextState, cancel: cancelledState },
  label:   () => 'User read the terms and conditions.' // optional
})

Multiple flows

A USSD service typically has several independent flows — signup, transfer, balance check, KYC — all accessible from a shared root menu. Each flow is just a subgraph of states. You compose them by connecting them to the root menu as transitions.

root menu
├── 1 → signup flow
│         ├── under 18   → END (age_blocked)
│         └── success    → END (signup_success)
├── 2 → transfer flow
│         ├── no balance → END (insufficient_funds)
│         └── success    → END (transfer_success)
└── 3 → kyc flow
          ├── fail        → END (kyc_failed)
          └── success     → END (kyc_success)

Every early exit is a terminalState. The interpreter treats a rejection message identically to a success message — session.isEnded is true and your handler sends END either way.

Example — three flows, one interpreter

import { UssdInterpreter } from 'ussd-interpreter'

// ── KYC flow ──────────────────────────────────────────────────────────────────

const kycFailed = UssdInterpreter.terminalState({
  id:     'kyc_failed',
  render: () => 'Verification failed. Please visit a branch.'
})

const kycSuccess = UssdInterpreter.terminalState({
  id:     'kyc_success',
  render: () => 'Identity verified successfully.'
})

const enterIdNumber = UssdInterpreter.dataState({
  id:         'enter_id_number',
  captureKey: 'idNumber',
  render:     () => 'Enter your ID number:',
  validate:   (token) => /^\d{8}$/.test(token) ? true : 'ID must be 8 digits.',
  onArrive:   async ({ gateway, session }) => {
    const result = await verifyKyc(session.idNumber)
    return { kycPassed: result.passed }
  },
  next: UssdInterpreter.routerState({
    id:    'kyc_router',
    route: (data) => data.kycPassed ? kycSuccess : kycFailed
  })
})

// ── Transfer flow ─────────────────────────────────────────────────────────────

const insufficientFunds = UssdInterpreter.terminalState({
  id:     'insufficient_funds',
  render: (data) => `Insufficient balance. Available: KES ${data.balance}.`
})

const transferSuccess = UssdInterpreter.terminalState({
  id:     'transfer_success',
  render: (data) => `KES ${data.amount} sent to ${data.accountNumber}.`
})

const confirmTransfer = UssdInterpreter.confirmationState({
  id:     'confirm_transfer',
  render: (data) => `Send KES ${data.amount} to ${data.accountNumber}?\n1. Confirm\n2. Cancel`,
  on:     { confirm: transferSuccess, cancel: mainMenu } // mainMenu defined below — use lazy if in same file
})

const enterAmount = UssdInterpreter.dataState({
  id:         'enter_amount',
  captureKey: 'amount',
  render:     (data) => `Available: KES ${data.balance}\nEnter amount:`,
  validate:   (token, data) => {
    const n = Number(token)
    if (isNaN(n) || n <= 0) return 'Enter a valid amount.'
    if (n > data.balance)   return 'Amount exceeds your balance.'
    return true
  },
  transform: (token) => Number(token),
  next: confirmTransfer
})

const enterAccount = UssdInterpreter.dataState({
  id:         'enter_account',
  captureKey: 'accountNumber',
  render:     () => 'Enter recipient account number:',
  validate:   (token) => /^\d{10}$/.test(token) ? true : 'Must be 10 digits.',
  onArrive:   async ({ gateway, session }) => {
    const { balance, hasBalance } = await fetchBalance(gateway.phoneNumber)
    return { balance, insufficientFunds: !hasBalance }
  },
  next: UssdInterpreter.routerState({
    id:    'balance_router',
    route: (data) => data.insufficientFunds ? insufficientFunds : enterAmount
  })
})

// ── Signup flow ───────────────────────────────────────────────────────────────

const ageBlocked = UssdInterpreter.terminalState({
  id:     'age_blocked',
  render: () => 'Sorry, you must be 18 or older to register.'
})

const signupSuccess = UssdInterpreter.terminalState({
  id:     'signup_success',
  render: (data) => `Welcome, ${data.name}! Your account is ready.`
})

const enterName = UssdInterpreter.dataState({
  id:         'enter_name',
  captureKey: 'name',
  render:     () => 'Enter your full name:',
  validate:   (token) => token.trim().length >= 3 ? true : 'Name too short.',
  next:       signupSuccess
})

const enterDob = UssdInterpreter.dataState({
  id:         'enter_dob',
  captureKey: 'dob',
  render:     () => 'Enter date of birth (DDMMYYYY):',
  validate:   (token) => /^\d{8}$/.test(token) ? true : 'Use format DDMMYYYY.',
  next: UssdInterpreter.routerState({
    id:    'age_router',
    route: (data) => {
      const year = parseInt(data.dob.slice(4, 8))
      const age  = new Date().getFullYear() - year
      return age < 18 ? ageBlocked : enterName
    }
  })
})

// ── Root menu — one entry point for all flows ─────────────────────────────────

const mainMenu = UssdInterpreter.navigationState({
  id:          'main_menu',
  render:      () => 'Welcome\n1. Sign Up\n2. Transfer Money\n3. Verify Identity',
  allowBack:   false,
  transitions: {
    '1': enterDob,       // → signup flow
    '2': enterAccount,   // → transfer flow
    '3': enterIdNumber   // → KYC flow
  }
})

const interpreter = new UssdInterpreter(mainMenu)

app.post('/ussd', async (req, res) => {
  const { sessionId, phoneNumber, text } = req.body
  const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })
  res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})

Key points

Every early exit is just a terminalState. There is no special "abort" or "early end" mechanism. A rejection message and a success message are both terminal states — session.isEnded is true and your handler sends END either way.

A routerState is the idiomatic way to branch on dynamic conditions. After onArrive() populates the data bag with a result like kycPassed or insufficientFunds, a router immediately redirects to the appropriate state — without the user pressing anything.

Flows are isolated by default. Data captured in the transfer flow is scoped to that session's frame stack. It does not leak into the signup or KYC flows.

One interpreter, one root. You always create a single UssdInterpreter instance per service. The root menu's transitions are the entry points into each flow.


Nested flows

A USSD service with multiple layers of menus uses the same three building blocks at every level. A navigationState is a menu. A dataState is a step. A terminalState is an exit. The pattern is identical whether you are one level deep or four.

root menu
├── 1 → accounts flow
│         ├── 1 → savings sub-flow
│         │         ├── 1 → deposit  → END
│         │         └── 2 → withdraw → END
│         └── 2 → loans sub-flow
│                   ├── 1 → apply    → END
│                   └── 2 → repay    → END
├── 2 → payments flow
│         ├── 1 → send money sub-flow
│         │         ├── 1 → mobile   → END
│         │         └── 2 → bank     → END
│         └── 2 → buy goods sub-flow
│                   ├── 1 → till     → END
│                   └── 2 → paybill  → END
└── 3 → services flow
          ├── 1 → KYC sub-flow       → END
          └── 2 → statements         → END

Code structure

Build bottom-up — leaf states first, menus last. Each level is a navigationState whose transitions point to either another menu or the first state of a sub-flow:

// ── Leaf states (defined first) ───────────────────────────────────────────────

const depositSuccess  = UssdInterpreter.terminalState({ id: 'deposit_success',  render: () => 'Deposit successful.' })
const withdrawSuccess = UssdInterpreter.terminalState({ id: 'withdraw_success', render: () => 'Withdrawal successful.' })
const loanApplied     = UssdInterpreter.terminalState({ id: 'loan_applied',     render: () => 'Application submitted.' })

// ... data states for each sub-flow (enterDepositAmount, enterWithdrawAmount, etc.)

// ── Sub-flow menus ────────────────────────────────────────────────────────────

const savingsMenu = UssdInterpreter.navigationState({
  id:          'savings_menu',
  render:      () => 'Savings\n1. Deposit\n2. Withdraw\n0. Back',
  transitions: { '1': enterDepositAmount, '2': enterWithdrawAmount }
})

const loansMenu = UssdInterpreter.navigationState({
  id:          'loans_menu',
  render:      () => 'Loans\n1. Apply\n2. Repay\n0. Back',
  transitions: { '1': enterLoanAmount, '2': enterRepayAmount }
})

// ── Root flow menus ───────────────────────────────────────────────────────────

const accountsMenu = UssdInterpreter.navigationState({
  id:          'accounts_menu',
  render:      () => 'Accounts\n1. Savings\n2. Loans\n0. Back',
  transitions: { '1': savingsMenu, '2': loansMenu }
})

const paymentsMenu = UssdInterpreter.navigationState({
  id:          'payments_menu',
  render:      () => 'Payments\n1. Send Money\n2. Buy Goods\n0. Back',
  transitions: { '1': sendMoneyMenu, '2': buyGoodsMenu }
})

const servicesMenu = UssdInterpreter.navigationState({
  id:          'services_menu',
  render:      () => 'Services\n1. KYC\n2. Statements\n0. Back',
  transitions: { '1': enterIdNumber, '2': enterStatementPeriod }
})

// ── Root menu ─────────────────────────────────────────────────────────────────

const mainMenu = UssdInterpreter.navigationState({
  id:          'main_menu',
  render:      () => 'Welcome\n1. Accounts\n2. Payments\n3. Services',
  allowBack:   false,
  transitions: { '1': accountsMenu, '2': paymentsMenu, '3': servicesMenu }
})

const interpreter = new UssdInterpreter(mainMenu)

Back navigation works automatically at every level. A user inside savingsMenu pressing "0" returns to accountsMenu. A user inside accountsMenu pressing "0" returns to mainMenu. The interpreter manages this through the frame stack — no special handling needed per level.

Organising the code

With nested flows, keeping everything in one file becomes unwieldy. The natural structure is one file per root flow, with each flow exporting only its entry state:

src/
  flows/
    accounts/
      index.js        ← exports accountsMenu
      savings.js      ← savingsMenu + its states
      loans.js        ← loansMenu + its states
    payments/
      index.js        ← exports paymentsMenu
      send-money.js
      buy-goods.js
    services/
      index.js        ← exports servicesMenu
      kyc.js
      statements.js
  menu.js             ← mainMenu — imports from each flow's index.js
  interpreter.js      ← creates and exports the UssdInterpreter instance

menu.js only knows about entry states — not internals:

// menu.js
import { accountsMenu } from './flows/accounts/index.js'
import { paymentsMenu } from './flows/payments/index.js'
import { servicesMenu } from './flows/services/index.js'

export const mainMenu = UssdInterpreter.navigationState({
  id:          'main_menu',
  render:      () => 'Welcome\n1. Accounts\n2. Payments\n3. Services',
  allowBack:   false,
  transitions: { '1': accountsMenu, '2': paymentsMenu, '3': servicesMenu }
})

Circular references and lazy transitions

When you build a flow bottom-up, you sometimes need a state that was defined after the one referencing it. This is a circular reference problem — JavaScript evaluates files top to bottom, so a variable that hasn't been defined yet is undefined at the point it is referenced.

Consider this:

const savingsMenu = UssdInterpreter.navigationState({
  id:          'savings_menu',
  render:      () => 'Savings\n1. Deposit\n2. Withdraw',
  transitions: {
    '1': enterDepositAmount,  // ❌ undefined — declared below
    '2': enterWithdrawAmount  // ❌ undefined — declared below
  }
})

const enterDepositAmount  = UssdInterpreter.dataState({ ... })
const enterWithdrawAmount = UssdInterpreter.dataState({ ... })

When JavaScript evaluates transitions: { '1': enterDepositAmount }, it reads the current value of enterDepositAmount — which is undefined because the const declaration hasn't been reached yet. The object is built immediately with undefined baked in. By the time a user presses "1", it is too late — the transitions map was already frozen with undefined values.

The fix: a lazy transition function.

Instead of a plain object — evaluated immediately at definition time — pass a function. A function is not called until the moment a user presses a key. By that time, every variable in the file has been defined:

const savingsMenu = UssdInterpreter.navigationState({
  id:     'savings_menu',
  render: () => 'Savings\n1. Deposit\n2. Withdraw',

  // Called at navigation time — all variables defined by then
  transitions: (token) => {
    if (token === '1') return enterDepositAmount   // ✓ defined by now
    if (token === '2') return enterWithdrawAmount  // ✓ defined by now
    return null
  }
})

const enterDepositAmount  = UssdInterpreter.dataState({ ... })
const enterWithdrawAmount = UssdInterpreter.dataState({ ... })

Why "lazy"? A plain object is eager — it resolves all its values the moment it is created. A function is lazy — it resolves nothing until it is called. Passing a function defers the lookup to the moment it is actually needed.

When you need it. You only need lazy transitions when a menu references states defined below it in the same file. The cleanest way to avoid it entirely is to build strictly bottom-up: leaf states first, menus last, and each menu only referencing things already defined above it.

Dynamic exits

Some exits are not known at definition time. A sub-flow may terminate differently depending on data fetched mid-flow — a balance check, an eligibility result, an API response. Use onArrive() to fetch the condition and a routerState to branch on it:

const withdrawBlocked = UssdInterpreter.terminalState({
  id:     'withdraw_blocked',
  render: (data) => `Insufficient funds. Available: KES ${data.balance}.`
})

const withdrawSuccess = UssdInterpreter.terminalState({
  id:     'withdraw_success',
  render: (data) => `KES ${data.amount} withdrawn successfully.`
})

const enterWithdrawAmount = UssdInterpreter.dataState({
  id:         'enter_withdraw_amount',
  captureKey: 'amount',
  render:     (data) => `Available: KES ${data.balance}\nEnter amount:`,
  validate:   (token, data) => {
    const n = Number(token)
    if (isNaN(n) || n <= 0) return 'Enter a valid amount.'
    if (n > data.balance)   return 'Amount exceeds your balance.'
    return true
  },
  transform: (token) => Number(token),
  onArrive:  async ({ gateway, session }) => {
    const { balance } = await fetchBalance(gateway.phoneNumber)
    return { balance }
  },
  next: UssdInterpreter.routerState({
    id:    'withdraw_router',
    route: (data) => data.balance <= 0 ? withdrawBlocked : withdrawConfirm
  })
})

The exit — withdrawBlocked or withdrawConfirm — is determined at runtime from live data, not hardcoded into the flow definition. This pattern scales to any condition: eligibility checks, KYC results, loan limits, account status.

Advisory: As flows grow deeper and exits become more dynamic, the single biggest risk is losing track of which states can reach which terminals. Keep each sub-flow's entry state and all its internal states in the same file. Document the possible exits at the top of each flow file as a comment. A flow that can end in five different ways with no documentation becomes very hard to reason about — especially when onArrive() results change what those exits are at runtime.


Async data — onArrive() and resolveAsync()

When a state needs external data before it can render — a live balance, a transaction reference, a KYC result — declare an onArrive() async function on the state. It receives { gateway, session } and must return a plain object. Its keys are merged into the session data available to render().

onArrive() runs when the user arrives at a screen. Data captured on the previous screen is in session. Data the user is about to enter on the current screen is not yet available — they haven't submitted it yet.

gateway contains whatever your USSD gateway sent — phoneNumber, sessionId, or any other fields. For all other state functions (render, validate, label), the data bag is flat — gateway and session keys merged together.

const showBalance = UssdInterpreter.terminalState({
  id:       'show_balance',
  render:   (data) => `Your balance is KES ${data.balance}`,
  onArrive: async ({ gateway, session }) => {
    const { balance } = await fetchBalance(gateway.phoneNumber)
    return { balance }
  }
})

Call resolveAsync() instead of resolve() in your handler, passing whatever your gateway sends:

app.post('/ussd', async (req, res) => {
  const { sessionId, phoneNumber, text } = req.body
  const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })
  res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})

Priority rule: user-captured data always wins over onArrive() on key conflict. onArrive() can only fill in keys that don't already exist in the data bag.


Session store — persisting data across requests

By default the interpreter is stateless — data fetched by onArrive() exists for one request only and is re-fetched on the next. If re-fetching is expensive, configure a store. The library manages the full store lifecycle — your handler writes nothing extra.

const interpreter = new UssdInterpreter(root, {
  storeTtl: 120, // seconds — match your gateway session timeout
  store: {
    async get (sessionId) {
      const raw = await redis.get(`session:${sessionId}`)
      return raw ? JSON.parse(raw) : {}
    },
    async set (sessionId, data, ttl) {
      await redis.set(`session:${sessionId}`, JSON.stringify(data), { EX: ttl })
    },
    async del (sessionId) {
      await redis.del(`session:${sessionId}`)
    }
  },
  onStoreError: (err) => logger.warn({ err }, 'Store error — failing open')
})

Pass gateway metadata to resolveAsync() — the library uses sessionId to key store operations automatically:

app.post('/ussd', async (req, res) => {
  const { sessionId, phoneNumber, serviceCode, text } = req.body
  const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber, serviceCode })
  res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})

How it works

Per request the library does:

1. store.get(sessionId)           → restore previously fetched session data
2. replay input                   → user-captured data always wins on conflict
3. onArrive({ gateway, session }) → enrich session with external data
4. session.isEnded
     ? store.del(sessionId)       → clean up on session end
     : store.set(sessionId, data, ttl)
5. return session

Replay-first: if a user goes back and corrects their input, replay produces the new value — the stored value is ignored for that key. Stale data never shadows corrected user input.

Fail-open: store errors never surface as USSD errors. If store.get() throws, the library proceeds without restored data. If store.set() throws, the session is returned normally. onStoreError is called in either case for observability.

The fetch guard pattern

With a store, onArrive() can skip re-fetching when data is already present. Store a fingerprint alongside fetched data to detect stale results on back-navigation:

onArrive: async ({ gateway, session }) => {
  // Already fetched and input hasn't changed — store provided it
  if (
    session.customerData &&
    session.customerData._source === session.passportNumber
  ) return {}

  const result = await verifyKYC({
    passportNumber: session.passportNumber,
    phoneNumber:    gateway.phoneNumber     // gateway data — certified, read-only
  })

  return {
    customerData: {
      ...result.customer,
      _source: session.passportNumber  // fingerprint — detects stale data on back-nav
    },
    isVerified: result.verified
  }
}

The _source prefix (prefixed _ to signal internal) mirrors the _page convention used by paginationState.

Store options

| Option | Default | Description | |---|---|---| | store | null | Store adapter { get, set, del }. If omitted, stateless default. | | storeTtl | 120 | Seconds before store entries expire. | | onStoreError | null | (err) => void — called on any store error. If omitted, fails silently. |


Pipeline — multiple async sources

When a state needs data from more than one source, use Pipeline inside onArrive(). It handles single, parallel, and chained fetches — each step with its own timeout and failure behaviour.

import { UssdInterpreter, Pipeline } from 'ussd-interpreter'

Single fetch

const p = new Pipeline([
  { name: 'balance', fetch: (data) => fetchBalance(data.accountNumber), timeout: 3000 }
])

Parallel — independent sources run concurrently

const p = new Pipeline([
  { name: 'equifax',    fetch: (data) => fetchEquifax(data.id),    timeout: 2000, parallel: true, onFail: 'continue' },
  { name: 'transunion', fetch: (data) => fetchTransUnion(data.id), timeout: 2000, parallel: true, onFail: 'continue' },
  { name: 'registry',   fetch: (data) => fetchRegistry(data.id),   timeout: 2000, parallel: true, onFail: 'continue' }
])

Chained — each step receives the previous output

Every step's fetch function receives two arguments: data (the session data bag) and prev (the merged results of all steps that have settled so far, keyed by step name). This lets each step use the output of the one before it:

const p = new Pipeline([
  { name: 'auth', fetch: (data)       => fetchAuth(data.idNumber),                  timeout: 2000, onFail: 'abort' },
  { name: 'kyc',  fetch: (data, prev) => fetchKyc(data.idNumber, prev.auth.token),  timeout: 3000, onFail: 'abort' }
  //                                                               ^^^^^^^^^^^^^^^^
  //                                                               output of 'auth' step
])

Mixed — combine freely

Sequential steps, parallel groups, and chained steps can be combined in any order. A sequential step after a parallel group receives all parallel results in prev:

const kycPipeline = new Pipeline([
  { name: 'auth',       fetch: (data)       => fetchAuth(data.idNumber),                        timeout: 2000, onFail: 'abort' },
  { name: 'equifax',    fetch: (data, prev) => fetchEquifax(data.idNumber, prev.auth.token),     timeout: 2000, onFail: 'continue', parallel: true },
  { name: 'transunion', fetch: (data, prev) => fetchTransUnion(data.idNumber, prev.auth.token),  timeout: 2000, onFail: 'continue', parallel: true },
  { name: 'score',      fetch: (data, prev) => computeScore(prev.equifax, prev.transunion),      timeout: 500,  onFail: 'abort' }
  //                                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^
  //                                                          both parallel results available here
])

const kycState = UssdInterpreter.confirmationState({
  id:       'kyc_verify',
  render:   (data) => `Score: ${data.score?.value}\n1. Continue\n2. Cancel`,
  on:       { confirm: createAccount, cancel: cancelled },
  onArrive: ({ gateway, session }) => kycPipeline.run(session)
})

pipeline.run(data) always returns a plain object:

{
  pipelinePassed: true,
  failedAt:       null,
  auth:       { token: 'T001' },
  equifax:    { passed: true, score: 88 },
  transunion: { failed: true, reason: 'timeout' }, // onFail: 'continue' — recorded, not thrown
  score:      { value: 85, passed: true }
}

Calculations work exactly the same as fetches — fetch can return a plain value without Promise.resolve():

{ name: 'band', fetch: (d, prev) => ({ band: prev.score.value >= 80 ? 'LOW' : 'HIGH' }) }

Step options

| Option | Default | Description | |---|---|---| | name | required | Unique key. Result stored under this name in prev and in the output. | | fetch | required | (data, prev) => value | Promise | | timeout | 5000 | ms before this step is considered failed. | | onFail | 'abort' | 'abort' stops the whole pipeline. 'continue' records the failure and moves on. | | parallel | false | Group with adjacent parallel: true steps and run via Promise.all. |


Pagination — long content across multiple screens

When content is too long to fit on a single USSD screen, use paginationState. It splits the text automatically, appends page indicators and navigation options, and handles all forward/back/accept/cancel navigation.

const termsAndConditions = UssdInterpreter.paginationState({
  id:      'terms',
  content: 'By registering you agree to our terms of service. ' +
           'We may use your data for service improvement. ' +
           'You can opt out at any time by contacting support. ' +
           'Charges apply as per your tariff plan.',
  pageSize: 160,
  on: {
    accept: signupFlow,
    cancel: cancelledState
  }
})

What the screens look like

Page 1 of 3 — Next and Cancel, no Back:

By registering you agree to
our terms of service. We may
use your data for improvement.
(1/3)
1.Next  0.Cancel

Page 2 of 3 — Next, Back, and Cancel:

You can opt out at any time
by contacting support. Charges
apply as per your tariff plan.
(2/3)
1.Next  2.Back  0.Cancel

Page 3 of 3 — Accept replaces Next:

Data is processed lawfully and
securely per our privacy policy.
(3/3)
1.Accept  2.Back  0.Cancel

Navigation behaviour

| Token | Page 1 | Middle page | Last page | |---|---|---|---| | "1" | Next page | Next page | Accept — advances to on.accept | | "2" | Exits pagination | Previous page | Previous page | | "0" | Exits pagination | Previous page | Previous page |

Note on cancel: "0" uses the standard back action — it pops one frame per press. From page 1 this exits to the previous flow state. From deeper pages it walks back page by page.

Dynamic content

content can be a function that receives the current data bag:

const transactionHistory = UssdInterpreter.paginationState({
  id:       'tx_history',
  content:  (data) => data.transactions
    .map((tx, i) => `${i + 1}. KES ${tx.amount} — ${tx.date}`)
    .join('\n'),
  pageSize: 160,
  onArrive: async ({ gateway, session }) => ({
    transactions: await fetchTransactions(gateway.phoneNumber)
  }),
  on: { accept: mainMenu, cancel: mainMenu }
})

Options

| Option | Default | Description | |---|---|---| | id | required | Unique state identifier | | content | required | Full text to paginate, or (data) => string for dynamic content | | on | required | { accept: state, cancel: state } | | pageSize | 160 | Max characters per page including the footer | | nextToken | '1' | Token to advance to the next page | | backToken | '2' | Token to go to the previous page | | cancelToken | '0' | Token to exit pagination (back action) | | acceptToken | '1' | Token to accept on the last page | | label | — | (data) => string — optional, for session stories | | onArrive | — | async ({ gateway, session }) => plainObject — same as other state types |


Session stories — session.buildStory()

After a session ends, buildStory() produces a structured account of what happened — the path the user took, all captured data, and a plain-English narrative of each step. Use it to power reports, audit logs, and analytics.

app.post('/ussd', async (req, res) => {
  const { sessionId, phoneNumber, text } = req.body
  const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })

  if (session.isEnded) {
    const story = {
      sessionId,
      phoneNumber,
      timestamp: new Date().toISOString(),
      ...session.buildStory()
    }
    await reports.save(story)
  }

  res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})

A completed transfer produces:

{
  "sessionId": "ATUid_abc123",
  "phoneNumber": "+254724000001",
  "timestamp": "2025-04-27T10:32:11.000Z",
  "outcome": "completed",
  "exitState": "transfer_success",
  "path": ["main_menu", "enter_account", "enter_amount", "confirm_transfer", "transfer_success"],
  "data": { "accountNumber": "0724000001", "amount": 500 },
  "steps": [
    { "state": "main_menu",        "label": "User opened the service." },
    { "state": "enter_account",    "label": "Entered account number 0724000001." },
    { "state": "enter_amount",     "label": "Entered amount KES 500." },
    { "state": "confirm_transfer", "label": "Reviewed transfer of KES 500 to 0724000001." },
    { "state": "transfer_success", "label": "Transfer of KES 500 to 0724000001 completed." }
  ],
  "narrative": "User opened the service. Entered account number 0724000001. Entered amount KES 500. Reviewed transfer of KES 500 to 0724000001. Transfer of KES 500 to 0724000001 completed."
}

Declaring labels

Add a label function to any state. It receives the final session data bag and returns a plain string. Supported on all state types except routerState.

const transferSuccess = UssdInterpreter.terminalState({
  id:     'transfer_success',
  render: (data) => `KES ${data.amount} sent to ${data.accountNumber}.`,
  label:  (data) => `Transfer of KES ${data.amount} to ${data.accountNumber} completed.`
})

const confirmTransfer = UssdInterpreter.confirmationState({
  id:     'confirm_transfer',
  render: (data) => `Send KES ${data.amount} to ${data.accountNumber}?\n1. Confirm\n2. Cancel`,
  on:     { confirm: transferSuccess, cancel: cancelledState },
  label:  (data) => `Reviewed transfer of KES ${data.amount} to ${data.accountNumber}.`
})

const enterAmount = UssdInterpreter.dataState({
  id:         'enter_amount',
  captureKey: 'amount',
  render:     () => 'Enter amount (KES):',
  validate:   (token) => Number(token) > 0 ? true : 'Enter a valid amount.',
  transform:  (token) => Number(token),
  next:       confirmTransfer,
  label:      (data) => `Entered amount KES ${data.amount}.`
})

Labels are opt-in. States without a label still appear in path and steps with label: null but contribute nothing to narrative.

Note: label(data) receives the final session data, not the per-frame snapshot. All captured values are available to every label function regardless of when in the flow they were entered.

Deduplication and raw mode

If a user backed out and re-entered a state, buildStory() keeps only the last visit — the narrative reflects the user's final committed path. For the full undeduped stack:

const story = session.buildStory({ raw: true })

Redacting sensitive data

Pass a redact array to replace sensitive keys with '[REDACTED]' in both the data output and in any label() calls — so no label function can accidentally leak a sensitive value. session.data itself is never mutated.

const story = session.buildStory({ redact: ['pin', 'password', 'cvv'] })

What the caller adds

buildStory() returns no gateway metadata. Merge sessionId, phoneNumber, and timestamp in yourself, as shown in the example above.


Chaos Monkey — stress-test your flow

import { ChaosMonkey } from 'ussd-interpreter/chaos'

The chaos monkey generates thousands of random sessions against your flow and verifies that your invariants always hold — regardless of what users enter, what your onArrive() hooks return, or how your flow branches.

const monkey = new ChaosMonkey(interpreter, {
  iterations: 5000,
  verbose:    true
})

monkey
  .dataHints({
    'enter_account': { valid: () => '0724000001', invalid: ['abc', '123'] },
    'enter_amount':  { valid: () => '500',         invalid: ['-1', 'abc'] }
  })
  .needsHints({
    'show_balance':     { happy: { balance: 3200 }, chaos: ['happy', 'null', 'timeout', 'throw'] },
    'transfer_success': { happy: { ref: 'TXN001' }, chaos: ['happy', 'null', 'wrong_type'] }
  })
  .invariant('accountNumber is always 10 digits when present', (session) => {
    if (!session.data.accountNumber) return true
    return /^\d{10}$/.test(session.data.accountNumber)
  })

const report = await monkey.run()
report.print()

Sample report

════════════════════════════════════════════════════════════
  CHAOS MONKEY REPORT
════════════════════════════════════════════════════════════
  Seed:        a3f8c1d2
  Iterations:  5,000
  Violations:  1
  Duration:    3.41s
  Result:      ✗ FAILED
════════════════════════════════════════════════════════════

  VIOLATION 1 — CRASH
  ────────────────────────────────────────────────────────
  resolveAsync() threw an error: Cannot read property 'toFixed' of null

  Input:     "2"
  State:     show_balance
  Chaos:     null
  Error:     Cannot read property 'toFixed' of null

  Reproduce: seed=a3f8c1d2, iteration=1847

════════════════════════════════════════════════════════════

Reproduce any failure by passing the seed back in, or run the specific input string directly:

new ChaosMonkey(interpreter, { seed: 'a3f8c1d2', iterations: 5000 })

const session = await interpreter.resolveAsync('2', { sessionId: 'test' })
console.log(session.render())

Chaos modes

| Mode | What it does | |---|---| | happy | Calls the real function or returns the hint | | null | Returns null | | empty | Returns {} | | wrong_type | Returns an array, string, number, or boolean | | missing_keys | Returns the happy result with random keys dropped | | extra_keys | Returns the happy result with extra unexpected keys added | | throw | Throws a generic Error | | throw_bad | Throws a non-Error (string, null, number) | | timeout | Returns a Promise that never resolves — triggers onArriveTimeout |

Options

| Option | Default | Description | |---|---|---| | iterations | 1000 | Number of random sessions to run | | seed | auto-generated | Seed for reproducibility | | maxViolations | 20 | Stop collecting after this many | | maxDepth | 10 | Max flow depth to explore | | maxStates | 100 | Max states to discover | | verbose | false | Print progress to console |


Reference

UssdSession

| Property | Type | Description | |---|---|---| | state | object | Current state definition | | data | object | Frozen captured data for the current frame | | error | string | null | Last error message shown to user | | retryCount | number | Consecutive failures on current state | | isDead | boolean | Session forcibly terminated | | isTerminal | boolean | Flow has reached a terminal state | | isEnded | boolean | isTerminal \|\| isDead — use this for CON/END | | depth | number | Navigation stack depth | | render() | string | Screen text to send to the user | | buildStory(options?) | object | Structured session story for reports and audit logs |

buildStory options:

| Option | Default | Description | |---|---|---| | raw | false | Skip deduplication — return the full undeduped frame stack | | redact | [] | Keys to replace with '[REDACTED]' in data output and in label() calls |

UssdInterpreter options

| Option | Default | Description | |---|---|---| | separator | '*' | Token separator | | maxTokens | 20 | Max tokens per input string (DoS guard) | | maxTokenLength | 64 | Max characters per token (DoS guard) | | maxRetries | 3 | Max consecutive failures before session is killed | | consumeBudget | 200 | Max internal operations per resolve() | | onArriveTimeout | 5000 | ms before onArrive() is killed | | store | null | Store adapter { get, set, del }. If omitted, stateless default. | | storeTtl | 120 | Seconds before store entries expire | | onStoreError | null | (err) => void — called on any store error. If omitted, fails silently. |


FAQ

Does the interpreter know about sessionId? Only as a store key. When a store is configured, the library reads sessionId from the gateway options passed to resolveAsync() and uses it to key store operations. That is the only thing the library does with it. Everything else — failure tracking, idempotency of side effects, logging — belongs in your request handler.

Does the interpreter know about CON and END? No. Use session.isEnded in your handler to decide which prefix to prepend.

Is concurrency a problem? No. The interpreter is stateless — each request creates its own instance, calls resolve(), and discards it. Two simultaneous requests for the same user resolve independently and produce the same deterministic result. Concurrency is only a concern in your handler if you trigger non-idempotent side effects.

Is it framework, server, and gateway agnostic? Yes. Zero runtime dependencies. Works identically on Express, Fastify, AWS Lambda, Cloudflare Workers, and any JavaScript runtime.

Can Pipeline handle calculations, not just network calls? Yes. A step's fetch function can return a plain value, a Promise, or the result of a synchronous calculation — the pipeline treats all three identically.

Can a user bypass the flow and jump to any state they want? No — but understanding what that means in practice matters. A flow is the sequence of states a user must pass through to reach an outcome: to reach transfer_success, they must pass through enter_account, then enter_amount, then confirm_transfer, in that order. The interpreter replays the full input string on every request, so there is no way to skip ahead — every state's validate() must return true before the flow advances. What the interpreter cannot enforce is whether the data the user entered is legitimate. A 10-digit account number that passes format validation may still not exist, belong to someone else, or have insufficient funds — those checks belong in your validate() and onArrive() functions, backed by your own APIs. The gateway request itself is also trusted at face value: if you are not verifying that requests genuinely originate from your gateway provider (via IP allowlist, shared secret, or signature), a bad actor could POST arbitrary input strings directly to your endpoint and skip the phone interaction entirely. The interpreter gives you the structure to enforce your rules cleanly — it does not replace them.

How do I handle fraud checks? Use onArrive() on a confirmationState to run the check after all input is collected, then route to a terminalState if blocked:

const confirmTransfer = UssdInterpreter.confirmationState({
  id:     'confirm_transfer',
  render: (data) => `Send KES ${data.amount} to ${data.accountNumber}?\n1. Confirm\n2. Cancel`,
  on: {
    confirm: UssdInterpreter.routerState({
      id:    'fraud_router',
      route: (data) => data.fraudBlocked ? fraudBlocked : transferSuccess
    }),
    cancel: cancelledState
  },
  onArrive: async ({ gateway, session }) => {
    const check = await runFraudCheck({ ...session, phoneNumber: gateway.phoneNumber })
    return { fraudBlocked: !check.passed, fraudRef: check.referenceCode }
  }
})

How do I trigger side effects like logging when a user reaches a specific step? Side effects belong in your request handler after resolve() returns — inspect session.state.id and act. For effects that must run exactly once per session, use an idempotency key (sessionId + stateId). On a single server a Set is enough; in production with multiple instances use Redis SET key NX:

const sideEffects = {
  confirm_transfer: (session, req) => fireOnce(`${req.body.sessionId}:confirm_transfer`, 300, () => analytics.track('confirmation_reached', session.data)),
  transfer_success: (session, req) => fireOnce(`${req.body.sessionId}:transfer_success`, 300, () => auditLog.record({ sessionId: req.body.sessionId, ...session.data }))
}

app.post('/ussd', async (req, res) => {
  const { sessionId, phoneNumber, text } = req.body
  const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })
  const effect = sideEffects[session.state.id]
  if (effect) await effect(session, req)
  res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})

License

MIT