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
};
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:
SelectProvider(null, 5000) → currency.ToUpper() crashes on null. Fix: one IsNullOrWhiteSpace guard before the switch.
EF Core InMemory enforced the constraint. Fix: explicit TenantId parameter in CreateIntentAsync().
Same class of bug, different entity. Fix: add TenantId to WebhookEvent model.
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.