← Blog

Introducing the BlendPay tech stack

One engine, one database, and a ledger that writes everything down first. How we build split-tender checkout, and why we chose boring tools on purpose.

19 October 2026 · Katlego Gaveni

BlendPay lets a shopper pay one order with two payment methods. Underneath that one row sits a system that has to talk to several payment providers, wait on each of them, recover when one says no, and never lose track of a rand. This post describes what we built it with, and the handful of rules that shaped every choice.

We keep a technology radar, in the spirit of the ones Thoughtworks and Stitch publish. Every tool sits in one of four rings: adopt, trial, assess or hold. Every tool in the adopt ring is there because of a decision we wrote down, and every tool on hold has its reason recorded so nobody argues it twice. A summary of the radar lives on our tech stack page.

One engine, one database

BlendPay is a single service in front of a single Postgres database. There are no microservices, no queue and no second datastore. That is a decision, not a starting point we plan to grow out of.

A payments product is mostly coordination: ask a provider for something, wait, verify the answer, decide what happens next. Splitting that across services would turn every one of those steps into a network call that can fail on its own. One process and one database give us transactions we can trust, and the separation a codebase needs comes from strict layering inside it rather than from deployment boundaries.

The engine is Python 3.12 with FastAPI and Pydantic at the boundary, SQLAlchemy 2 and psycopg 3 underneath, and Alembic for migrations. Postgres does more than store rows: row locks serialise the work on any one order, and unique indexes make a replayed message harmless.

Money is an integer

Every amount in BlendPay is a whole number of cents in rand. R12,000.00 is 1200000. Floats never come near money, and the API refuses a decimal string where it expects cents, because a store that sends rands by mistake would otherwise open an order for a hundredth of its cart.

It sounds small. It removes a whole family of rounding bugs, and it means the two parts of an order always add up to the total, to the cent.

State machines, written as data

An order, each part of an order, and a two-part order as a whole each have a lifecycle, and each lifecycle is a table of allowed moves. Nothing changes state by assignment. A move that isn’t in the table raises an error, and our tests are generated from the tables themselves, so every legal move is exercised and every illegal one is proven to fail.

A two-part order is a small saga. Part 1 is paid, part 2 is attempted, and if part 2 is refused the shopper gets a bounded window to try another method before part 1 is refunded. Writing that as data, rather than as branches spread across a codebase, is what lets us reason about the unhappy paths as carefully as the happy one.

Write it down first

Every interaction with a provider, whether we call out, a webhook arrives, a shopper returns or a refund is asked for, is written to an append-only ledger before anything else changes. The ledger is the truth, and every state can be rebuilt from it.

This is why we can explain any order line by line. It is also why we can be safe about retries: every webhook is deduplicated, every refund carries our own reference, and every write the API accepts takes an idempotency key.

The provider’s own pages, every time

We integrate each provider exactly the way its own documentation and plugins tell a merchant to: its hosted payment page, its verification steps, its refund API. No undocumented endpoints and no shortcuts a real merchant couldn’t take.

Two things follow. Card numbers are typed on the provider’s page and nowhere else, so they never reach BlendPay or the merchant’s site; we design to keep merchants on the lightest card-security obligations. And we call providers with a plain HTTP client rather than their SDKs, so every request is visible and every response can be tested against a recorded copy.

Testing as the specification

Our tests are a pyramid of four layers. Unit tests cover the domain and services in milliseconds. A contract suite runs the same tests against every provider adapter, so any one of them can stand in for another. Integration tests assemble the whole application against a real Postgres. End-to-end tests drive the deployed engine against the providers’ real sandboxes every night, and a red night opens an issue the same morning.

On the paths that move money, the failing test is written before the code. Type checking runs in strict mode, and linting, security scanning and secret scanning run on every change, with the same command locally as in CI.

The row in your checkout

The part a shopper sees is a small script, about 20 kB gzipped, built with Preact and drawn inside a shadow root so a store’s styles and ours never collide. It holds no secrets and talks only to our API. If it can’t load, the store’s checkout renders exactly as it did before BlendPay was there. We call that failing invisibly, and it is a rule, not a hope.

Where it runs

The engine ships as a Docker image to Railway, with Postgres on Neon. Our sites and the reference store are static builds on Cloudflare Pages. Logs are structured JSON with a request ID on every line. Because the Docker image is the deployable thing, moving hosts is a configuration change rather than a rewrite.

What we chose not to use

Kubernetes is right at a larger scale and wrong for one service. Actor frameworks give you one mailbox per order; we get the same property from a serialisation rule and a row lock, with no hidden control flow. Provider SDKs hide their HTTP. A second datastore would add a second thing to keep consistent. Each of these sits on hold with its reason written down, and each can come back when the reason stops being true.

What’s next

The stack will change as BlendPay grows, and we will update this post and the radar when it does. If you’re building a checkout and want to see BlendPay in it, read the developer docs or get in touch.