Engineering notes

How Billing Gateway is built

Billing Gateway is a multi-tenant cloud platform for billing and access control on MikroTik networks. Operators sell timed or data-capped Wi-Fi vouchers; the platform authorises every login, enforces the limits centrally, and gives the operator the numbers on their phone.

It's built to handle specific constraints: Starlink behind CGNAT, mains power that comes and goes, a currency that moves, and a market that runs on cash. Most of the interesting decisions below come from one of those four.

June 2026

Live since

8

Routers

9,800

Vouchers activated

60

Days, no downtime

Last updated June 2026 · about 7 minutes to read

If you run a hotspot and want the product rather than the internals, start here.

The problem

Almost every small hotspot, WISP, guest Wi-Fi in this market runs on one of two free tools: Mikhmon, which runs on a computer at the shop, or MikroTik's own User Manager, which runs on the router itself. Both work. Both share the same structural weakness — all the state lives at the site.

That produces four failures, and operators describe all four without ever connecting them to a cause:

  • 1

    State is lost when the hardware (router) is..

    Either reset, replace or damaged. Resulting with the users, vouchers and usage history being lost with it. A batch of printed vouchers becomes worthless paper as history is lost.

  • 2

    Expiry is enforced locally, so it fails quietly

    Counters live on the router. After a reboot, or a script that did not survive an upgrade, sessions simply keep running. Nobody is notified, because nothing errored — the customer just keeps browsing.

  • 3

    Nothing is auditable

    There is no independent record of what was sold, so cash cannot be reconciled against usage. The owner estimates the sales and hopes it correct.

  • 4

    Operations require physical presence

    Generating a batch of vouchers, changing a price, or cutting off a customer means somebody travels to the site.

The design goal that follows solves this issues: move every piece of state that matters: identity, entitlement, usage, money.. are moved off the router and into one place that is authoritative, auditable and reachable from a phone or any other remote device. The router should hold no records it cannot afford to lose in case it fails or malfunctions.

Architecture

The router keeps no user database. It holds a hotspot, a WireGuard peer and a heartbeat schedule, and it asks the platform about every single login. Everything that decides whether someone gets online (the voucher, its remaining time and data, the tenant's account standing) all live in one place.

captive portal RADIUS, over the tunnel rlm_rest, HTTPS reads and writes called by Laravel Customer phone enters the voucher PIN MikroTik RouterOS v7 hotspot + WireGuard peer FreeRADIUS authentication + accounting Laravel API policy, billing, tenancy PostgreSQL shared — app tables + radacct Go service pushes config to the router At the site In the cloud
One authorisation request, end to end. Customer traffic never crosses the tunnel — only authentication, accounting and management do.

The tunnel is outbound. Standard Starlink is behind CGNAT, so there is no public IP to reach and no port to forward. The router dials out to the platform over WireGuard instead, which means a site needs nothing more than a working internet connection.

Only control traffic crosses it. Customer browsing leaves through the local WAN as normal. What travels over the tunnel is RADIUS, accounting and the occasional API call — so bandwidth cost stays flat regardless of how much a site sells, and so does hosting cost. That single fact is why the pricing is per router rather than per voucher in most cases.

One endpoint, three protocols. The API routes on Service-Type and Framed-Protocol, so hotspot, PPPoE and WPA2-Enterprise share the same authorization path and differ only in which action handles them and which attributes come back.

Zero-touch provisioning

Every customer I have arrived through a technician, and every one of those technicians had built this same configuration stack and steps by hand, many times. The cumbersome setup pain was not a feature request but one of the main reasons they switched. So the target was one paste into a terminal on a factory-reset router to enable automatic configuration of all requirements.

Run once on the router

> /tool fetch url=https://your-server/p/KEY mode=https dst-path=ztp.rsc; :execute { /import ztp.rsc }

The script it fetches is generated per router and configures:

WireGuard peer and keys Dual-bridge client isolation DHCP server NAT and fast-track Captive portal and login page Walled garden API user, firewalled Hardware heartbeat schedule

The onboarding screen then polls for the router's first heartbeat and moves the tenant to their dashboard the moment it arrives. So the technician immediately gets a definite answer instead of wondering whether it worked.

What this endpoint had to get right

Fetch-and-import is remote code execution by design. Whatever comes back over that connection is executed on the router with full administrative rights, so two things are not optional:

  • A verified channel

    Without certificate verification, anyone positioned between the router and the server can serve their own script. On shared or resold links that is not a hypothetical. RouterOS v7 will verify properly once the CA bundle is imported, which is one more line in the same ZTP command above.

  • A key that dies after use

    The provisioning UUID in the URL is the only secret in the flow, and it travels in a command people copy, paste and screenshot. It expires on first successful import, and after a short window regardless.

  • Limits that survive CGNAT

    Per-address throttling is the obvious control and the weakest one here, most of this market sits behind carrier-grade NAT, so a legitimate technician could possible share an address with multiple strangers. The per-key limit does the real work: a provisioning key is expected to be fetched once, by one router, within minutes of being issued. The per-address limit is set generously, as a backstop against bulk scanning rather than as the main defence.

Authorization and metering

Moving the decision off the database

The conventional FreeRADIUS setup writes credentials and limits into radcheck and lets the server read them. That works, but it means every voucher, price change and suspension has to be synchronized into RADIUS tables and anything synchronized can potentially end up inconsistent if not done right.

Instead, FreeRADIUS calls the application at authentication time via rlm_rest, and the application answers with the correct attributes to apply.

The trade-off I made: a network round trip and an application dependency on the authentication path, in exchange for no synchronization, no inconsistencies over time, and policy decisions — including whether the tenant's own account is in good standing (evaluated against live data at the moment someone tries to connect)

A flat reply, FreeRADIUS specific..

rlm_rest has a nested JSON schema with value, op and do_xlat keys. It also accepts a flat object where the list is a prefix on the key, which is far less code to generate and far less to get wrong:

What the API returns

{
        "control:Cleartext-Password": "A7F2K9",
        "control:Simultaneous-Use": "1",
        "control:Max-All-Session": "3600",
        "control:Max-Total-Octets": "1073741824",
        "reply:Mikrotik-Rate-Limit": "2048k/2048k"
        }

FreeRADIUS reads the prefix, assigns each value to the right internal list, and the pap module validates against the password it was handed, so the request never touches radcheck at all. Missing keys are simply absent rather than requiring an empty object.

Why the clock cannot be reset

The interesting part is that Max-All-Session and Max-Total-Octets go back as control attributes, not reply attributes. FreeRADIUS intercepts them with sqlcounter, sums the session's prior usage from radacct, and sends the router only the remaining amount.

So a one-hour voucher used for forty minutes, then disconnected and reconnected, gets twenty minutes; not another hour. The router enforces a countdown it was handed; the arithmetic happens centrally, where it survives reboots, resets and replacement hardware.

That is the whole problem from the first section, closed. On a router-based system the counter lives on the thing that keeps rebooting; here the router is told the answer and never has to remember it. The billing gateway cloud does most of the work!

Charging exactly once

RADIUS is UDP, and FreeRADIUS retries when a reply is slow. A retry that activates a voucher twice, or debits an account twice, is a correctness failure people notice immediately — so activation sits behind two layers: a short-lived cache lock keyed on the voucher code, and a lockForUpdate() inside the transaction to prevent race conditions.

An already-active voucher takes a separate path: it returns the same attributes without charging again. Someone whose phone drops and reconnects is not a new sale, and should not look like one in the ledger.

Vouchers remember their own data snapshots

Plans define what a voucher is worth — price, duration, data cap, speeds, device limit. Those values are copied onto the voucher at generation rather than read through the relationship at authentication.

It denormalizes deliberately. An operator who raises prices on Monday has already sold Sunday's printed batch at Sunday's price, and those vouchers must honour it. Editing a plan cannot retroactively change what a customer already paid for, and deleting one cannot invalidate vouchers sitting in a drawer unlike for subscriber logic.

Keeping the auth path short

Activation produces accounting work — a transaction, an invoice, ledger entries. None of it is needed to answer the question "can this person get online?", so it is dispatched to a queue after the transaction commits. The customer is online while the books are still being written asynchronously.

Rejections (Invalid vouchers/subscribers) return with control:Auth-Type = Reject and a reply message. It keeps the rejection reason inside the RADIUS protocol, where the captive portal can show the customer something specific — "this voucher has expired" rather than a generic failure.

Multi-tenancy

One database, tenant-scoped, with a panel per tenant. The unusual part is where the tenant comes from: not a session, not a subdomain, but NAS-IP-Address on the incoming RADIUS request.

The router identifies itself, the router resolves to a tenant, and the tenant carries the billing context for the whole request. Nothing about the customer's input decides whose account is charged — which matters, because the customer's input is a six-character code typed into a captive portal by a stranger.

Three checks, in cost order

  1. 1

    Is the tenant in good standing?

    Checked before the database is touched at all. A suspended account costs one comparison rather than a query. And this runs on every login attempt across every site, so efficiency matters.

  2. 2

    Is the router ready?

    Suspended hardware and hardware that is still provisioning both reject, with different messages. Billing a voucher against a router the platform thinks is half-configured is a good way to produce a ledger nobody can explain later.

  3. 3

    Does this voucher belong here?

    A voucher is scoped to its tenant, so one operator's codes cannot be redeemed on another operator's router. It can also be pinned to a single router, for an operator running several sites who wants a batch to work at one of them only.

Many-to-many membership is modelled too — every account can have more than one login and assigned different roles and permissions (RBAC). The schema also records which user generated which batch of vouchers or any other event, so the audit trail exists too.

Built for local constraints

Four conditions shaped more of this system than any technical preference did.

Power that comes and goes

Power fails, a generator picks up, and routers can potentially restart several times a day. Rebooting is the normal operating condition here, not an incident. So nothing is allowed to depend on the router staying up. It reconnects its tunnel by itself, re-registers, and reports in on a schedule automatically. Customers are also re-connected automatically without having to re-type their codes.

A currency that moves

The pound went from roughly 6,500 to 7,400 to the dollar in a matter of months. Pricing denominated in SSP silently loses value; pricing shown in dollars is unhelpful to someone who earns and spends in pounds.

So amounts are denominated in USD, charged in SSP at a published rate, and reviewed monthly.

A market that runs on cash

Mobile money collection is built.

Mobile money was growing here until a cash shortage made withdrawals difficult or capped for businesses as of 2026. Collecting a customer's payment into an account the operator cannot draw from does not help him; it moves his money somewhere he cannot reach. But as the economy recovers, mobile money will be the default.

Reconciliation is designed around cash for now, and the platform's own subscriptions are collected in either cash, bank transfer or mobile money.

The billing model I had to replace

The platform started per-activation: a small fee deducted from a prepaid wallet each time a voucher was first used. It is a fair model. It scales with the customer's success, costs nothing on a quiet day, and the arithmetic favoured almost every tenant — most were paying meaningfully less than a flat fee would have cost them.

Some asked for the flat fee anyway. Predictability was worth more to them than being right about the price, and a number a technician can quote to a client without opening a spreadsheet is worth more than a number that is technically fairer.

It is now a fixed monthly price per router, which also matches the cost curve: hosting scales with routers and concurrent sessions, not with vouchers sold.

What I got wrong Initially, and what is still missing

In order of how much it would cost me if it went badly.

One machine held everything

Initially; WireGuard, FreeRADIUS, PostgreSQL and the Go service run on a single VPS with no failover. If it went down, every tenant stops authenticating new customers at the same moment, and the database goes with it. I am selling cloud reliability against a local tool that has never had an outage, so this is the gap that mattered the most and needed urgent fixing.

What happens next: Being fixed in cost order: off-box backups and monitoring first, then a managed database so the box becomes stateless and disposable, then a second RADIUS node. MikroTik fails over between RADIUS servers natively, so that part needs no router-side change.

No local authentication fallback

If the platform is unreachable, new logins fail. Sessions already established keep running, and a site whose own internet is down has nothing to sell anyway. So the only case that really pains is "site up, platform down", which is entirely my fault when it happens. That needed to change

What happens next: A local mirror of unused voucher codes on the router would close it, at the cost of reintroducing exactly the drift the central design exists to avoid. Not obviously worth it until uptime is solved properly.

A pricing boundary bug that ran for months

The per-activation fee used bracketed ranges, minimum inclusive and maximum exclusive. A 30-day voucher is 2,592,000 seconds — which falls out of the "1 week to 30 days" band and into "30 days to 1 year", at two and a half times the intended fee. The most commercially important product on the platform was never once charged at its advertised rate.

What happens next: It surfaced because a customer said his wallet was draining faster than expected, not because anything alerted. Boundary conditions in a pricing table are exactly the sort of thing that deserves explicit tests, and did not have them.

Two protocols to be implemented fully

PPPoE and WPA2-Enterprise are routed correctly and then rejected with an honest message. The identity model they need is different from a voucher: a named person with a persistent credential and a renewal date, rather than an anonymous single-use code.

What happens next: The plan is one subscribers table and one shared authorization action, with the protocol deciding only which attributes come back — PPPoE and EAP differ in transport, not in identity.

What is next

Hardening before features. The platform is only worth building on if it is still there next month.

Next

Harden provisioning

Single-use, short-lived provisioning keys and a verified TLS channel for the setup script. Small, and it removes the one remote code execution path in the system.

Next

Backups and monitoring off the box

Automated database dumps somewhere that is not the VPS, plus alerting that reaches me before it reaches a customer. Cheap, and it is the difference between a bad hour and a dead company.

Soon

Pause a subscription

Operators close for a season, or a generator dies for three weeks. Billing stops, configuration and vouchers survive. No foreign platform offers it, because no foreign platform expects a business to stop for a month.

Later

PPPoE and WPA2-Enterprise

One subscribers table for named, recurring identities, and one shared authorization action. The protocol decides which attributes come back: PPPoE adds address assignment, EAP changes the credential exchange but the identity model is the same.

Later

A second RADIUS node

Once the database is managed and the application box is stateless, a spare authorizer on another provider is straightforward. MikroTik fails over between RADIUS servers natively, so nothing changes at the site.

Stack

MikroTik RouterOS v7 WireGuard FreeRADIUS 3 PostgreSQL Laravel Filament v5 Livewire / Volt Go Alpine.js Tailwind CSS

Questions about any of this?

I am happy to talk about any of it — the RADIUS design, the provisioning flow, or what it is actually like running network software in a market like this one.

If you run a hotspot and wanted the product rather than the internals, that page is here.