No signal, no problem: building AI-powered offline payments for Africa

Kayode Ojo
Software Engineer
Jul 10, 2026
11 min
No signal, no problem: building AI-powered offline payments for Africa

Heavy consumption of mobile data is super common amongst Nigerians; we use it every day. It doesn’t matter who you are: a technologist working remote jobs, a professional doom-scroller, or a business owner making transactions over the internet, we are all in the same data-usage boat. 

The major downside to this high demand is that data dries up at the worst possible time, and then you need to buy more. That should be super easy, right? 

Wrong!

In a cruel twist of fate, every method available to you to re-up your data requires the very data you just ran out of.

You try to open your banking app (e.g., OPay, Kuda, etc.), but that needs data. You could dial a USSD code, navigate five menus deep, and hope you remember the right option for your MTN data bundle. While it works without the internet, you still have to memorize the code and the exact prompt path just to complete a transaction.

You could borrow airtime for a moment, use it to buy data, get back online, then sort yourself out. You could ask a friend to share their hotspot, which means they have to be available, willing, and close enough. All of these work, but none of them are fast.

The fastest thing you can do when your data finishes is send a text message. This approach requires no internet, no menus, no coordination. Just a text. That is the idea behind the offline bill payment system we built at Sendtrill, and it turned out to be more interesting in execution than we expected.


How it works

Sendtrill has a dedicated phone number. A user sends an SMS to that number in plain, natural language, such as "Send me 1GB MTN data" or "Buy DSTV Compact for 012345678." The system processes the message, fulfills the request, and sends back a confirmation SMS. No internet is required on the user's end at any point.

The pipeline has four moving parts: the SMS processor app, the Sendtrill API, the AI classification layer, and the fulfillment engine.

The SMS processor app

The SMS processor is a Kotlin app running on a physical Android phone with a SIM card on the dedicated Sendtrill number. When an SMS arrives, the app intercepts it and decides what to do with it.

The first job is filtering. Not every SMS that arrives on that number is a user request. For example, marketing messages, OTP codes, and bank alerts are ignored. The app filters by sender type, selecting only  messages from actual phone numbers, not from alphanumeric sender IDs that networks use for promotional content.

The second layer is registration. You have to register your phone number with Sendtrill before the system responds to your messages. If your number is not registered, your SMS gets ignored. No registered number, no service. This simple trust boundary keeps the system clean and prevents abuse.

We chose Kotlin because SMS interception on Android is sensitive hardware-level work. You want direct control, not an abstraction layer. Flutter would need a plugin, and plugins in this territory are either unreliable or require you to write the native code anyway. Kotlin gives full access to Android APIs with no middleman, which is exactly the level of control this kind of system needs.

Once the app confirms the message is from a registered number, it forwards the raw SMS text to the Sendtrill API.

The AI layer

This is where it gets interesting. People do not text in structured formats. Someone might send "shey I fit see 1gb mtn data for dia, 08023456789" or "send me data, MTN, 1000 naira own." The system needs to understand all of these and extract the same structured information from each of them: bill type, recipient, package, amount.

Now to the main business, the AI classifier. To make this work, the raw SMS is sent to an OpenAI model with a system prompt that instructs it to return structured JSON only, without preamble or explanation:

const SYSTEM_PROMPT = `You are a bill payment assistant. Extract structured information from SMS messages. Return ONLY a JSON object with these fields: 

- bill_type: "data" | "airtime" | "cable" | "electricity" 

- recipient: phone number or null if not specified 

- network: network provider or null 

- package_id: package identifier or null 

- amount: number or null 

- needs_clarification: boolean 

- clarification_message: string or null 

If any required field is missing or ambiguous, set needs_clarification to true and provide a clarification_message asking for the specific missing information. Never guess. If you are not sure, ask.

`;

export async function classifySMS(from: string, message: string) {

  const response = await openai.chat.completions.create({

    model: "gpt-5.4-nano",

    response_format: { type: "json_object" },

    messages: [

      { role: "system", content: SYSTEM_PROMPT },

      {

        role: "user",

        content: `SMS from ${from}: "${message}"`,

      },

    ],

  });

  return JSON.parse(response.choices[0].message.content!);

}

The returned JSON looks like this:

{

  "bill_type": "data",

  "recipient": "08023456789",

  "network": "MTN",

  "package_id": "mtn_1gb_1000",

  "amount": 1000,

  "needs_clarification": false,

  "clarification_message": null

}

If the user does not specify a recipient, the recipient defaults to the sender. If someone just wants data for themselves, they do not need to include their own number in the message.

The model is also instructed on what to do when information is missing or ambiguous. If it cannot confidently extract all the required fields, instead of guessing, it returns needs_clarification: true with a message asking for the specific missing detail. The API sends that back to the user as an SMS. The user replies, the new message goes through the same pipeline, and the conversation continues until the request is complete. A full multi-turn AI interaction, entirely over SMS, zero internet on the user side.

This is the part that actually required AI. You could write pattern-matching rules for common request formats, but Nigerian SMS language does not follow rules. Pidgin, abbreviations, mixed languages, creative spellings; a rule-based system would miss half of it. The model handles all of it naturally.

The fulfillment engine

Once the API has a complete and well-structured JSON from the AI, it passes it to the process bills function. This validates the structure, looks up the exact package in the database, checks the user's balance, and calls the appropriate provider API to fulfill the request.

If the user hasn’t specified a recipient, the system defaults to sending to the number that sent the SMS. After fulfillment, the user always gets a response back. Success or failure, they hear back. If something goes wrong (provider unavailability, insufficient funds, unrecognized package) they get a clear SMS explaining what happened. Nobody is left wondering if their request went through.

Does it scale?

The honest conversation about this system is that the physical phone is a single point of failure. If the phone dies, loses signal, or runs out of battery, the offline channel goes down. We handle this with a heartbeat system. The processor app pings the Sendtrill API at regular intervals to confirm it is alive and processing. If the heartbeat stops, we know immediately and can respond before users start noticing.

The queuing system handles duplicate SMS delivery. Mobile networks sometimes deliver the same SMS twice during congestion. Without deduplication, a user ends up with two identical transactions from one message. The queue assigns a unique ID to each incoming SMS and checks for duplicates before processing. This makes sure only one transaction goes through when it gets a duplicate SMS.

At a larger scale, the single-phone architecture needs to evolve. The natural path is multiple-processor phones, each handling a different network or region, with the API routing incoming requests based on processor availability and network affinity. The heartbeat system already tracks processor status, so the routing logic is straightforward: find the healthiest processor on the right network and assign it. The queue infrastructure supports this without changes since processors just become workers pulling from the same queue.

Why this is faster than everything else

The workarounds people use today all have friction. Borrowing airtime temporarily works but involves coordination and a float you need to cover. Sharing a hotspot requires someone available and willing. USSD works without internet, but navigating it blind, with session timeouts and network drops is genuinely painful.

Sending a text takes ten seconds. If you have bought data before on Sendtrill, you only have to copy your last message and send it again. No app, no menu, no asking anyone for anything. Your data is topped up, and you get a confirmation SMS before you have even put your phone down.

That is the whole point. Not that the other methods do not work. Just that this one is faster than all of them.

Wrapping up

Full disclosure, ISP charges around 5 naira for every SMS message sent. If you don’t mind these little charges, this is most certainly for you.

The catch-22 of buying data without data is not a uniquely Nigerian problem. Any prepaid mobile market has it. What is Nigerian about this solution is the context it was built in, the specific friction of the market, the way people actually communicate, the mix of languages and formats that only a model trained on diverse text can handle gracefully.

The offline SMS channel is not a replacement for the app. Most users will still buy data through the app most of the time. But when they cannot, the text message is there, and it works exactly the way you would want it to. Fast, simple, no dependencies.

Kayode Ojo
Software Engineer
No items found.
No items found.
No items found.

Recent articles