Using State Machines to Model USSD Flows in the Dial It iOS App
Some mobile flows are not APIs. They are negotiations with the operating system. USSD is one of those flows.

While building Dial It for iOS, I kept running into a deceptively small product question:
When should the app record that a user intentionally continued a USSD-based action?
At first glance, this sounds easy. The user taps a button, the app opens a USSD code, and the action has started. But in a USSD flow, "started" is not the same as "continued." The user can cancel the system prompt. The session can fail to open. The app can briefly leave and return. The user may never reach the reply/input step.
In a money-related flow, recording too early is not just a technical detail. It changes what the product believes happened.
This article is not a guide to USSD itself, and it is not a deep dive into mobile OS internals. It is a story about using a state machine to bring structure to an uncertain flow.
The Problem With Recording Too Early
Dial It helps users initiate USSD-based actions such as mobile money and airtime flows. The app can prepare the code and hand control to the system dialer, but once that happens, the app does not own the entire journey anymore.
That handoff creates an awkward boundary.
The tempting implementation is:
openUSSDCode()
recordUserIntent()
It is simple. It is also too optimistic.
Opening a USSD code only proves that the app attempted to start the flow. It does not prove that the user accepted the system prompt. It does not prove that the external session continued. It does not prove that the user reached the input point where the flow becomes intentional enough for the app to record.
What I needed was a cleaner boundary between:
the app launched a USSD code
the user intentionally moved forward inside the USSD session
Those are different moments.
The Constraint
The difficult part is that iOS does not give the app a perfect callback for this kind of flow.
There is no beautiful event that says:
userDidContinueUSSDSession()
Instead, the app gets partial signals.
It can observe that it opened a dial URL. It can observe that it left the foreground. It can observe that it returned. It can observe input-related changes when the system-managed flow reaches a reply step.
None of those signals alone tells the full story.
But together, they describe a journey.
And journeys are often better modeled as state machines than as scattered booleans.
Moving From Flags to States
The early shape of the problem naturally produced questions like:
Did the app open the USSD code?
Did the app leave the foreground?
Did it come back?
Did it leave again after the system prompt?
Did an input/reply stage appear?
Those questions can be represented with booleans, but booleans do not explain the flow. They tell you facts, not where you are.
A state machine gives the flow names.
Instead of thinking in isolated flags, I could think in states:
IdleUSSD Code OpenedSystem Prompt ShownApp ReturnedUSSD Session ContinuedReply Input Detected
That change sounds small, but it made the product decision much easier to reason about.
Only one state should record user intent.
Not when the app opens the code. Not when the app briefly leaves the foreground. Not when it returns.
Only when the flow has moved far enough to suggest the user intentionally continued inside the USSD session.
A Simplified State Machine
The app production implementation has platform-specific details, but the public version of the model looks like this:
The important idea is not the exact names. It is the direction of the flow.
The app starts with no assumptions. Each observable event moves the session forward. If the session never reaches the right state, the app does not record the action.
Swift-ish Pseudocode
The actual app code is more careful around lifecycle timing, but the idea can be shown with simplified Swift-style pseudocode:
enum USSDFlowState {
case idle
case codeOpened
case systemPromptShown
case appReturned
case sessionContinued
case replyInputDetected
}
enum USSDFlowEvent {
case codeOpened
case appLeftForeground
case appReturned
case replyInputDetected
case timeout
}
var state: USSDFlowState = .idle
func handle(_ event: USSDFlowEvent) {
switch (state, event) {
case (.idle, .codeOpened):
state = .codeOpened
case (.codeOpened, .appLeftForeground):
state = .systemPromptShown
case (.systemPromptShown, .appReturned):
state = .appReturned
case (.appReturned, .appLeftForeground):
state = .sessionContinued
case (.sessionContinued, .replyInputDetected):
state = .replyInputDetected
recordUserIntent()
case (_, .timeout):
stopObservingWithoutRecording()
default:
break
}
}
This is the core of the solution.
The app does not try to claim more certainty than it has. It simply says:
I will record user intent only after the observable flow reaches this specific state.
That made the behavior safer.
The Timeout Matters Too
One small but important part of the design is the timeout.
An observer like this cannot wait forever. If the app opens a USSD flow and never sees enough signals to reach the reply/input state, it needs to stop observing and return a non-recording outcome.
That timeout is not a business decision by itself. It is a safety cap.
It means:
If the expected state transition does not happen within a reasonable window, assume the app should not record the action.
This matters because external flows are messy. Users cancel. Sessions fail. The OS behaves differently depending on timing. A timeout keeps the app from holding onto an unfinished observation indefinitely.
The Real Lesson: Model What You Can Observe
The most useful shift was accepting that I did not need to model the entire USSD session. I simply could not.
The app does not own that entire experience, and pretending otherwise would make the code less honest.
What I needed was smaller and more practical:
Model enough of the observable journey to avoid recording too early.
That is where the state machine helped.
It did not make the operating system transparent. It did not magically turn USSD into a clean API. It gave names to the parts of the journey the app could actually observe.
That distinction matters.
Sometimes good engineering is not about finding perfect certainty. Sometimes it is about building a safer boundary around partial certainty.
Why This Felt Better
The final version felt better for a few reasons.
First, the code became easier to explain. Instead of saying, "if these three booleans and this input signal happen in this order," the code could say, "the session is now in this state."
Second, the product behavior became easier to defend. Recording happens at one intentional point in the flow, not as a side effect of opening a URL.
Third, the solution became easier to change. If a future platform behavior requires another step, that step can become a new state or transition instead of another condition buried inside a callback.
That is the beauty of state machines in product engineering. They force unclear flows to become explicit.
This Applies Beyond USSD
This article is about Dial It for iOS, but the pattern applies to many mobile and web flows.
Any time your app hands control to another system, you may be dealing with partial observability:
payment redirects
OTP flows
external authentication
browser-based checkout
native OS prompts
app-to-app deep links
In all of those cases, there is a temptation to treat "launched" as "completed" or "opened" as "continued."
That is often where bugs hide.
A state machine gives you a way to ask better questions:
Which transitions are allowed?
What can the app actually observe?
Which events are meaningful?
Which state is allowed to trigger the product decision?
What happens if the flow times out?
Those questions are more useful than adding another boolean and hoping future-you remembers what it meant.
A Checklist Suggestion
If you are building around an external flow you do not fully control, this is the checklist I would use:
Name the user intent you are trying to record.
Separate "flow started" from "flow intentionally continued."
List only the signals your app can actually observe.
Convert those signals into named states.
Allow the product decision to happen from exactly one state.
Add timeout behavior for incomplete flows.
Keep the implementation honest about what it cannot know.
Personal Closing
The beautiful part of this solution was not that it made USSD simple! USSD is still USSD. The beautiful part was that the messy edges finally had names.
When the platform gives you partial signals, do not spread assumptions across callbacks and flags. Name your states. Name your transitions. Then make the product decision happen at the right state.



