The README covers installation and a quick example. This guide covers every feature in depth, one concept at a time.
- Getting Started
- Context
- Record
- Menu
- Decisions
- States & Transitions
- Pagination & Truncation
- Actions & Conditional Branching
- Back Navigation
- Resuming Sessions
- Configurators
- Localized Menus
- Encrypted Records
- Exception Handling
- Gateway Responses
- Visualizing a Flow
- Simulating a Session
- Linting a Flow
- Generators
- Testing
- Session Events
- Cache Backends
- Configuration Reference
Getting Started
Every request into a flow is a Ussd.Context (who's calling, what they typed) run through
a builder pipeline that ends in Ussd.run/1:
alias Ussd.Context
context = Context.new(session_id, phone_number, input)
Ussd.build(context)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.run()Context.new/3 takes three strings:
uid- unique to this specific dial-in. Usually your gateway's session id.gid- shared across a caller's redials. Usually their phone number.input- what they typed this request. An empty string on the very first request.
Ussd.run/1 looks up (or starts) the session behind that context, resolves exactly one
screen, and returns whatever Ussd.use_response/2's formatter produces - a string, a map,
whatever your gateway expects.
Context
Ussd.Context is the read-only value every render/1, Ussd.Action.execute/1, and
transition/back/paginate callback receives.
Ussd.Context.uid(context) #=> "session-abc123"
Ussd.Context.gid(context) #=> "233200000000"
Ussd.Context.input(context) #=> "1"Attach extra values your states need - the caller's network, a request header, anything
your gateway sent alongside the standard fields - with with_bag/2, and read them back
with get/2:
context =
Context.new(session_id, phone, input)
|> Context.with_bag(%{network: params["network"]})
Ussd.Context.get(context, :network) #=> "MTN"with_bag/2 replaces the bag rather than merging into it, so set everything you need in
one call before passing the context to Ussd.build/1.
Record
Ussd.Record is per-session storage - the thing that lets a flow remember what the caller
already told it across requests. It's automatically namespaced by the context's uid,
backed by a pluggable Ussd.Cache (see Cache Backends):
def render(context) do
record = Ussd.Record.new(Ussd.Context.uid(context), Ussd.Context.gid(context))
Ussd.Record.set(record, "selected_plan", "unlimited")
Ussd.Record.get(record, "selected_plan") #=> "unlimited"
Ussd.Record.get(record, "missing_key", "none") #=> "none"
endEvery read/write function accepts options:
:ttl- seconds until the value expires on its own (default: never):public- store undergidinstead ofuid(default:false), so the value is visible to a differentuidsharing the samegid- this is exactly how "continuing" sessions (see Resuming Sessions) hand a live session from an expireduidto the redial's new one
The full API: get/4, set/4, get_many/4, set_many/3, has?/3, increment/4,
decrement/4, forget/3, forget_many/3, plus locale/1/set_locale/2 and
set_encrypted/4/get_encrypted/4 (see Encrypted Records).
Ussd.Record.increment(record, "otp_attempts") #=> 1
Ussd.Record.increment(record, "otp_attempts") #=> 2
Ussd.Record.set_many(record, %{"a" => 1, "b" => 2})
Ussd.Record.get_many(record, ["a", "b", "c"]) #=> [1, 2, nil]Menu
Ussd.Menu is the fluent builder every render/1 returns. Build one with build/0 and
grow it with the pipe operator:
@impl Ussd.State
def render(_context) do
Ussd.Menu.build()
|> Ussd.Menu.line("Welcome to Speso Bank")
|> Ussd.Menu.line("Select an option")
|> Ussd.Menu.listing(["Check Balance", "Transfer", "Buy Airtime"])
|> Ussd.Menu.line_break()
|> Ussd.Menu.text("Powered by Speso")
end| Function | What it does |
|---|---|
text/2 | Appends raw text, no newline |
line/2 | Appends text followed by a newline |
line_break/2 | Appends n blank lines (default 1) |
listing/3 | Appends a numbered list; see below |
format/3 | Appends :io_lib.format/2-formatted text, e.g. format(menu, "Balance: ~s", ["GHS 10"]) |
append/2 / prepend/2 | Splices in another menu, or the menu built by a (menu -> menu) function |
trans/4 / trans_line/4 | Appends a Gettext-translated string; see Localized Menus |
listing/3 takes options:
Ussd.Menu.build()
|> Ussd.Menu.listing(
["Airtime", "Data Bundle"],
numbering: &(&1 + 1), # index -> label, default 1-based
spacer: ". ", # between number and item, default "."
divider: "\n", # between items, default "\n"
page: 1, # slice the list before rendering
per_page: 5
)A menu implements String.Chars, so to_string(menu) (or plain interpolation) always
gives you the rendered text - useful when a truncate/2 decision needs to measure it.
Decisions
A decision is a struct plus a Ussd.Decision.decide/2 implementation - something that
looks at the caller's raw input string and says yes or no. They're used everywhere routing
happens: transition/2, back/2, paginate/2, truncate/2, and Ussd.ContinueState.confirm/0.
| Module | Matches when input... |
|---|---|
Ussd.Decisions.Equal.new(x) | equals x (numeric-aware: "7" matches 7) |
Ussd.Decisions.NotEqual.new(x) | doesn't equal x |
Ussd.Decisions.Between.new(low, high) | is in [low, high] inclusive, numerically |
Ussd.Decisions.NotBetween.new(low, high) | is outside [low, high] |
Ussd.Decisions.GreaterThan.new(x) | is greater than x |
Ussd.Decisions.GreaterThanOrEqualTo.new(x) | is greater than or equal to x |
Ussd.Decisions.LessThan.new(x) | is less than x |
Ussd.Decisions.LessThanOrEqualTo.new(x) | is less than or equal to x |
Ussd.Decisions.In.new([a, b, ...]) | equals any of the list |
Ussd.Decisions.NotIn.new([a, b, ...]) | equals none of the list |
Ussd.Decisions.IsNumeric.new() | parses fully as an integer or float |
Ussd.Decisions.Length.new(n) | is exactly n characters |
Ussd.Decisions.Regex.new(~r/.../) | matches the given Regex |
Ussd.Decisions.Fallback.new() | always matches - use as a catch-all last transition/2 |
Equal, Between, and the other comparison decisions coerce both sides to numbers before
comparing when both sides look numeric, and fall back to string comparison otherwise -
Between.new(1, 10) correctly matches "9" and "10", not just "1" through "9" the
way naive string comparison would.
Custom decisions are just a struct and an implementation - no macro required:
defmodule MyApp.Decisions.EvenLength do
defstruct []
def new, do: %__MODULE__{}
defimpl Ussd.Decision do
def decide(_decision, actual), do: rem(String.length(actual), 2) == 0
end
endStates & Transitions
A Ussd.State is one screen. use Ussd.State gives you five macros to declare how it
routes, and requires you to implement render/1:
defmodule MyApp.Ussd.States.Welcome do
use Ussd.State, initial: true
alias Ussd.Decisions.{Equal, Fallback}
transition Equal.new("1"), to: MyApp.Ussd.States.Airtime
transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle
transition Fallback.new(), to: __MODULE__
@impl Ussd.State
def render(_context) do
Ussd.Menu.build()
|> Ussd.Menu.line("Welcome")
|> Ussd.Menu.listing(["Airtime", "Data Bundle"])
end
endtransition/2 is repeatable - the first one whose decision matches wins, so put your
catch-all Fallback last. Each also accepts callback: (a 0- or 1-arity function run when
it matches, before moving on):
transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle, callback: fn context ->
Analytics.track(Ussd.Context.uid(context), "selected_data_bundle")
endinitial: true is advisory metadata - mix ussd.lint uses it to auto-discover flow entry
points when you don't pass one explicitly. It is not enforced by
Ussd.use_initial_state/2; any Ussd.State or Ussd.Action works there.
A state reached with input that matches nothing raises
Ussd.Exceptions.NextStateNotFoundError - run mix ussd.lint to find these before your
users do (see Linting a Flow).
Pagination & Truncation
Pagination pages a structured list (like listing/3) across screens. Mix in
Ussd.Pagination and declare paginate/1:
defmodule MyApp.Ussd.States.Products do
use Ussd.State
use Ussd.Pagination, per_page: 5
alias Ussd.Decisions.{Equal, Fallback}
paginate next: Equal.new("#"), previous: Equal.new("0")
transition Fallback.new(), to: MyApp.Ussd.States.ProductDetail
@impl Ussd.Pagination
def get_items(_context), do: Catalog.list_products()
@impl Ussd.State
def render(context) do
Ussd.Menu.build()
|> Ussd.Menu.listing(get_items(context), page: current_page(context), per_page: 5)
|> Ussd.Menu.line(if has_next_page?(context), do: "#. More")
end
enduse Ussd.Pagination injects current_page/1, last_page/1, first_page?/1,
last_page?/1, has_next_page?/1, and has_previous_page?/1, all backed by a counter in
Ussd.Record. You must implement get_items/1; per_page/1 is either given via the
per_page: option or implemented yourself (e.g. if it varies per caller).
Pressing next/previous past the first/last page falls through to your other
transition/2s rather than looping - so a Fallback transition after paginate/1 acts as
"anything else, including an exhausted next/previous, moves on".
Truncation caps how much of a single long string comes back per screen (some gateways have a hard character limit per response):
truncate limit: 160, ending: "\n0. More", more: Equal.new("0")The rendered content is wrapped and shown a chunk at a time; pressing the more: decision's
key advances to the next chunk of the same state. Once exhausted, further input falls
through to transition/back normally, same as pagination.
Actions & Conditional Branching
Sometimes the next state can't be a static transition/2 - it depends on an HTTP call, a
database lookup, or anything else that has to run before you know where to go. A
Ussd.Action is a one-shot decision point with no screen shown to the caller:
defmodule MyApp.Ussd.Actions.LoadAccount do
@behaviour Ussd.Action
@impl Ussd.Action
def execute(context) do
case Accounts.find(Ussd.Context.get(context, :phone)) do
{:ok, _account} -> MyApp.Ussd.States.Dashboard
:error -> MyApp.Ussd.States.Registration
end
end
endPoint a transition/2 (or Ussd.use_initial_state/2) at an action just like a state - the
engine keeps calling execute/1 while the result is itself an action, so actions can chain,
and stops once it reaches a real Ussd.State to render.
Back Navigation
back/2 returns to whatever state most recently transitioned into the current one, via an
automatic per-session stack - no manual bookkeeping:
defmodule MyApp.Ussd.States.Airtime do
use Ussd.State
alias Ussd.Decisions.{Equal, Fallback}
back Equal.new("0")
transition Fallback.new(), to: MyApp.Ussd.States.Confirm
@impl Ussd.State
def render(_context), do: Ussd.Menu.build() |> Ussd.Menu.line("Enter amount") |> Ussd.Menu.line("0. Back")
endEvery transition/2 that moves to a different state pushes the state being left onto the
stack; back/2 pops it. If the stack is empty (nothing to go back to), back/2 simply
doesn't match, and resolution falls through to transition/2 as normal.
Resuming Sessions
By default (:start, the implicit default), every dial is a brand-new session. Two other
modes let a redial pick back up after a dropped call, configured via
Ussd.use_continuing_state/4:
Ussd.build(context)
|> Ussd.use_continuing_state(:continue, 300) # resume silently, session valid 300s
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.run():continue- a redial under a new session id (but the samegid/phone number) silently resumes exactly where the previous session left off, as long as it's within the given TTL.:confirm- instead of resuming silently, the redial is routed to acontinuing_state- a module declared with
use Ussd.State, continue: trueand aconfirm/0callback - which asks the caller whether they want to resume:
defmodule MyApp.Ussd.States.ConfirmResume do use Ussd.State, continue: true @impl Ussd.ContinueState def confirm, do: Ussd.Decisions.Equal.new("1") @impl Ussd.State def render(_context) do Ussd.Menu.build() |> Ussd.Menu.line("Resume your previous session?") |> Ussd.Menu.line("1. Yes") end end Ussd.build(context) |> Ussd.use_continuing_state(:confirm, 300, MyApp.Ussd.States.ConfirmResume) |> Ussd.use_initial_state(MyApp.Ussd.States.Welcome) |> Ussd.run()A reply matching
confirm/0's decision resumes the previous session; anything else starts a brand-new one atinitial_state.- a module declared with
Both modes rely on Ussd.Record's public: true scoping (see Record) to hand
the live session from the expired uid to the redial's new one under the shared gid.
Configurators
Group repeated Ussd.use_*/2 setup so it isn't copy-pasted across every entry point:
defmodule MyApp.UssdConfigurator do
@behaviour Ussd.Configurator
@impl Ussd.Configurator
def configure(ussd) do
ussd
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.use_exception_handler(&MyApp.UssdErrors.handle/1)
end
end
Ussd.build(context)
|> Ussd.use_configurator(MyApp.UssdConfigurator)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.run()Localized Menus
Menu.trans/4 and trans_line/4 translate via a configured Gettext backend:
# mix.exs
{:gettext, "~> 0.20"}
# config/config.exs
config :ussd, :gettext_backend, MyApp.GettextUssd.Menu.build() |> Ussd.Menu.trans_line("greeting", %{name: caller_name})The engine also persists a session's chosen locale (via Ussd.Record.set_locale/2) and
applies it automatically on every subsequent request in that session, so a caller who picks
French once stays in French for the rest of the conversation.
Encrypted Records
Store sensitive values (PINs, account numbers) encrypted at rest instead of in plain text:
# config/config.exs
config :ussd, :encryption_key, System.fetch_env!("USSD_ENCRYPTION_KEY")Ussd.Record.set_encrypted(record, "pin", "1234")
Ussd.Record.get_encrypted(record, "pin") #=> "1234"
Ussd.Record.get(record, "pin") #=> the ciphertext, not "1234"Encryption is AES-256-GCM; the configured key is hashed down to 256 bits, so it can be any length/format convenient for you to manage as a secret.
Exception Handling
An unhandled exception during Ussd.run/1 doesn't crash the request - it's caught and
turned into a message via whatever's configured with Ussd.use_exception_handler/2:
defmodule MyApp.UssdErrors do
@behaviour Ussd.ExceptionHandler
@impl Ussd.ExceptionHandler
def handle(_exception), do: "Sorry, something went wrong. Please try again."
end
Ussd.build(context)
|> Ussd.use_exception_handler(MyApp.UssdErrors)
# or inline: |> Ussd.use_exception_handler(fn exception -> Exception.message(exception) end)
|> Ussd.run()The session always ends (terminating?: true) after an exception - there's no live state
left to safely resume from.
Gateway Responses
Ussd.use_response/2 shapes the resolved {message, terminating?} into whatever your
gateway's HTTP contract expects. Six formatters ship out of the box:
| Module | Shape |
|---|---|
Ussd.Responses.AfricasTalking | "CON message" / "END message" |
Ussd.Responses.Speso | %{"message" => ..., "action" => "prompt" | "end"} |
Ussd.Responses.Nalo | %{"USERID" => uid, "MSISDN" => gid, "USERDATA" => input, "MSG" => ..., "MSGTYPE" => bool} |
Ussd.Responses.Nsano | %{"USSDResp" => %{"action" => ..., "menus" => "", "title" => ...}} |
Ussd.Responses.Moolre | %{"message" => ..., "reply" => bool} |
Ussd.Responses.Arkesel | %{"sessionID" => ..., "userID" => ..., "msisdn" => ..., "message" => ..., "continueSession" => bool} |
Ussd.build(context)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.run()Or write your own - a module implementing Ussd.Response, or just an inline 3-arity
function (context, message, terminating? -> term):
mix ussd.gen.response MyGatewayVisualizing a Flow
mix ussd.graph MyApp.Ussd.States.Welcome
mix ussd.graph MyApp.Ussd.States.Welcome --output flow.mmdWalks every transition/2 from the given state and renders a Mermaid stateDiagram-v2.
States only reachable via back/2 collapse into a single "Previous state" node, matching
how the back-stack actually behaves at runtime; states resolved dynamically (via
Ussd.Action) are shown but not expanded further.
Simulating a Session
mix ussd.simulate MyApp.Ussd.States.Welcome
mix ussd.simulate MyApp.Ussd.States.Welcome --phone 233200000000 --response Ussd.Responses.AfricasTalkingWalks a flow interactively in the terminal like a real handset - no gateway or phone
required. Useful flags: --with KEY=VALUE (repeatable, extra context bag values),
--configurator MODULE (repeatable), --continuing-mode start|continue|confirm,
--continuing-state MODULE, --continuing-ttl SECONDS, --store MODULE. Type :restart
to drop the call without ending the session, :exit to hang up.
Linting a Flow
mix ussd.lint
mix ussd.lint MyApp.Ussd.States.Welcome
mix ussd.lint --strictWalks the flow from its initial: true states (or the ones you pass explicitly) and
reports:
- Errors - a
transition/2target that doesn't exist or isn't aUssd.State/Ussd.Action; a state with notransition,back, orterminateat all (it would always raiseNextStateNotFoundErrorif reached). - Warnings - a state only reachable via
back/2with notransition/terminateof its own; a duplicatetransition/2match (the second one can never trigger); a state that exists but is never reached from any root.
--strict makes warnings fail the command too, so you can wire it into CI.
Generators
mix ussd.gen.state Welcome
mix ussd.gen.state Welcome --initial
mix ussd.gen.state Confirm --continue
mix ussd.gen.action LoadAccount
mix ussd.gen.response MyGateway
mix ussd.gen.decision EvenLength
mix ussd.gen.configurator Default
mix ussd.gen.exception_handler DefaultEach scaffolds a starting-point module under lib/<namespace>/<kind>/<name>.ex, where
<namespace> defaults to <YourApp>.Ussd (override with config :ussd, :namespace).
Testing
Ussd.Test is a fluent ExUnit helper for walking a flow screen by screen:
import Ussd.Test
test "buying airtime" do
build(MyApp.Ussd.States.Welcome)
|> start()
|> assert_see("Welcome")
|> input("1")
|> assert_see("Enter amount")
|> input("5")
|> assert_terminated()
endAvailable assertions: assert_see/2, assert_terminated/1, assert_not_terminated/1,
assert_context_has/3, assert_context_missing/2, assert_record_has/3,
assert_record_missing/2 (the /3 versions take an optional expected value or a 1-arity
predicate function). acting_as/2 switches which simulated caller is dialing in - each
name gets its own session id and its own remembered screen, so you can test multi-party
scenarios and switch back and forth without losing either side's state. timeout/3
simulates a redial after a real sleep, for testing :continue/:confirm TTL expiry.
Session Events
Two :telemetry events fire during Ussd.run/1, for logging or analytics without
touching core modules:
:telemetry.attach("log-ussd-states", [:ussd, :state, :entered], fn _event, _measurements, %{state: state, context: context}, _config ->
Logger.info("#{Ussd.Context.uid(context)} entered #{inspect(state)}")
end, nil)[:ussd, :state, :entered]- metadata%{state: module, context: Ussd.Context.t()}, fired every time a screen is about to render.[:ussd, :session, :terminated]- metadata%{context: Ussd.Context.t()}, fired when a response comes back withterminating?: true.
Cache Backends
Ussd.Record reads/writes through a pluggable Ussd.Cache behaviour. The default,
Ussd.Cache.ETS, is process-local (a supervised GenServer + ETS table) - fine for a single
node, with real TTL expiry via both lazy reads and a periodic sweep. Anything running on
more than one node needs a shared backend (Redis, :cachex with a distributed adapter,
Mnesia, ...) - implement the six Ussd.Cache callbacks and point at it:
# config/config.exs
config :ussd, :cache, MyApp.RedisCache
# or per-request:
Ussd.build(context) |> Ussd.use_store(MyApp.RedisCache) |> ...Configuration Reference
config :ussd,
namespace: "MyApp.Ussd", # mix ussd.gen.* / mix ussd.lint auto-discovery
cache: Ussd.Cache.ETS, # or your own Ussd.Cache implementation
cache_sweep_interval: :timer.minutes(1), # Ussd.Cache.ETS's expired-entry sweep
session_ttl: 300, # seconds a mid-flow session survives with no input
encryption_key: System.fetch_env!("USSD_ENCRYPTION_KEY"), # required for set_encrypted/get_encrypted
gettext_backend: MyApp.Gettext, # required for Menu.trans/trans_line
arkesel_user_id: System.get_env("ARKESEL_USER_ID") # used by Ussd.Responses.Arkesel