AI
← All posts

· 14 min read · Engineering

Building Sonecare: an operating system for Nigeria's HMOs and providers

An enrollee walks into a clinic in Lagos. She hands over her HMO card. The receptionist opens WhatsApp and messages the HMO's preauth desk for a code so the consultation can happen. Twenty minutes later the code arrives, the doctor sees her, and the clinic sends an invoice at the end of the week by email. Three months later the clinic is still asking when the money will come. At the HMO, someone opens Excel and reconciles the batch by hand.

That's not a horror story. That's the baseline most Nigerian HMOs and providers run on. Every piece of it works, barely, held together by WhatsApp, email, spreadsheets, and the goodwill of people who've been doing this for years. The friction isn't anyone's fault. There just hasn't been a system built for this market from the ground up.

That's the thing I set out to build. Sonecare is an operating system for Nigeria's HMOs and providers. Not a claims tool, not a reporting dashboard. The whole revenue cycle plus the admin layer that makes it trustworthy, on one platform, with one audit trail.

Why now: the NHIA 2025 reset

Nigeria's National Health Insurance Authority ran a reset in 2025 that most tech vendors have been slow to catch up with. The National Health Insurance Industry Risk Assessment pushed through a set of changes that collectively redraw how claims flow:

  • Fee-for-service tariffs went up around 378%. Capitation went up around 93%.
  • A hard one-hour SLA on preauthorization decisions.
  • A hard 60-day settlement window from accepted batch to paid remittance.
  • Standardized denial reason codes across the industry.
  • Per-provider SLA breach reporting back to the regulator.

Together these turn claims processing from an informal process into a regulated clock. You can no longer afford to lose a claim in an email thread. You can no longer afford to settle in 90 days. And you can't adapt a US-built system that was designed around CPT, HCFA-1500 and CARC without reshaping half of it, because the Nigerian benefit catalogue, settlement model and compliance surface are genuinely different.

So the question I kept coming back to was: what would a platform look like if you designed it for this market, to this specification, from the first commit?

The thesis: an operating system, not a feature

The temptation when you say "healthcare claims" is to build a claims app. Submit, review, approve, pay. A lot of what I see in this space goes one step further and tries to help even earlier — OCR a scanned claim form, extract the line items, structure them, push the result to the payer. It's a reasonable thing to build. The forms are everywhere, the pain is obvious, and the market will pay for anything that shortens the gap between "the clinic emailed us a stack of claim forms" and "we've got them in our system."

But by the time you're OCR'ing the form, every mistake that matters has already happened. The eligibility wasn't checked at the point of service. The diagnosis code was chosen in a hurry at the end of a long shift. The procedure wasn't matched against the preauth that was actually issued. The copay rule for the dependent was ignored. The claim is just the output of a long chain of events — enrollee → encounter → preauth → service → claim → adjudication → settlement — and the chain is what breaks. If you want claims to be clean, you have to own the whole chain, starting from the moment the enrollee walks through the door.

So Sonecare spans:

  • Enrollment and eligibility. Enrollees, their dependents, the plan they're on, when it's active, and the card they walk into a clinic with.
  • Coverage and plans. Versioned health plans and benefit groups aligned to the NHIA catalogue.
  • Provider network. HMO-to-provider agreements, invitations, onboarding and status across the network.
  • Encounters. A provider logs a visit and declares what's being rendered.
  • Preauthorization. Per-benefit requests, bundled where appropriate, with authorization codes and the one-hour clock.
  • Claims and adjudication. Per-line review with standardized denial reason codes and versioned decisions.
  • Settlement and remittance. Batching claims, posting payments, and tracking the 60-day window.
  • Analytics. Dashboards that answer different questions depending on who's logged in.
  • Admin and RBAC. The structure of the organization itself: branches, departments, teams, staff, roles, and granular permissions.
  • Subscriptions and billing. Platform tiers and invoices for the org itself.
  • Workflows and notifications. Approval flows plus email and SMS delivery.
  • Audit. Immutable history on every mutable model, plus explicit event logs.

Four organization types share the same platform: payers (HMOs), providers (hospitals and clinics), clients (employers who buy coverage for their staff), and the regulator. Beyond the organizations, individual enrollees are first-class users too — a patient logs in to see her plan, her remaining benefits for the period, her dependents and her claims history. The data model has been designed for that from day one; the self-service frontend is next on the roadmap. Each of these users sees a different slice of the same data on the same platform.

The project page at /projects/sonecare has a walk through the main modules in screenshots.

Sonecare architecture: four factions, one platform, one audit trail

The next sections are the engineering decisions I keep going back to because they're what make the breadth work. I'll walk through them in roughly the order the data flows: set up the foundation, verify against it, serve it to the right users, and keep every decision versioned and auditable.

Setup: everything that has to be true before the first claim

The most under-appreciated part of a healthcare platform is the operational setup that precedes the first claim. It's not glamorous, it's not what demos lead with, but it's where most of the engineering lives. If this layer is wrong, nothing downstream can be right.

Enrollees and dependents. An HMO brings enrollees in two ways: in bulk from an employer's roster, or individually for self-paying members. Each enrollee is on a plan with an effective date range. Each enrollee can have dependents attached — a spouse, children — and each dependent is tied to a principal and bound by the plan's dependent rules. The platform tracks who is covered for what, and when.

Plans and benefit groups. A plan isn't a label on a CSV; it's a versioned bundle. Each plan contains benefit groups, each of which is a reusable package of specific benefits (an outpatient GP visit, a lab panel, a specialist referral, an inpatient surgery). The same "Basic Primary Care" benefit group might appear in Basic, Standard and Premium plans with different limits applied. When tariffs change, a new plan version is minted; the old version stays intact so historical claims aren't retroactively nuked.

The benefit catalogue. This is where NHIA alignment lives. Benefits in the catalogue map to NHIA fee-for-service codes with tariff ceilings. Each benefit knows whether it's a primary-level, secondary-level or tertiary-level service, which determines whether a given provider is even allowed to render it. Each is categorized — consultations, labs, imaging, pharmacy, inpatient, maternity, chronic care — so limits can be applied per category without hand-maintaining lists.

Diagnosis codes: ICD-10. For diagnoses, Sonecare uses ICD-10, the global standard. Every encounter and every claim line carries at least one ICD-10 code. The diagnosis isn't incidental. It's what verification uses to answer the real question — does this procedure make clinical sense for this condition? Without a disciplined coding backbone that question can't be asked at all.

The provider network. A payer invites providers to join its network. Each payer-provider relationship captures the agreement date, status, and the specific tariff schedule in force for that pair. Two HMOs can have the same provider on their network with different rates for the same procedure, and both are respected.

Once this foundation is set up — enrollees onboarded, plans versioned, benefits catalogued, codes attached, network wired — a provider logging a visit for an enrollee has everything the platform needs to automatically verify eligibility. Which is where it gets interesting.

Enrollee Management: enrollees, dependents, plan assignment

Verification as a chain, not as scattered checks

The first time I wrote eligibility logic I did what everyone does: a bunch of if statements inside the preauth view. Within two features it was unmaintainable. There were edge cases around dependents that lived in one place, facility-level rules that lived in another, tariff ceilings somewhere else, and I could never confidently answer "has this benefit been verified?"

So I pulled it out into an explicit chain. The current engine runs 42 checks against an EncounterBenefit before the preauth request can go to a reviewer. The chain, roughly in order:

  • Is the enrollee active and not suspended?
  • Is their coverage in effect at service date?
  • Does their plan cover this benefit?
  • Is the benefit covered at this facility's level (primary, secondary, tertiary)?
  • Is the quantity within the plan's monthly or annual limit?
  • Is the cost within the NHIA tariff ceiling and the payer-provider contract cap?
  • Which copay rule applies: primary care, specialist, or referred?
  • If this is a dependent, is dependent coverage eligible for this benefit?
  • For inpatient, is present-on-admission logic satisfied?
  • A dozen more that cover the specific NHIA edge cases.

When all 42 pass, EncounterBenefit.verification_approved_at gets stamped. Downstream, the preauth short-circuits: if a benefit is already verified, the reviewer sees it green with the decision pre-computed, and only has to confirm. If any check fails, the specific failure is attached to the benefit so the provider sees exactly what went wrong before the preauth is even submitted.

The payoff isn't the rule engine itself. It's that the reviewer's job shrinks. Instead of staring at every claim trying to decide if it's eligible, they're reviewing the exceptions the engine couldn't fully resolve. Clean claims adjudicate cleanly. Days-to-payment goes down for the provider. Denial rate goes down for the payer. Everybody wins, and the only cost is the discipline of writing the rules once, in one place.

Encounter detail with information, verification, and pre-authorizations tabs

That handles the front half of the cycle. On the back half, the provider doesn't send claims one at a time; they batch, typically monthly, and submit the batch as a single envelope that moves through its own state machine (DRAFT → SUBMITTED → ACCEPTED → PARTIALLY_REMITTED → REMITTED → CLOSED). The payer accepts the batch, which starts the NHIA 60-day settlement clock. When they're ready to settle, they issue a RemittanceAdvice listing which claims are being paid at what amounts; posting the payment flows down to each claim's remitted_amount and the batch closes out. The provider sees exactly where every claim sits — accepted, remitted, closed, or still in review — and the payer sees which batches are approaching their SLA breach. That's the full arc, from the enrollee walking through the door to the money arriving, running on one platform with one audit trail. No email thread. No Excel reconciliation at the end of the month.

One analytics URL, four dashboards: multi-faction routing

Once the data is set up and verified, the next design question was how to serve four organization types without the code turning into a mess of if faction == "PAYER" branches scattered through every module.

I decided early that there would be no faction column on any domain model. The organization itself has a type:

class Organization(BaseModel):
    class Type(models.TextChoices):
        PAYER = "payer"
        PROVIDER = "provider"
        CLIENT = "client"
        REGULATOR = "regulator"
 
    organization_type = models.CharField(
        max_length=16, choices=Type.choices,
    )

That's the only place the distinction lives. Everything else dispatches off it.

On the frontend there's a single route, /analytics. A useFactionRouting hook reads the current org's type from the auth context and renders the right dashboard component. A payer gets denial rate, AR aging and claim throughput. A provider gets payment timelines and peer benchmarks. A client gets utilization across their enrolled employees. The URL is the same for all three; the layout and nav shell are the same; the RBAC gate is a single analytics:read permission.

On the backend, each domain app owns its own metrics. claims.selectors.ClaimMetricSelectors knows how to compute denial rates and days-to-payment. encounters.selectors.EncounterMetricSelectors knows encounter volume by type. The analytics app doesn't recompute any of that; it composes:

# apps/analytics/services/composers.py
def compose_dashboard(org, period):
    match org.organization_type:
        case Organization.Type.PAYER:
            return payer_dashboard(org, period)
        case Organization.Type.PROVIDER:
            return provider_dashboard(org, period)
        case Organization.Type.CLIENT:
            return client_dashboard(org, period)

Each composer pulls from the domain selectors and returns the shape the frontend expects. When claims adds a new metric, analytics gets it for free. When a new faction comes online, I add a new composer and a new frontend module. The old ones don't change.

Analytics overview, dispatched per organization type

The admin layer: four-layer RBAC

If I had to pick the most under-appreciated part of a healthcare platform, it would be the admin layer. Most claims tools treat RBAC as "admin can do everything, reviewer can approve, viewer can read." That falls apart within a week of talking to a real HMO.

Sonecare evaluates four layers for every permission check:

  1. Platform subscription. What the Sonecare SaaS tier allows. Some modules are gated behind higher tiers.
  2. Organization subscription. What the specific HMO or provider has paid for inside their account.
  3. Organization settings. The override layer: feature flags, workflow toggles, policy choices the org has made for itself.
  4. User permission. The final gate, keyed on module:action codes.

Permission codes are deliberately granular. For claims alone there are codes like claims:read, claims:adjudicate, claims:finalize, claims:appeal, claims:manage_codes. There's a registry in apps/system/config/permission.py that's grown past a thousand lines, which sounds like a lot until you realize how many fine-grained actions actually exist across the modules.

Each view method declares what it needs:

class ClaimAdjudicationView(APIView):
 
    @require_permission("claims:adjudicate")
    def post(self, request, claim_id):
        # The decorator has already checked all four layers for this user,
        # on this org, against this permission code. If any layer rejects,
        # we never get here.
        return services.adjudicate_claim(
            claim_id=claim_id,
            actor=request.user,
            decision=request.data,
        )

The decorator doesn't hit the database four times. There's a request.permission_checker that's built once per request and cached for the lifetime of that request. It knows the user, their org, their subscription state and their effective permission set, and it answers every subsequent check in-memory.

The piece I'm proudest of is hierarchical staff impersonation. A platform admin sometimes needs to see what a specific user sees, or act on their behalf to resolve a support case. That action is permitted but every single thing done in that session is written into the audit trail with both the acting user and the impersonated user, so it's always traceable who actually did what.

The reason this matters is that a payer's internal reviewer, a TPA that the payer has delegated adjudication to, and a provider submitting their own claim all need different gates on the same Claim object. Four layers is what it takes to express that cleanly without hard-coding roles.

Admin → authorization: roles and granular permissions

Everything is versioned

When NHIA raised fee-for-service tariffs by around 378% in April 2025, a naive system would have had a problem: a claim submitted in March at the old rate, adjudicated in May after the hike, would either silently settle at the wrong amount or require someone to manually freeze the historical tariff. Multiply that by thousands of claims and you have a reconciliation nightmare.

The design answer is to version aggressively and snapshot everywhere it matters:

  • PlanVersion is immutable per version. When a plan changes, a new version is minted. Existing claims stay bound to the version that was in force at service date.
  • ClaimLine.benefit_code_snapshot captures the benefit code and its description at the moment of submission.
  • PreAuthorizationRequest captures a frozen snapshot of enrollee, plan version, approved quantity and approved amount.
  • Claim links to its preauth through both a navigable FK and a frozen authorization_number string, so if the preauth record is ever archived the claim still carries the proof of what was approved.
  • Monetary fields use djmoney's MoneyField and are snapshotted on submission for billed_amount, allowed_amount and approved_paid_amount. They are never recomputed.
  • ClaimAdjudication has a version field. The initial decision is v1. If the provider appeals and the adjudication is reopened, that becomes v2, with v1 kept intact.

In code the pattern looks like this:

class ClaimLine(BaseModel):
    claim = models.ForeignKey(Claim, on_delete=models.PROTECT)
 
    # Live FK — follow this for the current state.
    benefit = models.ForeignKey(Benefit, on_delete=models.PROTECT)
 
    # Immutable snapshot — the truth at submission time.
    benefit_code_snapshot = models.CharField(max_length=64)
    benefit_description_snapshot = models.CharField(max_length=255)
    tariff_amount_snapshot = MoneyField(...)

The rule I follow: anything a dispute could later be fought over has an immutable snapshot. Anything you want to navigate and keep up to date has an FK. Most important fields have both.

The version I'm genuinely proud of is on adjudication. A denied claim and its appeal are two different versions of the same adjudication record, not a mutation of one. The original denial is kept, with its reason codes and the identity of the reviewer. The appeal is a new row. If regulators audit, every decision ever made on that claim is there, in order, with who made it and when. Nothing was overwritten.

Audit isn't a feature; it's the shape of the data

This is the smallest section but it may be the one that matters most. The audit trail isn't a module you turn on. It's the shape of the data.

  • Every mutable model uses django-simple-history's HistoricalRecords, so every change writes a historical row.
  • Every domain model inherits an AuditTrailMixin that adds created_by, modified_by and deleted_by fields, plus a soft-delete column. Nothing hard-deletes.
  • Claim-level activity goes into a ClaimEvents table with explicit event types: claim_submitted, adjudication_completed, sla_breached, appeal_opened, remittance_posted. Business questions get answered against events, not against inferred state.
  • Claims carry metadata snapshots (encounter type, enrollee age group, is-emergency, visit type) at submission, so analytics never has to re-join through the source tree a year later when some upstream model has changed.

The practical effect is that if a regulator ever asks "who made this decision, when, and based on what?", I don't have to go archaeology through logs. The answer is a query away. Every denial is defensible and every decision is traceable, because the data was shaped to answer those questions from day one.

Claim batch: an immutable submission envelope

What's next

A few things that are already shipping or imminent:

  • Formal appeal workflow that enforces the NHIA 30-day appeal window and caps appeals per claim at a policy-defined number, backed by the versioned adjudication record above.
  • ClaimAttachment for supporting documents (referrals, prescriptions, lab reports, discharge summaries) with GCS upload and virus scanning in a Celery task.
  • Regulator faction with read-only cross-HMO visibility and NHIA-format CSV exports, so the regulator becomes a user of the system instead of being handed PDFs each quarter.
  • Enrollee self-service. The individual enrollee logs in to see their active plan, their remaining benefits for the period, their dependents and their claims history. The data model has been designed for it from the start; the frontend is the missing piece.

The longer bets I'm actively thinking about:

  • Deeper clinical features for the provider side. The payer side of the platform is where most of the depth has gone so far, because that was the acute pain. The provider side has a lot of headroom: appointment scheduling, clinical notes, e-prescribing, lab ordering, discharge summaries. Each of these feeds back into cleaner encounters and cleaner claims downstream.
  • ICD-10 matching via RAG. Clinicians don't speak in codes. They write "chest pain, two days, no radiation" and someone has to pick the right ICD-10 code — often several — to attach to the encounter. A retrieval-augmented layer over the ICD-10 catalogue would let a provider describe a diagnosis in natural language and get a ranked set of candidate codes to pick from, with the reasoning for each. The downstream effect is cleaner coding, which is one of the most common sources of claim denials in Nigerian HMO data.
  • Offline-first QR member cards for clinics with unreliable connectivity, so eligibility can be verified at the point of service without a network round-trip and queued for sync when it returns.
  • Clinical decision support in the apps/clinical module. The question I want it to answer isn't "diagnose this patient" but the much more tractable "does this diagnosis plausibly justify this procedure?" as a hint to the reviewer.
  • Real-time preauth webhooks so HMO internal systems can act on preauth decisions without polling.
  • TPA sub-delegation so an HMO can delegate claims review to a third-party administrator under the same RBAC, without the TPA getting access to data they shouldn't see.

Each of these is a project of its own. The core platform doesn't change shape to accommodate any of them, which is a sign the foundations are the right ones.

What building for this market has taught me

A few lessons that have shaped how I write code now, not just on this project:

Strict typing from day one, not as a cleanup. Python with strict mypy, TypeScript with no any. A healthcare platform has too many places where a wrong field can cause a wrong payment. Types catch a surprising number of those before they ship.

Don't assume connectivity. The clinic you're serving might be in Ikoyi with gigabit fiber or in a rural LGA with intermittent 3G. The second one is the harder constraint and it has to be designed for, not retrofitted.

Multi-stakeholder complexity is a first-class concern. The regulator is a user. The employer is a user. The enrollee is a user. The TPA is a user. Treat them as users from the beginning or you'll be grafting them on later and getting it wrong.

The hardest part of a healthcare platform isn't claims. It's the setup and admin layer that makes claims trustworthy. Versioned plans, a disciplined benefit catalogue, a verification engine that stamps clean benefits automatically, four-layer RBAC, immutable audit trails — these are the parts without which no HMO would ever let you near their data, and they're also the parts you can't retrofit. Build them before the first claim form.

The project page at /projects/sonecare has a walk through the main modules in screenshots if you want to see what the surface looks like today. If you've built or operated something similar in a different market, I'd genuinely like to hear how you solved the same problems. There's more than one right answer here and I'm still learning.

Comments