A reactive, event-driven payments platform on Stripe
Backend microservices for a B2B2C payments platform serving 20,000+ merchants, built around Stripe, a double-entry ledger and CQRS dashboards, without ever touching card data.
- Role
- Backend engineer
- Timeframe
- Mereb Tech · Jun 2023 to Jan 2025
- Stack
- Java 17/21, Spring WebFlux, Kafka (MSK), PostgreSQL, MongoDB, Stripe, Kubernetes
- Scale
- 2M+ transactions per day, peak API traffic of 5,000 requests/s
Problem
Payments Core is a B2B2C payments platform built for a European client. Merchants take payments in person on POS tablets with physical card readers, and online through web widgets. More than 20,000 merchants use it, and traffic is anything but even: at rush hour, every POS terminal is busy at once.
I joined while the platform was migrating from a monolith to microservices, and worked in the backend team across all of its services. A dedicated frontend team built the POS and dashboard applications. Card payments already ran through Stripe in the monolith, so card data never touched the platform, and the new architecture had to keep it that way. Beyond that, it had to solve three problems at the same time:
- Concurrency at peak. Most of a payment request’s life is spent waiting on Stripe. A thread-per-request server holds a thread for all of that waiting, and at peak it runs out.
- Money must reconcile. Payment results arrive asynchronously, possibly twice and in any order, and every platform fee and merchant payout must be accounted for exactly once.
- Merchants read constantly. Sales dashboards must stay fast without slowing down the database that records money.
Architecture
The platform is a set of reactive, event-driven microservices, with boundaries drawn using Domain-Driven Design. Each service is a bounded context that owns its own data:
- API Gateway (Amazon API Gateway): the entry point for traffic from POS tablets and online merchant widgets.
- Merchant Management Service: the source of truth for the merchant as a business, holding KYB compliance documents, payout banking details and each merchant’s negotiated fee configuration.
- Transaction Command Service: the write side for checkouts. It creates payments with Stripe and owns each transaction’s lifecycle state.
- Webhook Handler Service: a public-facing service that does one job, which is to verify, deduplicate and record Stripe’s webhooks, scaled for their spikes.
- Ledger Service: calculates the split between the platform fee and the merchant payout, and records it in a double-entry ledger in PostgreSQL.
- Read Projection Service: the query side of CQRS. It consumes events and flattens them into MongoDB documents shaped for merchant dashboards.
Services communicate through Kafka on Amazon MSK, which decouples the payment flow from everything downstream of it: state updates, accounting, dashboards and analytics. They run in Docker on Kubernetes, ship through Jenkins CI/CD pipelines, log to the ELK stack and expose metrics to Prometheus, with quality gates in SonarQube and JUnit/Mockito test suites.
The life of a payment
Starting the payment, without touching the card
- A merchant rings up an order on a POS tablet. The request goes through API Gateway to the Transaction Command Service.
- The service records the transaction as
PENDINGand asks Stripe to create a PaymentIntent, sending an idempotency key with the request. If the call times out and is retried, Stripe returns the original PaymentIntent instead of creating a second one. - Stripe returns the PaymentIntent’s
client_secret, which the service passes back to the tablet. - The tablet’s Stripe Terminal SDK uses the secret to collect the payment on the card reader. The card data is encrypted inside the reader and goes to Stripe. It never passes through the platform’s backend.
Finishing it: webhook, state, ledger, dashboard
- Stripe authorizes the payment and sends a
payment_intent.succeededwebhook to the Webhook Handler Service. - The handler verifies the
Stripe-Signatureheader: an HMAC SHA-256 over the timestamp and the raw request body. Signatures with a stale timestamp are rejected, which blocks replayed requests. - In one local database transaction, the handler inserts the Stripe event ID into a table with a unique constraint, and writes a
PaymentSucceededevent into an outbox table. If the insert hits the constraint, the event is a duplicate delivery and nothing else happens. Either way, the handler returns200immediately, as Stripe recommends. - A relay publishes outbox rows to Kafka, keyed by the PaymentIntent ID, so every event for one payment lands on the same partition in order.
- Three consumers react to the event. The Transaction Command Service moves the transaction from
PENDINGtoSUCCEEDED. The Ledger Service applies the merchant’s fee configuration and records the platform fee and the merchant payout as balanced entries in one ACID transaction, then publishes the posted entry. The Read Projection Service consumes the ledger’s events and updates the merchant’s dashboard document in MongoDB, so the numbers merchants see are the numbers in the books.
Key decisions
Keep card data with Stripe
The monolith already delegated card handling to Stripe, and splitting it into services must not weaken that boundary. Payments go through Stripe PaymentIntents, and the card readers run Stripe Terminal, so encryption and authorization with the card networks happen entirely on Stripe’s side. Every new service handles only tokens. That keeps raw card data out of the platform and its PCI-DSS scope a fraction of what processing cards directly would require.
Spring WebFlux for the peak, then virtual threads
I championed moving the core APIs to Spring WebFlux, built on Project Reactor and running on a non-blocking Netty runtime on Java 17. On a thread-per-request Tomcat server, every request waiting on Stripe holds an OS thread, so a pool sized for normal load is exhausted at peak. Netty runs a small number of event-loop threads, and a request waiting on I/O holds none of them.
That only works if nothing on the event loop blocks. Stripe’s official Java library is synchronous, and calling it on an event-loop thread would stall every request sharing that thread. So the reactive services called Stripe’s REST API through Spring’s non-blocking WebClient, keeping the whole request path non-blocking.
After the platform upgraded to Java 21 LTS, I migrated to virtual threads. A blocked virtual thread is unmounted from its carrier OS thread, so blocking I/O becomes cheap again. That keeps the concurrency gain while letting the code read top to bottom instead of as chains of reactive operators.
The webhook as the source of truth, recorded with an outbox
A card payment completes on Stripe’s side, after the API call that created it has already returned, so the webhook is the moment a payment becomes real. Two things can go wrong when recording it. Stripe may deliver the same event more than once, and writing to the database and publishing to Kafka are two separate operations, so a crash between them could lose the event or publish one that was never recorded.
The transactional outbox solves both. The duplicate check and the outgoing event are written in the same local transaction, so either both happen or neither does, and the unique constraint makes the duplicate check safe even when two deliveries of the same event arrive at once. Publishing happens afterwards, from the outbox, and is retried until it succeeds. Acknowledging Stripe only requires a fast local commit, which is what Stripe asks for.
Ordered per payment, idempotent everywhere
Kafka guarantees order only within a partition, and Stripe does not guarantee the order of webhooks at all. Keying events by PaymentIntent ID keeps everything about one payment in one partition, and each service applies state transitions only forward, so a late event can never move a transaction backwards. Kafka delivers at least once, so every consumer is idempotent too: ledger entries carry the ID of the event that produced them, and a redelivered event cannot post twice.
A double-entry ledger
Every movement of money is recorded as balanced entries in PostgreSQL, inside one ACID transaction. A fee and a payout either both exist or neither does, so a failure halfway through cannot leave the books unbalanced.
CQRS, fed by the ledger
Merchant dashboards are read far more often than payments are written. Serving them from denormalized MongoDB documents means heavy dashboard traffic never competes with the ledger for locks. Building those documents from the ledger’s own events, rather than from the raw payment event, means the fees and payouts on a dashboard always match the books. The trade-off is eventual consistency: a sale appears on the dashboard shortly after it is posted, not in the same transaction.
Results
- Card data stayed with Stripe through the whole migration. No new service ever touches a card number.
- Peak API traffic of up to 5,000 requests per second across the platform sustained at rush hour, a load that would exhaust a thread-per-request pool, and later carried on virtual threads after the Java 21 upgrade.
- Every payment is recorded exactly once, whatever Stripe redelivers, with no event lost between the database and Kafka.
- Dashboards match the ledger and never compete with it for the database.
- Part of the move from a monolith to microservices, building backend services with Domain-Driven Design boundaries.