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

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

Handholding Guide

Start with How it works to understand the replay model, then Quick start to see a working flow in 30 lines. State types covers the six building blocks you compose flows from. Async data explains how to fetch external data, Session store how to persist it across requests,. Pipeline, Pagination, and Advisories covers production gotchas every deployment will encounter. Session stories cover advanced features. Reference has the full option tables and execution model. FAQ answers the questions that come up in production.


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',
  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),
  render:     () => 'Enter amount (KES):',
  next:       success
})

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

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

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()}`)
})

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',
  onArrive:    async ({ gateway, session }) => ({ ... }),        // optional — see Async data
  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
})

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. This is intentional: data like phone numbers, amounts, and ID numbers can legitimately start with zero. There is no built-in back from a data capture screen — use escapeKey if you need one.

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

How a submission flows through dataState

When a user submits input on a dataState, execution follows this sequence:

user types token
  → escape check              if token matches escapeKey → redirect, stop here
  → validate(token, data)     return true to pass, string to reject
  → transform(token, data)    only runs if validate passed — return stored value
  → data[captureKey] = value  stored value saved into data bag (not raw token)
  → next(value, data)         determines next state — value is already transformed

If validate returns a string, the same screen is re-rendered with the error prepended and captureKey is not stored. transform never runs on a failed validation.

confirmationState — yes/no prompt

const confirm = UssdInterpreter.confirmationState({
  id:           'confirm_transfer',
  onArrive:     async ({ gateway, session }) => ({ ... }),              // optional
  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
})

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',
  onArrive: async ({ gateway, session }) => ({
    ref: await generateTransactionRef(session.accountNumber, session.amount)
  }),
  render:   (data) => `Done! Ref: ${data.ref}`,
  label:    (data) => `Transfer of KES ${data.amount} completed.`  // optional
})

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.

Never use a routerState as your root state. The user would see a blank screen and be immediately redirected with no explanation. Routers are always the next of a dataState or the destination of a transition — never a starting point.

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',
  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 }
  },
  render: () => 'Enter your ID number:',
  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',
  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),
  render:     (data) => `Available: KES ${data.balance}\nEnter amount:`,
  next:       confirmTransfer
})

const enterAccount = UssdInterpreter.dataState({
  id:         'enter_account',
  captureKey: 'accountNumber',
  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 }
  },
  render: () => 'Enter recipient account number:',
  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',
  validate:   (token) => token.trim().length >= 3 ? true : 'Name too short.',
  render:     () => 'Enter your full name:',
  next:       signupSuccess
})

const enterDob = UssdInterpreter.dataState({
  id:         'enter_dob',
  captureKey: 'dob',
  validate:   (token) => /^\d{8}$/.test(token) ? true : 'Use format DDMMYYYY.',
  render:     () => 'Enter date of birth (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',
  transitions: {
    '1': enterDob,       // → signup flow
    '2': enterAccount,   // → transfer flow
    '3': enterIdNumber   // → KYC flow
  },
  allowBack:   false
})

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.

The function form also enables dynamic routing — choosing which state to go to based on the current data bag:

// Route to different flows based on account type fetched by onArrive()
const accountMenu = UssdInterpreter.navigationState({
  id:          'account_menu',
  render:      () => '1. Transact\n2. Statement',
  transitions: (token, data) => {
    if (token === '1') return data.accountType === 'savings' ? savingsTransact : currentTransact
    if (token === '2') return statementFlow
    return null
  }
})

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',
  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 }
  },
  render: (data) => `Available: KES ${data.balance}\nEnter amount:`,
  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 — before they have typed anything on that 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.

This is the most common source of confusion. If you put onArrive() on enterAmount, it runs when the user sees the "Enter amount" prompt — at that point session.amount is undefined. session.accountNumber from the previous screen is available. This is correct and intentional — use onArrive() to pre-fetch data the current screen needs to render, not to process what the user has just typed.

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',
  onArrive: async ({ gateway, session }) => {
    const { balance } = await fetchBalance(gateway.phoneNumber)
    return { balance }
  },
  render: (data) => `Your balance is KES ${data.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.

Why validate and transform are synchronous

validate and transform run during the synchronous replay of the input string — before onArrive(). Making them async would mean re-running them on every request for every historical token, turning a microsecond operation into potentially dozens of API calls per request.

If you need external data to validate something — checking a recipient exists, verifying a balance — fetch it in onArrive() on the previous screen and store it in the data bag. validate can then check data.balance or data.recipientFound without any async work.

// Fetch balance in onArrive() on enterAccount
const enterAccount = UssdInterpreter.dataState({
  id:         'enter_account',
  captureKey: 'accountNumber',
  validate:   (token) => /^\d{10}$/.test(token) ? true : 'Must be 10 digits.',
  onArrive:   async ({ gateway }) => {
    const { balance } = await fetchBalance(gateway.phoneNumber)
    return { balance }  // stored in data bag
  },
  render: () => 'Enter account number:',
  next:   enterAmount
})

// Use it synchronously in validate on enterAmount
const enterAmount = UssdInterpreter.dataState({
  id:         'enter_amount',
  captureKey: 'amount',
  validate:   (token, data) => {
    const n = Number(token)
    if (n > data.balance) return `Exceeds balance of KES ${data.balance}.`
    return true
  },
  render: () => 'Enter amount (KES):',
  next:   confirmState
})

See The fetch guard pattern to avoid re-fetching on every request when a store is configured.


Escape key — escaping dataStates and confirmationStates

On a dataState, "0" is always raw input — the user cannot press back. On a confirmationState, on.cancel may not route far enough back. With a deep flow, a user mid-transfer who wants to cancel everything and return to the main menu has no clean way out.

Configure escapeKey and escapeTarget on the interpreter to give users an exit from any data capture or confirmation screen:

const interpreter = new UssdInterpreter(mainMenu, {
  escapeKey:    '00',    // token that triggers escape
  escapeTarget: mainMenu // where to go — state object, not a string id
})

When the user presses "00" on any dataState or confirmationState, validate() and transform() are skipped entirely. The current state is replaced with escapeTarget — stack depth stays the same, so back navigation still works correctly from the escape destination.

Disabling escape on a specific state

Some states should not have an escape route. A PIN entry screen may use "00" as valid input. Use escapeKey: null to disable the global escape on that state:

const enterPin = UssdInterpreter.dataState({
  id:         'enter_pin',
  captureKey: 'pin',
  validate:   (token) => /^\d{4}$/.test(token) ? true : 'PIN must be 4 digits.',
  render:     () => 'Enter your 4-digit PIN:',
  next:       confirmState,
  escapeKey:  null  // disable — "00" is treated as raw input here
})

Per-state escape target

A specific state can escape to a different destination than the global target:

const enterLoanAmount = UssdInterpreter.dataState({
  id:           'enter_loan_amount',
  captureKey:   'loanAmount',
  validate:     (token) => Number(token) > 0 ? true : 'Enter a valid amount.',
  render:       () => 'Enter loan amount:\n00. Back to loans menu',
  next:         confirmLoan,
  escapeKey:    '00',
  escapeTarget: loansMenu  // escape to loans menu, not root
})

Escape on confirmationState

on.confirm and on.cancel continue to work normally. Escape is an additional exit route, not a replacement for either:

const confirmTransfer = UssdInterpreter.confirmationState({
  id:     'confirm_transfer',
  render: (data) => `Send KES ${data.amount}?\n1. Confirm  2. Cancel  00. Main menu`,
  on:     { confirm: transferSuccess, cancel: enterAmount },
  // escapeKey inherited from interpreter — "00" goes to mainMenu
})

Precedence

| Situation | Behaviour | |---|---| | Per-state escapeKey: null | Escape disabled — global escape ignored | | Per-state escapeKey set | Uses per-state key and per-state target (if set), else global target | | No per-state override | Uses global escapeKey and escapeTarget | | No global escapeKey | No escape — existing behaviour preserved |

Options

| Option | Where | Description | |---|---|---| | escapeKey | Interpreter | Global escape token. Requires escapeTarget. | | escapeTarget | Interpreter | State object to escape to. Required if escapeKey is set. | | escapeKey | dataState, confirmationState | Per-state override. null disables global escape. | | escapeTarget | dataState, confirmationState | Per-state escape destination. |


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


Session resumption — app layer pattern

USSD sessions drop. Carriers enforce hard timeouts — typically around 3 minutes. For long flows, resumption is a real usability concern.

Session resumption is not a library feature — it is an application-level pattern. The library is stateless and unaware of sessions across dials. The handler owns the resume store, the consent screen, and the gateway text reconciliation.

See the session resumption proposal in the project documentation for a full implementation including resumption.js, stripToRoot, and handler integration.

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.Prev  0.Cancel

Page 3 of 3 — Accept replaces Next:

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

Hiding the page indicator

Set showPageIndicator: false to omit the (n/total) counter. Useful when the content is self-contained and the counter adds visual clutter — T&C screens, welcome messages, or any content where page position is not meaningful to the user:

const termsAndConditions = UssdInterpreter.paginationState({
  id:                'terms',
  content:           tandc,
  pageSize:          160,
  showPageIndicator: false,
  on: { accept: signupFlow, cancel: cancelledState }
})

Pages then render without the counter:

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

Customising token labels

The display labels for Next and Back can be customised independently of the tokens. Cancel and Accept are not generated by the library — write them directly in your content string so you have full control over their wording and position. This lets you match your product's language or brand voice without changing the token values:

const termsAndConditions = UssdInterpreter.paginationState({
  id:                'terms',
  content:           tandc,  // write your own cancel/accept options in the content
  pageSize:          160,
  showPageIndicator: false,
  nextToken:         '3',   nextLabel: 'More',
  prevToken:         '4',   prevLabel: 'Back',
  cancelToken:       '0',
  acceptToken:       '1',
  on: { accept: signupFlow, cancel: cancelledState }
})

Middle pages:

...terms of service content...
3.More  4.Prev

Last page — Cancel and Accept are in the content, not the footer:

...final terms content...
1. Yes
2. No
4.Prev

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',
  onArrive: async ({ gateway, session }) => ({
    transactions: await fetchTransactions(gateway.phoneNumber)
  }),
  content:  (data) => data.transactions
    .map((tx, i) => `${i + 1}. KES ${tx.amount} — ${tx.date}`)
    .join('\n'),
  pageSize: 160,
  on: { accept: mainMenu, cancel: mainMenu }
})

Options

| Option | Default | Description | |---|---|---| | id | required | Unique state identifier | | onArrive | — | async ({ gateway, session }) => plainObject — same as other state types | | content | required | Full text to paginate, or (data) => string for dynamic content | | pageSize | 160 | Max characters per page including the footer | | showPageIndicator | true | Show (n/total) on each page. Set to false to hide. | | on | required | { accept: state, cancel: state } | | nextToken | '1' | Token to advance to the next page | | prevToken | '2' | Token to go to the previous page (content navigation) | | cancelToken | '0' | Token to exit pagination (back action) | | acceptToken | '1' | Token to accept on the last page | | nextLabel | 'Next' | Display label for the next token | | prevLabel | 'Prev' | Display label for the prev token | | label | — | (data) => string — optional, for session stories |


Advisories

This section documents architectural constraints and production gotchas that are not bugs but will affect real deployments. Each advisory explains the problem, who it affects, and what to do about it.


Advisory: Side effects and stateless replay

Affects: any flow where onArrive() creates, updates, or deletes something external and a routing condition reads that state.

The interpreter replays the full input string on every request. This is what makes the library stateless and horizontally scalable. But replay carries an implicit assumption: the world does not change between requests in a way that affects routing decisions.

When a side effect inside onArrive() permanently changes external state — creating an account, disbursing a loan, completing KYC — and a routing condition somewhere in the graph reads that state, replay may produce a different branch than the original pass did.

The classic failure: a user completes an account opening flow. Their account is created mid-session. They press 00 to return to the main menu. The library replays the full input string from root. The root router calls hasAccount() — which now returns { found: true } because the account was just created. The tokens that followed the account opening flow are now replayed against the existing customer menu. The user lands in the wrong state.

Design checklist. Before shipping any flow where onArrive() has a side effect, ask three questions:

  1. Does this onArrive() create, update, or delete something external?
  2. Is there a routing condition anywhere in the graph that reads what was just changed?
  3. Can the user navigate back or to root after this onArrive() runs?

If all three are yes, apply Fix A if the user can navigate to root, Fix B if the user can navigate back one step, or both if both are possible.

Fix A — 00 (root navigation): strip the input string to everything after the last 00 before passing it to resolveAsync(). The root branch condition re-evaluates with current world state and the user correctly lands on the right menu:

function stripToRoot (text) {
  if (!text) return ''
  const tokens = text.split('*')
  const rootIndex = tokens.lastIndexOf('00')
  if (rootIndex === -1) return text
  return tokens.slice(rootIndex + 1).join('*')
}

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

Fix B — 0 (back one step): set allowBack: false on any state whose onArrive() performs an irreversible side effect. The user cannot navigate back past a point of no return:

UssdInterpreter.confirmationState({
  id:        'open_account_confirm',
  allowBack: false,  // side effect occurs here — back navigation disabled
  onArrive:  async ({ gateway, session }) => {
    const result = await bankingApi.openAccount({ ... })
    return { accountNumber: result.accountNumber }
  },
  render: (data) => `Account ${data.accountNumber} created.\n1. Continue`,
  on:     { confirm: welcomeScreen, cancel: welcomeScreen }
})

As a general rule: any state whose onArrive() performs an irreversible side effect should set allowBack: false. This applies to dataState, navigationState, confirmationState, and paginationState. Declare the point of no return at the state level, where the side effect lives — not in the handler.


Advisory: Deep flows and the dynamic exits problem

Affects: flows with more than 5–6 steps where routing conditions change at runtime.

As flows grow deeper and exits become more dynamic, the single biggest risk is losing track of which states can reach which terminals. A flow that can end in five different ways — depending on balance, KYC status, account type, and fraud score — with no documentation becomes very hard to reason about, especially when onArrive() results change what those exits are at runtime.

Recommended practice: document the possible exits at the top of each flow file as a comment, before any code:

/**
 * Transfer flow — possible exits:
 *   recipient_not_found     — invalid account number
 *   insufficient_funds      — balance check failed
 *   fraud_blocked           — fraud check failed at confirmation
 *   transfer_cancelled      — user cancelled at confirmation
 *   transfer_success        — completed
 */

Keep each sub-flow's entry state and all its internal states in the same file. A state that appears in two flow files is a sign the flow boundary is wrong.


Advisory: Session drops and user abandonment

Affects: any flow longer than 3–4 steps deployed on a mobile carrier network.

USSD sessions drop. Carriers enforce hard session timeouts — typically around 3 minutes. A user who loses signal mid-flow dials again and starts from scratch. For short flows this is a minor inconvenience. For long flows — onboarding, identity verification, loan applications — it is a real abandonment driver.

Session resumption is an application-level pattern — not a library feature. For flows longer than 4 steps in a production deployment, implement resumption in your handler. See Session resumption for the pattern.


Advisory: onArrive() performance and the fetch guard pattern

Affects: any flow using onArrive() without a store configured.

onArrive() runs on the landing state on every request. Without a store, every request replays the full input string from scratch and the landing state's onArrive() re-fetches from external APIs every time. In a 6-step flow, the 6th request triggers one onArrive() call — but without a store, so did the 5th, and the 4th. Every request fetches fresh.

This is correct for freshness but expensive at scale. Configure a store to cache onArrive() results across requests in the same session. See Session store.

If you cannot use a store, use the fetch guard pattern inside onArrive() to skip re-fetching when data is already present:

onArrive: async ({ gateway, session }) => {
  if (session.balance !== undefined) return {}
  const { balance } = await fetchBalance(gateway.phoneNumber)
  return { balance }
}

This reduces redundant fetches without a store, at the cost of potentially serving stale data within a session.


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',
  validate:   (token) => Number(token) > 0 ? true : 'Enter a valid amount.',
  transform:  (token) => Number(token),
  render:     () => 'Enter amount (KES):',
  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'] })

Resumption in stories

When a session was resumed, buildStory() includes a resumption field and prepends a plain-English note to narrative. See Session resumption for the full output format.

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 |

buildStory() always returns a resumption field — null when no resumption occurred, a structured object when the session was resumed. See Session resumption.

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 | | escapeKey | null | Token that triggers escape on dataState and confirmationState. Requires escapeTarget. | | escapeTarget | null | State object to escape to. Required if escapeKey is set. | | 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. |


Order of execution

Every call to resolveAsync() follows this sequence:

resolveAsync(inputString, gatewayOptions)
  │
  ├─ 1. store.get(sessionId)
  │       Restore data from previous requests.
  │       If store throws → continue without it (fail open).
  │
  ├─ 2. replay input string token by token
  │       For each token on a dataState or confirmationState:
  │         a. escape check          if token matches escapeKey → replace, continue
  │         b. validate(token, data) only runs if escape did not fire
  │         c. transform(token, data) only runs if validate passed
  │         d. data[captureKey] = result  stored value, not raw token
  │         e. drain routerState     if next is a router, resolve it now
  │       Produces a session at the landing state.
  │
  ├─ 3. onArrive({ gateway, session })  landing state only, once
  │       Runs after replay, before render.
  │       Result merged into data — captured data wins on conflict.
  │       If throws or times out → dead session, stop here.
  │
  ├─ 4. store.set(sessionId, data, ttl)  if session is alive
  │    or store.del(sessionId)           if session ended
  │       If store throws → return session normally (fail open).
  │
  └─ 5. return session
           Your handler calls session.render()

What each function can see

| Function | What it can see | |---|---| | escape check | Fires before anything else on dataState and confirmationState. Skips validate and transform entirely when it fires. | | validate(token, data) | Data from all previous screens. It cannot see the current token — that is what is being validated. It cannot see onArrive() data — that runs after replay. | | transform(token, data) | Same as validate. The token has already passed validation. | | route(data) | Data captured by all screens the user passed through to reach this router. If the user typed "1*0724000001" and the router sits after enterAccount, it sees data.accountNumber — but not data.amount, captured on a later screen. | | onArrive({ gateway, session }) | All captured data from every previous screen. Gateway options. Store-restored data from prior requests. It cannot see the current screen's own captureKey — the user has not submitted it yet. | | render(data) | Everything — replay data plus onArrive() result. | | label(data) | Final session data at the time buildStory() is called. All captured values from all screens. |


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. You create one interpreter instance at startup and share it across all requests — it holds no mutable state. Each call to resolve() or resolveAsync() is entirely self-contained: it takes an input string, replays it, and returns a session. Two simultaneous calls for the same user produce the same deterministic result independently. 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