Build USSD (Unstructured Supplementary Service Data) applications in Elixir without breaking a sweat.
This README covers installation and a quick example. For every feature in depth - Record,
Decisions, Pagination & Truncation, Resuming Sessions, Configurators, Localization,
Encrypted Records, Gateway Responses, the mix ussd.graph/ussd.simulate/ussd.lint
tasks, Testing, and more - see the full guide.
Add ussd to your list of dependencies in mix.exs:
def deps do
[
{:ussd, "~> 0.2.0"}
]
end- Menus as modules — define screens as
Ussd.Statemodules with a fluentUssd.Menubuilder, and route between them declaratively withtransition/2. - Built-in decisions — match input with
Equal,Between,In,Regex,IsNumericand more out of the box; scaffold custom ones withmix ussd.gen.decision. - Conditional branching —
Ussd.Actionmodules decide the next state at runtime (e.g. after an HTTP call), for flows that can't be expressed as static transitions. - Back navigation —
back/2with an automatic per-session history stack, no manual bookkeeping. - Automatic pagination —
use Ussd.Paginationpluspaginate/1page long listings without manual bookkeeping. - Response truncation —
truncate/1caps how many characters a screen returns, so dynamic content can't blow past your gateway's character limit. - Resumable sessions —
use_continuing_state/4lets a redial pick back up where a timed-out session left off, silently or after confirming with the user. - Configurators — group and share repeated setup (response format, exception handling, etc.) across entry points.
- Localized menus — build menu content from a Gettext backend, with locale persisted across a session.
- Session records with real expiry — a
Ussd.RecordAPI (get/set/increment/decrement/...) for persisting data during a session, backed by a pluggableUssd.Cachewith actual TTL, swept periodically instead of leaking forever. - Encrypted session data — store sensitive values (PINs, account numbers) encrypted at
rest via
set_encrypted/get_encrypted. - Built-in gateway responses — ships
Ussd.Responseformatters for Speso, Africa's Talking, Nsano, Nalo, Moolre and Arkesel; scaffold your own withmix ussd.gen.response. - Exception handling — implement
Ussd.ExceptionHandlerto turn an unhandled exception into a message the caller sees, instead of a dead session. - Flow visualization —
mix ussd.graphrenders a Mermaid state diagram of a flow from itstransition/2/back/2/terminate/0declarations. - Interactive simulation —
mix ussd.simulatelets you walk a flow in the terminal like a real handset, no gateway or phone required. - Flow linting —
mix ussd.lintcatches dead ends, broken transitions, duplicate matches and unreachable states before they ship. - Testing utilities — a fluent
Ussd.TestAPI for asserting screens, context and session state across multi-step conversations. - Session events —
:telemetryevents for state entry and session termination, for logging or analytics without touching core modules. - Mix generators — scaffold states, actions, responses, decisions, configurators and
exception handlers with
mix ussd.gen.*.
mix ussd.gen.state Welcome
generates lib/my_app/ussd/states/welcome.ex:
defmodule MyApp.Ussd.States.Welcome do
use Ussd.State
alias Ussd.Decisions.Fallback
transition Fallback.new(), to: __MODULE__
terminate()
@impl Ussd.State
def render(_context) do
Ussd.Menu.build()
|> Ussd.Menu.line("Welcome")
end
enddefmodule MyApp.Ussd.States.Welcome do
use Ussd.State, initial: true
alias Ussd.Decisions.Equal
transition Equal.new("1"), to: MyApp.Ussd.States.Airtime
transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle
@impl Ussd.State
def render(_context) do
Ussd.Menu.build()
|> Ussd.Menu.line("Welcome")
|> Ussd.Menu.line("Select an option")
|> Ussd.Menu.listing(["Airtime Topup", "Data Bundle", "TV Subscription", "ECG/GWCL"])
|> Ussd.Menu.line("")
|> Ussd.Menu.text("Powered by Speso")
end
enddefmodule MyAppWeb.UssdController do
use MyAppWeb, :controller
def index(conn, params) do
context = Ussd.Context.new(params["session_id"], params["phone"], params["input"] || "")
result =
context
|> Ussd.build()
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.run()
text(conn, result)
end
endNothing here is Phoenix-specific — Ussd.run/1 returns whatever your Ussd.Response
formatter produces, so it works the same from a Plug, a mix run script, or a test.
# config/config.exs
config :ussd,
namespace: "MyApp.Ussd", # used by mix ussd.gen.* and mix ussd.lint's auto-discovery
cache: Ussd.Cache.ETS, # or your own Ussd.Cache implementation
cache_sweep_interval: :timer.minutes(1),
session_ttl: 300, # seconds a mid-flow session survives with no further input
encryption_key: System.fetch_env!("USSD_ENCRYPTION_KEY"), # required for set_encrypted/get_encrypted
gettext_backend: MyApp.Gettext # required for Ussd.Menu.trans/4import 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()
endMIT. Please see the license file for more information.