The README covers installation and a quick example. This guide covers every feature in depth, one concept at a time.

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"
end

Every read/write function accepts options:

  • :ttl - seconds until the value expires on its own (default: never)
  • :public - store under gid instead of uid (default: false), so the value is visible to a different uid sharing the same gid - this is exactly how "continuing" sessions (see Resuming Sessions) hand a live session from an expired uid to 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]

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
FunctionWhat it does
text/2Appends raw text, no newline
line/2Appends text followed by a newline
line_break/2Appends n blank lines (default 1)
listing/3Appends a numbered list; see below
format/3Appends :io_lib.format/2-formatted text, e.g. format(menu, "Balance: ~s", ["GHS 10"])
append/2 / prepend/2Splices in another menu, or the menu built by a (menu -> menu) function
trans/4 / trans_line/4Appends 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.

ModuleMatches 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
end

States & 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
end

transition/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")
end

initial: 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
end

use 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
end

Point 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")
end

Every 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 same gid/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 a continuing_state

    • a module declared with use Ussd.State, continue: true and a confirm/0 callback - 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 at initial_state.

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.Gettext
Ussd.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:

ModuleShape
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 MyGateway

Visualizing a Flow

mix ussd.graph MyApp.Ussd.States.Welcome
mix ussd.graph MyApp.Ussd.States.Welcome --output flow.mmd

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

Walks 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 --strict

Walks the flow from its initial: true states (or the ones you pass explicitly) and reports:

  • Errors - a transition/2 target that doesn't exist or isn't a Ussd.State/ Ussd.Action; a state with no transition, back, or terminate at all (it would always raise NextStateNotFoundError if reached).
  • Warnings - a state only reachable via back/2 with no transition/terminate of its own; a duplicate transition/2 match (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 Default

Each 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()
end

Available 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 with terminating?: 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