> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reconifyhq.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Reconify's API reference is read-only for customer data. Do not invent endpoints or authentication behavior beyond the OpenAPI contract.
> The public OpenAPI document contains only the documented external /v2 contract.

# Matching algorithm

> How Reconify classifies matches, differences, duplicates, and unmatched rows.

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

<img src="https://mintcdn.com/reconify/Jfsy-BiGfPU9ut1l/images/cli/matching-pipeline.svg?fit=max&auto=format&n=Jfsy-BiGfPU9ut1l&q=85&s=0d1c7c36bc5f9b0e789a7d0d56c2d0b0" alt="Matching pipeline from parsed left and right transactions through reference matching, optional name-token matching, duplicate annotation, and matched or unmatched outcomes." width="729" height="930" data-path="images/cli/matching-pipeline.svg" />

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:

| Outcome               | Condition                                                                                                                                                       |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `grouped_matched`     | Sum of right amounts within tolerance, all right dates within window.                                                                                           |
| `grouped_amount_diff` | Sum outside tolerance. `DiffMinor = left amount − sum(rights)`.                                                                                                 |
| `grouped_timing_diff` | Sum within tolerance, but a right date falls outside the window. `DaysDiff` is the largest gap in the group.                                                    |
| Both fail             | Left stays unmatched; the right rows are not consumed and remain available to later passes.                                                                     |
| `ambiguous_groups`    | Two or more left rows share the same reference, so grouping is undetermined. Every row involved is excluded from matching entirely and needs manual resolution. |

### 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:

| Store ledger rows | Reference    | Amount |
| ----------------- | ------------ | -----: |
| Order A sale      | `payout_123` |  10000 |
| Order B sale      | `payout_123` |   8000 |
| Order C refund    | `payout_123` |  -3000 |
| Stripe fee        | `payout_123` |   -500 |

| Stripe payout rows  | Reference    | Amount |
| ------------------- | ------------ | -----: |
| Card payments total | `payout_123` |  18000 |
| Refunds total       | `payout_123` |  -3000 |
| Stripe fees         | `payout_123` |   -500 |

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:

```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}}
pairs:
  left_source_vs_right_source:
    left: left_source
    right: right_source
    passes:
      - type: reference_one_to_one
      - type: name_tokens_one_to_one
```

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.
