CadenseCadense
  • Docs
  • Blog
  • Integrations
  • Pricing
  • Docs
  • Blog
  • Integrations
  • Pricing

What should we get done?

Docs
Cadense

Product

HarnessWorkflowsKnowledge BaseTablesMCPAPISelf HostingPricingChangelogFiles

Resources

BlogDocsPricingPartnersChangelog

Blocks

AgentRouterFunctionConditionAPIWorkflowParallelGuardrailsEvaluatorLoop

Integrations

All IntegrationsConfluenceSlackGitHubGmailHubSpotSalesforceNotionGoogle DriveGoogle SheetsSupabaseStripeJiraLinearAirtableFirecrawlDiscordMicrosoft TeamsTelegram

Legal

Terms of ServicePrivacy Policy
Back to Blog
Why one signal can't reconcile a bank statement (and the five we use instead)

Why one signal can't reconcile a bank statement (and the five we use instead)

A worked example of how Cadense matches bank transactions to invoices and payments using five independent signals — amount, date, identity, history, and topology — with weekend-aware tolerance, a rules layer, and a 15-category exception taxonomy.

Apr 17, 2026
Si
Siddharth

A real reconciliation example, before any architecture talk.

We have an invoice from Northline Logistics for $1,260.00, due April 15. Our AP team initiates an ACH payment on April 14. The bank descriptor that comes back, three business days later, looks like this:

1
date:        2026-04-17
2
amount:      -1234.50 USD
3
descriptor:  ACH STRIPE PAYOUT po_3PqR9K2eZvKYlo2C

A naive matcher comparing 1260.00 to 1234.50 says no match. A naive matcher comparing "Northline Logistics" to "STRIPE PAYOUT" says no match. A naive matcher checking the date — April 14 vs. April 17 — sees a 3-day gap (which is normal for ACH but still a "miss" by exact comparison).

The actual answer is: yes, this is the payment. Stripe routed the ACH for us, took a $25.50 fee, and the payout reference (po_3PqR9K2eZvKYlo2C) ties back to a payment record we already have in the system. The two-business-day gap is the standard ACH settlement window. Every signal individually looks weak; together, they're decisive.

That's the entire shape of the Cadense reconciliation engine. Single signals don't survive contact with real bank feeds. Multiple independent signals, combined with explicit thresholds, do.

TL;DR

  • Reconciliation is built on five independent signals: amount, date, identity, history, topology. None of them on their own can drive an auto-match.
  • Text normalization is aggressive — accents stripped, legal entity suffixes (Inc/LLC/Ltd) removed, whitespace and punctuation collapsed — so "Acme Office Supply, LLC" and "ACME OFFICE SUP" share most of their tokens.
  • Weekend/holiday grace days account for banking realities. A payment due Friday that settles Monday is zero banking days late.
  • Auto-match requires combined confidence ≥ 0.72 and no ambiguity within 0.05 of the next-best candidate. Otherwise it becomes a suggestion for review.
  • Exceptions are first-class: 15 distinct categories with severity and recommended action.

The three brittle approaches we rejected

Before settling on multi-signal scoring, we built (and discarded) each of these:

ApproachWhy it fails in real AP
Pure rules ("if descriptor contains X, match payment Y")Ossifies the moment your bank changes descriptor format. Doesn't generalize to new vendors. Maintenance burden grows with vendor count.
Pure amount matching with date toleranceDefeated by processor fees, FX, partial payments, batched settlements. False positives across same-amount payments to different vendors.
Pure ML / LLM matcherOpaque. Cannot explain why A matched B and not C. Unacceptable for a controller signing off on the reconciliation.

The middle path — multiple independent signals, a rules layer for tweaks, a clear exception taxonomy, and explicit thresholds — is what survived.

What the engine reasons over

Three datasets have to land in the same shape before matching starts:

  • Invoices — what we owe, to whom, when.
  • Payments — what we initiated. State machine: pending, submitted, succeeded, failed, returned, voided.
  • Bank transactions — what the bank actually saw clear, with whatever descriptor the rail gave us.

Reconciliation signal scoring flow Invoices, payments, and bank transactions normalize into candidate match shapes. Each candidate scores across amount, date, identity, history, and topology before thresholding routes it to auto-match, suggestion, or exception review.

The orchestration entry point in packages/ap-reconciliation/src/reconcile.ts looks roughly like this:

1
export async function reconcileWorkspace(
2
  workspaceId: string,
3
  period: { from: Date; to: Date },
4
  options: ReconcileOptions
5
): Promise<ReconciliationResult> {
6
  const settings = await loadSettings(workspaceId)
7
 
8
  // Extend the payment lookback by dateToleranceDays so a transaction
9
  // settling on Apr 2 can match a payment initiated Mar 28.
10
  const paymentHorizon = subDays(period.from, settings.dateToleranceDays)
11
 
12
  const [invoices, payments, bankTxns] = await Promise.all([
13
    loadInvoices(workspaceId, period),
14
    loadPayments(workspaceId, paymentHorizon, period.to),
15
    loadBankTransactions(workspaceId, period),
16
  ])
17
 
18
  return analyzeReconciliation({
19
    invoices,
20
    payments,
21
    bankTxns,
22
    settings,
23
    history: await loadFeedbackHistory(workspaceId),
24
    rules: await loadRules(workspaceId),
25
  })
26
}

The paymentHorizon extension matters more than it sounds: it lets a bank transaction settling on April 2 match a payment that was initiated on March 28, which happens constantly and breaks naive period-bounded matchers.

The five signals — applied to the Northline example

For every (bank transaction, candidate payment, candidate invoice) tuple, the engine computes five independent scores. Let's walk each one through the real example from the top.

Signal 1: Amount

Compares the bank transaction amount (-1234.50) to the expected amount (payment amount 1260.00). The variance is 25.50.

Tolerance is configurable two ways:

  • Cardinal — amountToleranceMinor, default 2000 (~$20 in USD minor units).
  • Percentage — e.g., 5% of the payment amount.

The matcher uses whichever is more permissive at the candidate's amount. 25.50 exceeds the $20 cardinal tolerance, so by default this would fail amount-only matching. But there's a Stripe fee rule active that allows fee-adjusted matches up to maxFeeToleranceMinor (default 5000 or 1%, whichever is lower). The variance falls inside that tolerance and is annotated fee_adjusted. Amount score: 0.91.

Signal 2: Date

Compares the payment date (April 14) to the bank settlement date (April 17). Calendar delta: 3 days. April 14 is a Tuesday, April 17 is a Friday — no weekend crossed in this case. ACH settlement of 2–3 business days is well within dateToleranceDays (default 5). Date score: 0.94.

If the gap had crossed a weekend, the engine would add weekendHolidayDateGraceDays to the tolerance — a payment due Friday settling Monday is zero banking days late.

Signal 3: Identity

This is the hardest signal and the one that consumed the most code. The engine compares vendor names from the invoice/payment side (Northline Logistics) to the descriptor on the bank side (ACH STRIPE PAYOUT po_3PqR9K2eZvKYlo2C).

Three sub-mechanisms:

  1. Aggressive text normalization. normalizeTextForReconciliation strips accents (café → cafe), lowercases, removes punctuation, collapses whitespace, and drops legal entity suffixes (Inc, LLC, Ltd, S.A., GmbH). After that, "Acme Office Supply, LLC" and "ACME OFFICE SUP" share most of their tokens.
  2. Token Jaccard + substring containment. We compute Jaccard similarity over normalized tokens and check whether one string contains the other. Either signal can lift the identity score.
  3. Trace token extraction. Bank descriptors often embed identifiers — ACH IMAD/OMAD, wire references, check numbers, processor payout IDs (po_* for Stripe, SET_* for Adyen). The engine extracts these via regex and boosts identity when they match a known payment.

For our example, vendor name overlap between "Northline Logistics" and "STRIPE PAYOUT" is zero. But the descriptor contains po_3PqR9K2eZvKYlo2C, which we recognize as a Stripe payout ID, and that maps directly to the payment record we initiated. Identity score: 0.86 — driven entirely by the trace-token match.

If the trace token hadn't been there, identity would have scored near zero and the candidate would have failed the combined threshold. This is by design: weak identity has to be salvaged by some other concrete signal (a unique reference, an exact amount, a confirmed history alias) — never by date plus amount alone.

Signal 4: History

If the workspace has prior reconciliation history, the engine uses it. A user-confirmed match between vendor X and bank descriptor pattern Y becomes a learned alias that boosts future matches between the same pair (up to a max contribution of 0.9). A user-rejected suggestion does the opposite — penalizes future matches with the same shape.

For our example, suppose this workspace has previously reconciled three Northline invoices via Stripe payouts. The history boost fires, contributing +0.05 to the combined confidence.

This is the only place the engine "learns," and it learns cautiously. The history boost only fires when there's a structural match (same vendor, similar descriptor, same rail). It doesn't try to generalize.

Signal 5: Topology

Topology asks: what's the structure of this match? Some shapes are inherently more confident than others.

TopologyScore modifierNotes
One-to-one (single payment, single bank transaction, single invoice)HighestThe clean case
One-to-many (one payment covers multiple invoices)HighConfident if amounts add up
Many-to-one (multiple payments aggregated into one settlement)MediumTypical for processor net settlements
Many-to-manyFlaggedSurfaces as a suggestion regardless of score
Fee-adjustedHighAllowed if a fee rule is present
Cross-currencyFlaggedAllowed only with explicit FX rates

Our Northline example is one-to-one with a fee adjustment. Topology score: 0.90.

Combining the signals into a decision

Each signal returns a score in [0, 1]. They're combined into a single confidence by a weighted sum (with weights expressed in settings.ts). For our Northline example, the combined score lands around 0.90. Above the auto-match threshold, no ambiguity, no rule blocking it — auto-matched.

Two thresholds drive the decision:

  • minAutoMatchConfidence (default 0.72) — above this and not blocked by a rule, the match is auto-applied.
  • minSuggestConfidence (often the same, sometimes lower) — above this but not auto-matched, the candidate becomes a suggestion that surfaces in the reconciliation queue.

There's also an ambiguityMargin (default 0.05). If the top two candidates score within that margin of each other, the match is flagged as ambiguous and demoted to a suggestion regardless of how high the top score is. We'd rather a controller pick between two near-identical options than have the engine guess.

The three modes

Workspaces can run the engine in one of three modes, which is just a preset of thresholds:

ModeUse it whenAuto-match thresholdAmbiguity margin
Balanced (default)Steady-state operations0.720.05
StrictFirst weeks, audit prep0.85+0.10
ExploratoryTriaging an old account0.550.02

Strict mode is what most teams use during their first reconciliation cycle. Once they trust the engine on their data, they drop back to balanced.

The exception taxonomy

A reconciliation run produces matches and suggestions, but it also produces exceptions — situations the engine recognizes as not-quite-a-match-but-worth-knowing-about. The taxonomy from types.ts:

ExceptionTrigger
missing_bank_transactionPayment with no bank counterpart yet
unmatched_bank_outflowBank transaction we can't tie to anything
amount_mismatchClose on identity and date, off on amount beyond tolerance
currency_mismatchPayment in one currency, bank settlement in another, no FX configured
duplicate_paymentTwo payments for the same invoice
vendor_mismatchStrong amount/date evidence, vendor names don't reconcile
payment_failed_or_returnedBank explicitly returned the payment
stale_pending_paymentPending longer than stalePendingDays (default 30)
date_outside_toleranceMatch would be valid except date is too far off
ambiguous_matchMultiple high-confidence candidates within ambiguity margin
fee_unexplainedAmount variance that looks like a fee but no fee rule covers it
partial_paymentBank cleared less than the invoice amount
overpayment_unappliedBank cleared more than the invoice; no credit applied
duplicate_bank_outflowTwo bank transactions look like the same payment was sent twice
unsupported_cross_currencyCross-currency match attempted without FX rates

Each exception has a severity and a recommended action, so the AP team gets a queue that's already triaged.

Persistence and dry runs

Reconciliation persistence schema The match record stores linked invoices, payments, bank transactions, signal scores, topology, and status. Rules and reviewer feedback sit beside the match so future runs can explain why confidence moved.

Matches and suggestions are grouped by their structure (the set of invoices/payments/bank transactions they touch) and persisted to the database. Subsequent runs can detect whether a previously-suggested match has been confirmed, modified, or rejected.

The engine also supports a dry-run mode (persist=false) that returns the same outputs without writing — useful for previewing the effect of a tolerance change before committing.

Where the engine doesn't learn (yet)

Some honest limits:

  • The five signal weights are hand-tuned, not learned. No gradient descent. If your business has a quirk that should re-weight the signals, you write a rule.
  • Identity matching is token-based. Jaccard + substring containment goes a long way, but semantic similarity (synonyms, abbreviations, transliterations) isn't in the box. If your vendor master spells a vendor differently from how the bank does, an alias is the lever — not the matcher.
  • History needs volume. The history-based boost only fires when there's a prior match for the same (vendor, descriptor pattern, rail) shape. New vendors fall back to signal-only scoring.
  • Stale-pending threshold is global. stalePendingDays is per-workspace, not per-vendor or per-rail. A wire that sits pending 25 days might be normal; an ACH that sits 25 days is broken. Treating both with the same threshold is a known compromise.

Why this shape

The five-signal model exists because the alternatives don't survive contact with real bank feeds. A pure rules engine ossifies. A pure ML matcher can't explain itself. The middle path — multiple independent signals, a rules layer for tweaks, a clear exception taxonomy, and explicit thresholds — is what we ended up trusting.

The reconciliation engine is part of a larger AP brain. It shares the cached vendor and category indexes with the categorization engine, and feeds reconciled spend into the spend anomaly detector. Each of the three was designed to fail the same way: drop into a lower-confidence state and surface for review, rather than silently produce a wrong answer.

If you want to read the matcher itself, the entry point is analyzeReconciliation in packages/ap-reconciliation/src/matcher.ts. The signals fan out from there.

FAQ

How does Cadense handle weekend and holiday delays in payment settlement?

When the date delta between a payment and its bank settlement crosses a weekend, the engine adds extra grace days configured by weekendHolidayDateGraceDays. A payment due Friday that settles Monday is exactly 3 calendar days late but zero banking days late, and the engine knows the difference.

What confidence threshold does the engine require to auto-match?

minAutoMatchConfidence defaults to 0.72. Above that and not blocked by a rule, the match is auto-applied. Between 0.55 and 0.71 it becomes a suggestion that surfaces in the reconciliation queue. The thresholds are configurable per workspace and there are three preset modes: balanced, strict, and exploratory.

How does the engine handle Stripe and Adyen processor net settlements?

Processor settlements that bundle many transactions into a single payout are flagged as high-risk topology and require explicit opt-in via allowProcessorNetSettlement. The engine extracts payout IDs (po_*, SET_*) from the bank descriptor and matches them against settlement records.

Does the engine learn from user corrections?

Yes, but cautiously. When a user confirms or rejects a suggestion, that becomes feedback for future matches with the same (vendor, descriptor pattern, rail) shape — boosting confidence on future similar pairs and penalizing future similar candidates that the user previously rejected. It does not generalize beyond exact structural matches.

What happens if two candidates score within a few points of each other?

The engine demotes both to suggestions. ambiguityMargin defaults to 0.05 — if two candidates are within that margin, the match is flagged as ambiguous and surfaced for user review rather than auto-matched. We'd rather a controller pick between two near-identical options than have the engine guess.

What kinds of exceptions does the engine surface?

Fifteen categories including missing_bank_transaction, unmatched_bank_outflow, amount_mismatch, currency_mismatch, duplicate_payment, vendor_mismatch, payment_failed_or_returned, stale_pending_payment, ambiguous_match, fee_unexplained, partial_payment, overpayment_unapplied, duplicate_bank_outflow, and unsupported_cross_currency. Each carries a severity and a recommended action.

Spotting unusual spend without crying wolf: Cadense's anomaly detector
Apr 26

Spotting unusual spend without crying wolf: Cadense's anomaly detector

Why a single $50k legal invoice in your baseline can blind a classical z-score, how robust statistics fix it, and the five signals Cadense uses to flag spend spikes without drowning AP teams in noise.

How we built a GL categorization engine a controller will sign off on
Apr 26

How we built a GL categorization engine a controller will sign off on

A real walkthrough of the five-stage cascade Cadense uses to suggest GL codes — deterministic rules first, embeddings as a fallback, abstention when nothing fits — and the trade-offs we made along the way.

Realtime Collaboration
Nov 25

Realtime Collaboration

A high-level explanation into Cadense realtime collaborative workflow builder - from operation queues to conflict resolution.