biya:~$ cat payments-in-a-chat-window.md

Payments in a chat window: the four constraints that shape everything we build

No app to download means no client state, no session, and no screen you control. Here is what that actually costs, and the three-outcome rule that took us longest to learn.

Biyabot is a Nigerian payments platform. People pay bills, buy airtime and data, and collect money from customers, mostly without installing anything. The main interface is a chat window they already have open.

That one decision, no app, drives most of the engineering, and not in the ways you would guess. This is the first of a series about how the system actually works, including the parts that have gone wrong. Before any of that, here is the shape of it and the four constraints everything else follows from.

Three products, one problem

For individuals, a chat bot. Telegram, Slack, Messenger, or the web. You send a message, you buy airtime, you pay for electricity, you get a token back in the same thread.

For businesses, a dashboard for the things a finance team does in bulk: airtime to a hundred staff phones, payroll, spend that needs a record.

For merchants, tools for taking money: payment requests that reach a customer inside their chat app, a point of sale, inventory, settlements.

They are separate products with separate users, and they share more than they look like they do. A merchant raises a payment request; the customer approves it in a chat window; the funds land in the merchant's wallet. Three products, one transaction.

Constraint 1: the interface is a window you do not control

A mobile app has state. You own the screen, you know what the user is looking at, you can hold a session in memory and put a spinner on it.

A chat window has none of that. You get a string of text, sometimes a button tap, from a person who may have last spoken to you six weeks ago, on a platform whose rules you did not write. Buttons behave differently on Telegram than on Slack. Some channels let you start a conversation, others only let you reply. A message you send may arrive out of order relative to one you sent a second earlier.

So conversation state lives on our side, not theirs, and every flow has to survive the user vanishing halfway through and returning later. "The customer closed the tab" is not an edge case here. It is Tuesday.

The upside is the whole thesis: there is no download, no new password, no app taking up space on a phone that is already full. The cost is that a lot of the machinery an app gives you for free, you build.

Constraint 2: money is integers, and the rule has exceptions

Every amount is stored in kobo, the hundredth of a naira. No floats anywhere near money. This is standard advice and we follow it.

The interesting part is the exceptions, because there are always exceptions. An older subsystem stores an amount in naira. A partner's API returns naira as a string with a comma in it. A field name says one unit and holds the other.

Each of those is fine on its own and known to whoever wrote it. The failure mode is a value crossing a boundary between two components that each have a correct and different assumption. That produces a number that is exactly a hundred times wrong, which is either obvious or invisible depending on how big it was to begin with.

We treat units as part of a field's type, not as a comment. A helper that converts is named for what it converts. Where a boundary has an exception, the exception is written down at the boundary rather than in someone's memory.

Constraint 3: an external call has three outcomes, not two

This is the one that took longest to internalise and is the most useful thing in this post.

You call a provider to deliver airtime. You think there are two answers: it worked, or it did not. There are three.

  1. It worked. You have an acknowledgement.
  2. It definitely did not work. The provider told you so, explicitly.
  3. You do not know. The connection timed out. The socket closed. You got a 502 from something in front of the provider that may or may not have forwarded your request.

The third outcome is the whole game, and it is the one that gets collapsed into the second by tired code. A timeout is not a failure. It is an absence of information. The airtime may be sitting on the customer's phone right now.

Treat unknown as failed and refund, and you have paid twice. Treat it as succeeded, and a customer paid for nothing. The only correct move is to hold it as unknown, go and ask the provider what happened, and act only once you have an answer.

Concretely: nothing auto-refunds on an ambiguous result. Refunds happen on definitive non-delivery only. Anything unresolved goes to a person, because a human deciding slowly beats an algorithm deciding wrongly with someone's money.

Constraint 4: the provider will be down, and it will not be tidy about it

We vend through more than one provider. Not for elegance: because any single one will have a bad afternoon, and "the network is down" is not something a customer accepts from you when they can see their phone working fine.

Multiple providers sounds like a solved problem until you try it. They disagree about everything that matters. Different names for the same product. Different field names for the same value. Different ideas about what counts as an error. One returns HTTP 400 with a clear explanation; another returns HTTP 200 with the word "failed" inside the body.

So the routing layer is not a switch. It is a translator, and every assumption it makes about one provider has to be checked against the other. We have a post coming about the time this went wrong in the most instructive possible way: a provider that rejected every single request we sent it, for weeks, while every customer was served perfectly and nothing alerted.

What this blog is for

Mostly incidents, and what they taught us.

Not case studies with the failures sanded off. The useful posts are the ones with a specific bug, the specific reasoning that missed it, and the specific change that means it cannot happen the same way twice. We keep those notes internally anyway. The interesting ones are worth publishing.

Some of it will be Nigeria-specific, because building payments here has constraints that do not show up in the literature. Most of it will not be. A cron job that logs success while sending nothing has the same shape wherever it runs.

First one up: how a working failover hid a total outage, and why the fix was a single log line.