Skip to main content
Every parsed transaction moves through the same pipeline: reference matching, then optional name-token matching, then whatever’s left is unmatched. This page tells you which bucket a row lands in and why, so you can predict a result before you read it.

The passes, in plain terms

Reconify runs one or more matching passes against every pair. Each one answers a different business question:
  • Reference matching (reference_one_to_one). Matches two rows that carry the same reference, like an invoice number or payment ID. This is the default, and the right choice for most reconciliations: it’s exact, fast, and doesn’t guess.
  • Name-token matching (name_tokens_one_to_one). Matches rows by comparing description text when you don’t have a reliable shared reference. Treat it as a fallback, not a primary strategy: text similarity is never as certain as an exact ID.
  • One-to-many (one_to_many). Matches one row against several rows that sum to it, like an invoice paid in three installments. Use this when one side of your data is more granular than the other.
  • Many-to-many (many_to_many). Matches a group of rows on one side against a group on the other, like a Stripe payout that bundles many sales, refunds, and fees into one settlement. This is the common case for payout and settlement reconciliation.
  • Duplicate detection. Not a matching pass. It flags rows in the same source that share a grouping key, for visibility, without stopping those rows from matching normally.
The rest of this page covers how each of these actually decides where a row lands.

The tiers

Matching pipeline from parsed left and right transactions through reference matching, optional name-token matching, duplicate annotation, and matched or unmatched outcomes. Every transaction, duplicate or not, goes through reference matching and, if it’s still unmatched, name-token matching. Duplicate detection runs alongside and reports what it finds; it never removes a row from consideration first.

Reference matching

Reference matching is the primary path, and it runs first. When a left and right transaction share the same reference:
  • Both matched when the amount is within tolerance and the date is within the window.
  • amount_diff when the date is fine but the amount disagrees beyond amount_tolerance_minor.
  • timing_diff when the amount is fine but the date falls outside date_window.

Best-candidate selection

When several right-side rows share the same reference, the engine doesn’t take the first one it encounters. An exact match, amount and date both within tolerance, wins immediately. Otherwise, the candidate with the smallest amount difference is preferred over the candidate with the smallest date difference, and ties break on the smallest difference regardless of where the candidate appears in the input file. Batch and streaming reconciliation use the same selection logic, so a large streamed run and a small in-memory run classify an identical input the same way. Transactions without a usable reference skip straight to name-token matching, or land in unmatched if name_mode is none.

Name-token matching

Set name_mode: "tokens" on a pair to compare the remaining unmatched transactions by Jaccard similarity on their name field’s word tokens. A score strictly greater than name_match_threshold counts as a match, still subject to the pair’s amount tolerance and date window. name_match_threshold must be greater than 0 and less than 1; it defaults to 0.5. 1.0 is rejected by config validation, because the comparison is strict and a Jaccard score never reaches 1.0, so that threshold could never match anything. Name-token matching only runs after reference matching, and only on rows reference matching left unresolved. It can never override a reference match, even a poor one.

Duplicates are an annotation, not a filter

A non-zero duplicate_count does not mean rows were excluded from matching. Rows in the same source that share a group_key (from group_col, or reference when group_col is unset) are grouped and reported for visibility, and that grouping happens alongside matching, not before it. Every row in a duplicate group, not just the first one, still goes through reference and name-token matching independently. This is what makes group_col useful for installments: three payments against one invoice share an invoice number in reference but have distinct payment_id values in group_key. All three show up as a group of 3 in Duplicates for review, and all three can still match independently against the right side. Set group_col to the per-row-unique column and the detector stops treating legitimate installments as a data-quality problem. duplicate_count counts individual transactions across every group, not the number of groups. A group of 3 installments and a group of 2 repeated export rows together produce duplicate_count: 5, not 2. Here are the common causes of a non-zero count:
  • Repeated export row
  • Reused references across unrelated transactions
  • Partial settlement records
  • Refunds sharing the original transaction’s reference.
Empty references are never grouped, since there’s no key to group them on.

Grouped passes

Reference and name-token matching are both one-to-one: one left row, one right row. Grouped passes handle the case where one side splits a transaction the other side records as a single row.

One invoice, many payments (one_to_many)

one_to_many sums right rows sharing a grouping key and compares the total to one left amount. By default the key is reference; set group_by: name or group_by: group_key to group on something else. Outcomes:

Split settlements (many_to_many)

many_to_many sums both sides of a shared grouping key and compares the two totals, for cases where each side splits the same business event differently: Both sides sum to 14500, so the output contains one explainable many_to_many_matched event with lefts and rights arrays, instead of four unmatched rows on each side.

What grouped passes do not do

Grouped passes don’t search for combinations of rows that happen to sum to a target amount, and they don’t use fuzzy matching. Rows only group together when they share the configured group_by key exactly. Good keys are stable identifiers meant for exactly this: payout IDs, invoice IDs, settlement IDs, payment-run IDs, remittance references. A key that isn’t consistently populated on both sides produces unmatched rows, not a partial group.

Pass order

By default a pair runs reference matching, then name-token matching if name_mode: tokens is set. For more control, declare passes as an explicit ordered list:
The four valid pass types are reference_one_to_one, name_tokens_one_to_one, one_to_many, and many_to_many. Each pass only sees rows that earlier passes left unmatched, which makes pass order a modeling decision with real consequences:
  • [reference_one_to_one, one_to_many]. The reference pass greedily takes the best 1-to-1 candidate for any left row whose reference appears on the right. Same-date installments get classified amount_diff and consumed there before one_to_many ever sees them. Use one_to_many alone unless some rows are genuinely 1-to-1 and the installments fail both amount and date individually against every right row.
  • reference_one_to_one before many_to_many on a shared reference. Same problem: the reference pass can pick off individual rows as matches or diffs before the group pass runs. Use many_to_many alone for settlement groups where the reference is the group key, or group on something else, like name or group_key.
  • When passes is set, name_mode: tokens is rejected by config validation. Add name_tokens_one_to_one to the list explicitly instead.