.NET 10 Blazor M-PESA Payments Architecture

How I Built a Multi-Provider Payment Engine in .NET 10

AL
Azim Litanga
· April 1, 2025 · ⏱ 12 min read
The PayCore Series — Article 1 of 4 · logicdynamics.net

I didn't start with a grand plan. I started with a folder and a question.

The question was: can a single .NET application intelligently route payments to Stripe for US dollar transactions, to M-PESA for Kenyan Shillings, to Airtel for Ugandan Shillings, and to PayPal for Mexican Pesos — all through one clean interface?

The answer is PayCore. This is how I built it.

The problem with payment integrations

Every payment provider has its own authentication model, its own webhook format, its own error vocabulary, and its own quirks. Stripe expects form-encoded request bodies — not JSON. M-PESA's OAuth token endpoint is a GET request, not a POST. PayPal needs amounts in decimal format while M-PESA needs integers. Stripe signs webhooks with HMAC-SHA256; M-PESA relies on IP whitelisting.

Without an abstraction layer, each integration is a separate codebase. Four providers means four completely different systems to maintain, test, and debug. Every new feature has to be built four times.

"The adapter pattern is not a design pattern choice. When you're building a multi-provider payment system, it's the only sane architecture."

The architecture: one interface, four implementations

PayCore's core is a single interface that every payment provider must implement:

public interface IProviderAdapter
{
    string Name { get; }  // 'stripe' | 'mpesa' | 'paypal' | 'airtel'
    Task<AuthorizeResponse> AuthorizeAsync(AuthorizeRequest req);
    Task<CaptureResponse>   CaptureAsync(CaptureRequest req);
    Task<RefundResponse>    RefundAsync(RefundRequest req);
    Task<VoidResponse>      VoidAsync(VoidRequest req);
    Task<WebhookEvent>      HandleWebhookAsync(string payload, IHeaderDictionary headers);
}

The Name property was the critical addition. Without it, the routing engine has no way to select the right adapter at runtime. With it, the entire system works like this:

// RoutingService decides the provider
var providerName = _routing.SelectProvider(currency, amount);
// → 'mpesa' for KES, 'stripe' for USD, 'paypal' for MXN

// PaymentService resolves the adapter by name
var adapter = _adapters
    .FirstOrDefault(a => a.Name == providerName)
    ?? _adapters.First(a => a.Name == "stripe"); // safe fallback

// Then calls it — same interface regardless of provider
var auth = await adapter.AuthorizeAsync(request);

The routing engine: currency as geography

Every currency is associated with a dominant payment ecosystem. The routing engine encodes that market knowledge as executable code:

return currency.ToUpper() switch
{
    // African mobile money
    "KES" => "mpesa",   // Kenya — M-PESA: 51M users, $61B/year
    "TZS" => "mpesa",   // Tanzania
    "GHS" => "mpesa",   // Ghana
    "UGX" => "airtel",  // Uganda — Airtel Money dominates

    // International cards
    "USD" => "stripe",
    "EUR" => "stripe",
    "GBP" => "stripe",

    // LATAM digital payments
    "MXN" => "paypal",
    "BRL" => "paypal",

    _ => "stripe" // safe fallback for unknown currencies
};
💡 Compliance override Any USD transaction of $10,000 or more routes to Stripe — regardless of what the switch says. Stripe's fraud detection and reporting tooling is better equipped for high-value compliance. This rule sits before the switch and runs first.

The M-PESA integration: the most interesting adapter

M-PESA's Daraja API is a masterpiece of enterprise fintech complexity. The STK Push flow — pushing a payment prompt directly to a customer's phone — requires several non-obvious steps:

// 1. Get OAuth token via GET (not POST — M-PESA's quirk)
// GET /oauth/v1/generate?grant_type=client_credentials
// Authorization: Basic Base64(ConsumerKey:ConsumerSecret)

// 2. Build time-based password — unique per request
// password = Base64(ShortCode + Passkey + Timestamp)
// Timestamp format: yyyyMMddHHmmss

// 3. STK Push — amount must be integer, phone must be 254XXXXXXXXX
// 4. Customer sees popup on their phone, enters PIN
// 5. M-PESA POSTs callback to your CallbackUrl
// 6. Parse ResultCode: 0 = success, anything else = failed

Phone normalization: eight lines of load-bearing code

private static string NormalizePhone(string phone)
{
    phone = phone.Trim().Replace("+", "").Replace(" ", "");
    if (phone.StartsWith("0"))    phone = "254" + phone[1..];
    if (!phone.StartsWith("254")) phone = "254" + phone;
    return phone;
}

// 0722000000    → 254722000000 ✅
// +254722000000 → 254722000000 ✅
// 254722000000  → 254722000000 ✅
// 722000000     → 254722000000 ✅

Before writing this function, I would have said: "Phone number input. Easy. Just validate the format and reject anything invalid." After writing it, I understand that "invalid" is a developer's word, not a user's word. The user knows their phone number — they just don't know which format your API expects.

The test suite: 42 tests, 3 production bugs

The test suite caught three bugs before they ever reached production:

🐛 Bug #1: NullReferenceException on null currency

SelectProvider(null, 5000)currency.ToUpper() crashes on null. Fix: one IsNullOrWhiteSpace guard before the switch.

🐛 Bug #2: PaymentIntent missing TenantId

EF Core InMemory enforced the constraint. Fix: explicit TenantId parameter in CreateIntentAsync().

🐛 Bug #3: WebhookEvent missing TenantId

Same class of bug, different entity. Fix: add TenantId to WebhookEvent model.

Total tests: 42/42 ✅ Time: 1.9 seconds Production incidents: 0

The result: a real payment orchestrator

PayCore v7.0 routes payments across four providers, processes webhooks in real time, broadcasts live events to a Blazor WebAssembly dashboard via SignalR, lets Claude query the database in natural language via an MCP Server, and deploys with a single command:

docker compose up --build

# PayCore.Api      → https://localhost:7060
# PayCore.Console  → https://localhost:7239
# PayCore.Mcp      → https://localhost:7070/mcp
# PostgreSQL        → localhost:5432
"PayCore isn't a demo. It's not a portfolio project. It's a production-ready payment orchestration engine that routes mobile money across Kenya, Uganda, Ghana, and Tanzania through the same interface as Stripe and PayPal." — LogicDynamics

The next article covers how I built an MCP Server in .NET 10 that lets Claude analyze real payment data in natural language — and what it found.

Next in series →
Teaching Claude to Query My Payment Database
AL
Azim Litanga

Founder of LogicDynamics — .NET engineer, builder, and creator. Passionate about Blazor, Web APIs, AI, and shipping real software. Based in Oklahoma City.