Real-Time Payment Reconciliation: Architecture and Lessons
The problem nobody wants to solve manually
Payment reconciliation is the process of verifying that each payment received corresponds to an issued invoice and that amounts match. Sounds simple. It is not.
A company managing 500 monthly invoices with 3 payment methods (bank transfer, card via Stripe, and direct debit) spends 40-60 hours per month on manual reconciliation. We know because we measured it in our own operations before automating it. An accountant opens online banking, finds the corresponding invoice in the ERP, compares amounts, notes the reference, marks as collected, and moves to the next. Multiplied by 500. With the usual false friends: partial payments, grouped payments covering multiple invoices, cent differences from bank fees, and clients who enter the wrong reference (or none at all).
This article documents the architecture we built to solve this problem in the fintech sector, the design decisions we made, and the lessons we learned in production.
The architecture: event-driven matching
The system has four main components.
Payment ingestion. Each payment source generates normalized events. Stripe emits webhooks that we capture and transform. Banking sends statements via API (we use Qonto’s API for the primary account and Open Banking scraping for other banks). Direct debits generate SEPA files that get parsed. All converge into a Kafka topic (payments.received) with a common schema:
{
"payment_id": "pay_abc123",
"source": "stripe",
"amount": 1250.00,
"currency": "EUR",
"reference": "INV-2025-0342",
"payer_name": "Company XYZ Ltd",
"payer_account": "GB29 NWBK 6016 1331 9268 19",
"timestamp": "2025-09-18T10:23:45Z",
"raw_data": { ... }
}
Invoice ingestion. The ERP (Holded in our case) emits events when an invoice is created or modified. We capture these via the Holded API with polling every 5 minutes (Holded lacks native webhooks, so we simulate events with polling and change detection). Events go to the invoices.issued topic with a schema including ID, amount, client, due date, and reference.
Matching engine. The system’s core. A Kafka consumer that reads both topics and runs a matching algorithm with three confidence levels:
- Exact match (100% confidence): The payment reference matches the invoice number exactly, and the amount matches. Ideal case. Occurs for 55-60% of payments.
- Probable match (70-95% confidence): The reference is similar but not exact (fuzzy matching with Levenshtein distance <3), or the amount matches a pending invoice from the same client. Occurs for 25-30% of payments.
- Uncertain match (<70% confidence): No clear reference or amount match. The system searches combinations (a payment that could cover two invoices, differences from fees). Occurs for 10-15% of payments.
Exact matches execute automatically: the invoice is marked as collected in the ERP, the accounting entry is generated, and everything is archived. Probable matches go to a review queue where an operator confirms or corrects with a click. Uncertain matches go to an investigation queue with all contextual information pre-populated.
Audit trail. Every system decision (automatic match, approved match, rejected match, corrected match) is recorded in an immutable audit table with: payment_id, invoice_id, decision type, confidence, timestamp, and user (if applicable). This table is the source of truth for any subsequent accounting discrepancy.
The matching algorithm in detail
Matching is not a simple IF reference == invoice_id. It is a pipeline of rules executed in order of specificity.
Rule 1: Exact reference + exact amount. The trivial case. If the payment reference matches an invoice number and the amount is identical, direct match. Additional check: the invoice must be pending collection (avoid marking an already-collected invoice as collected again).
Rule 2: Exact reference + amount with tolerance. If the reference matches but the amount differs by less than EUR 2, match at 90% confidence. Cent differences are common: bank fees, exchange rate rounding for international payments, or tax withholdings the payer applies without communicating.
Rule 3: No reference + match by amount and client. When the payment has no reference (or “transfer” as reference, which is the same as nothing), we search pending invoices from the same client (identified by payer name or bank account) with the same amount. If there is exactly one match, confidence 85%. If there are several, confidence 60% and into the review queue.
Rule 4: Grouped payment. A payment whose amount does not match any individual invoice but matches the sum of multiple invoices from the same client. Common in B2B: the client pays 3 invoices with a single transfer. The algorithm searches subsets of pending invoices whose sum matches the payment (with EUR 2 tolerance). If it finds exactly one subset, match at 80% confidence.
Rule 5: Partial payment. A payment matching an invoice but for a lower amount. Records a partial collection and keeps the invoice in “partially collected” status with the remaining balance. Confidence 75%.
Rule 6: Fallback. If no rule produces a match, the payment goes to the investigation queue. The system provides the 5 most likely invoices (by amount similarity, client, and date) to facilitate manual resolution.
In production, the distribution is: rule 1 covers 55% of payments, rule 2 covers 5%, rule 3 covers 15%, rule 4 covers 8%, rule 5 covers 2%, and the remaining 15% hits fallback. The total automatic match rate is 85%, meaning out of 500 monthly payments, only 75 require human intervention.
Exception handling
Exceptions are the system’s soul. Without solid exception handling, the 15% that does not auto-match becomes a bottleneck that invalidates the entire automation.
Review queue. Web interface with two columns: payment on the left, candidate invoices on the right. One click to confirm the suggested match, a search bar to find the correct invoice if the suggestion is wrong. Average resolution time: 15 seconds per payment (measured in production). Compared to 3-5 minutes for full manual reconciliation, that is a 12x improvement.
Recurring exceptions. If a client always pays without a reference, the system learns the pattern and raises confidence for amount+client matching for that payer. Implemented as a per-client overrides table that adjusts matching engine thresholds. This progressively reduces review queue volume.
Accounting differences. When a payment and invoice do not match exactly (typically due to bank fees), the system automatically generates an adjustment entry for the difference. For differences under EUR 5, adjustment is automatic against a “rounding differences” account. For larger differences, approval is required.
Orphan payments. Payments received without a corresponding invoice. Common when a client pays an advance or deposit without a prior invoice. Recorded as unallocated collections and generate an alert to the administration team.
Accounting integration
Reconciliation does not end when the payment is linked to the invoice. It must reach accounting.
Our system generates automatic accounting entries for each confirmed match:
- Full payment: Debit bank account, credit accounts receivable. Cross-referenced with the invoice.
- Partial payment: Same entry for the received amount. Invoice remains partially collected.
- Rounding difference: Additional entry against exchange difference accounts.
- Bank fee: If Stripe charges a fee, separate entry against banking services account.
Integration happens via the ERP’s REST API. Each entry includes payment and invoice references for traceability. At month-end, the accounts receivable balance matches bank statements automatically. Monthly accounting close that used to take 2 days now takes 2 hours (mostly spent verifying exceptions, not reconciling).
Production lessons
Idempotency is mandatory. Stripe webhooks duplicate. Bank statements sometimes overlap. ERP polling can read the same invoice twice. Every event must have a unique ID, and the system must ignore duplicates. Without idempotency, you generate duplicate accounting entries, which is worse than having no automation.
Fuzzy matching needs continuous calibration. Thresholds that work with 100 clients do not work with 500. The probability of false positives (two invoices with the same amount from different clients) grows with volume. We recalibrate thresholds quarterly based on human correction rates.
Bank data is a mess. The “reference” field in a bank transfer is free text. We have seen: “invoice 342”, “INV342”, “inv no. 342”, “order 342”, “payment invoice three four two”, and, in one memorable case, just “money.” The reference parser needs more fuzzy matching logic than the main matching engine.
ROI is immediate and measurable. 40-60 hours of monthly manual work reduced to 5-8 hours of exception review. At EUR 25/hour labor cost, that is EUR 800-1,300 in direct monthly savings. The system pays for itself in 4-6 months. But the real benefit is not time saved; it is error elimination. A manually misallocated payment can take months to detect. With automated reconciliation, every discrepancy is caught in real time.
Start with one payment method. Do not try to integrate Stripe, banking, and direct debits simultaneously. Start with the highest-volume channel (usually bank transfers), validate that matching works, and add others progressively. We started with Stripe (best API, cleanest data), then added our primary bank, then remaining accounts.
What remains to be done
The current system resolves 85% of payments automatically. Our target is 95%. Improvements we are exploring:
- NLP for references. Using a language model to interpret free-text payment references and extract invoice numbers with greater accuracy.
- Predictive matching. Using a client’s payment history to predict which invoice they are paying before searching. If a client always pays the oldest invoice first, the system prioritizes that match.
- Multi-currency reconciliation. Currently we only reconcile in EUR. For clients paying in USD or GBP, we need to incorporate historical exchange rates and conversion tolerances.
This event-driven architecture is a concrete example of the patterns we describe in our practical guide to real-time pipelines. If you need to build a similar system, our data engineering team has direct experience with this pattern. Payment reconciliation is one of those problems that does not seem important until you calculate how many hours and errors it generates. And once you automate it, you wonder how you survived without it.
About the author
abemon engineering
Engineering team
Multidisciplinary engineering, data and AI team headquartered in the Canary Islands. We build, deploy and operate custom software solutions for companies at any scale.
