ussd-interpreter-x
v1.0.1
Published
Stateless USSD session interpreter with async pipeline support
Readme
ussd-interpreter
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-interpreterContents
- How it works
- Quick start
- State types
- Multiple flows
- Nested flows
- Async data
- Session store
- Pipeline
- Pagination
- Session stories
- Chaos Monkey
- Reference
- FAQ
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 TransferQuick 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/ENDprefix is your responsibility — the interpreter exposessession.isEndedso 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 → ENDCode 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 instancemenu.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 sessionReplay-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.CancelPage 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.CancelPage 3 of 3 — Accept replaces Next:
Data is processed lawfully and
securely per our privacy policy.
(3/3)
1.Accept 2.Back 0.CancelNavigation 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
