# Detect duplicate bills

Accounts payable, spend management, and audit tools lose real money when the same supplier bill gets booked twice and then paid twice. The [Accounting API](/apis/accounting/reference) exposes the `bills`, `suppliers`, and `attachments` resources needed to build deterministic and fuzzy match keys, score candidate pairs, and resolve confirmed duplicates by holding, annotating, voiding, or deleting the offending bill instead of issuing a second payment. Connectors commonly used in AP workflows include QuickBooks, Xero, NetSuite, Sage Intacct, Exact Online, and Microsoft Dynamics 365 Business Central.

## One integration for every ledger

Duplicate detection has the same shape on every ledger, but every ledger exposes it differently. Reading one normalized `Bill` object instead of a vendor bill in QuickBooks, a purchase bill in Xero, and a purchase invoice in Exact Online removes a layer of connector-specific plumbing from the detection pipeline.

- No per-connector field maps for supplier, bill number, reference, document date, and amount.
- Line items and tracking categories arrive in the same shape across ledgers, and attachments come from one `attachments` endpoint.
- Pagination, `filter[updated_since]`, `filter[billed_since]`, and cursor handling are consistent through `GET /accounting/bills`.
- One code path handles delete via `DELETE /accounting/bills/{id}` where supported, with a fallback to a status update where it isn't.

## Resource mapping

| Connector | Downstream object |
| --- | --- |
| QuickBooks | `Bill` |
| Xero | `Purchase Bill` (`ACCPAY` invoice) |
| NetSuite | `Vendor Bill` |
| Sage Intacct | `AP Bill` |
| Exact Online | `Purchase Invoice` |
| Microsoft Dynamics 365 Business Central | `Purchase Invoice` |
| FreshBooks | `Bill` |
| Zoho Books | `Bill` |
| Odoo | `account.move` (vendor bill) |
| Wave | `Bill` |

## Walkthrough

The pipeline has three stages: fetch the candidate set, build and score match keys, then act on confirmed duplicates. The pre-create check runs the same scoring against a draft bill before it reaches the ledger.

### 1. Fetch the supplier and recent bills

Before scoring, resolve the supplier and pull the bills booked against them in a reasonable window. Keep the window to 90 days for most AP flows and expand to a year for audit sweeps.

Send this to [`GET /accounting/suppliers`](/apis/accounting/reference#operation/suppliersAll).

```http
GET /accounting/suppliers?filter[email]=ap@northwind-supplies.com HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer {API_KEY}
x-apideck-app-id: {APP_ID}
x-apideck-consumer-id: {CONSUMER_ID}
x-apideck-service-id: quickbooks
```

Then pull recent bills via [`GET /accounting/bills`](/apis/accounting/reference#operation/billsAll). The bills filter has no supplier option, so bound the request by bill date with `filter[billed_since]` and keep only bills whose `supplier.id` matches on your side.

```http
GET /accounting/bills?filter[billed_since]=2024-11-16&limit=200 HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer {API_KEY}
x-apideck-app-id: {APP_ID}
x-apideck-consumer-id: {CONSUMER_ID}
x-apideck-service-id: quickbooks
```

When you already have a bill number or supplier reference to test, `filter[bill_number]` and `filter[reference]` do exact-match lookups and return a much smaller set. Bill numbers are not unique on every connector, so the response can contain bills from several suppliers; compare `supplier.id` before treating a hit as a duplicate.

A single candidate bill in the response looks like this:

```json
{
  "id": "bill_01HV9K2M3N4P5Q6R7S8T9U0V1W",
  "bill_number": "INV-2025-0417",
  "reference": "PO-88213",
  "supplier": {
    "id": "sup_01H8X9Y2A3B4C5D6E7F8G9H0J1",
    "display_name": "Northwind Supplies Ltd"
  },
  "bill_date": "2025-02-14",
  "due_date": "2025-03-16",
  "currency": "USD",
  "sub_total": 1842.00,
  "total_tax": 147.36,
  "total": 1989.36,
  "balance": 1989.36,
  "status": "authorised",
  "line_items": [
    {
      "line_number": 1,
      "description": "Rack-mount server, 2U chassis",
      "quantity": 2,
      "unit_price": 921.00,
      "total_amount": 1842.00,
      "ledger_account": { "id": "6700", "name": "Office Equipment" }
    }
  ]
}
```

### 2. Normalize and build match keys

Most duplicates take one of three forms: the same supplier posted the same bill number twice, the same supplier posted the same amount within a few days, or the same bill number appears with cosmetic formatting differences. Build one match key per form.

```json
{
  "exact_key": "sup_01H8X9Y2A3B4C5D6E7F8G9H0J1|INV-2025-0417",
  "fuzzy_key": "sup_01H8X9Y2A3B4C5D6E7F8G9H0J1|USD|1989.36|2025-02-14",
  "normalized_number_key": "sup_01H8X9Y2A3B4C5D6E7F8G9H0J1|INV20250417"
}
```

Normalization rules that catch the common false negatives:

- Strip whitespace, hyphens, slashes, and leading zeros from `bill_number` before comparing, so `INV-2025-0417`, `inv/2025/0417`, and `INV20250417` collapse to one key.
- Compare `total` to two decimal places in the bill's own `currency`. Do not convert through a base currency.
- Expand the date window to five days on either side of `bill_date` to absorb entry lag.
- Treat `supplier.id` as the identity, not `supplier.display_name`. The same vendor often exists under multiple name variants.

### 3. Score the candidate pair

Combine the signals into a score so the UI can draw a line between "block" and "warn".

| Signal | Weight |
| --- | --- |
| Same `supplier.id` + normalized `bill_number` match | 60 |
| Same `supplier.id`, same `currency`, `total` within $0.01 | 25 |
| `bill_date` within 5 days | 10 |
| Attachment file hash matches an existing bill's attachment | 20 |
| First-line-item description Jaro-Winkler >= 0.9 | 10 |

A score of 60 or higher is a hard duplicate (block). Between 30 and 59 is a soft duplicate (hold for human review). Attachment hashes are worth fetching when the first two signals are ambiguous. List a bill's files with [`GET /accounting/attachments/{reference_type}/{reference_id}`](/apis/accounting/reference#operation/attachmentsAll), with `reference_type` set to `bill` and `reference_id` set to the bill id, and fetch the bytes to hash with [Download attachment](/apis/accounting/reference#operation/attachmentsDownload).

### 4. Check a draft bill before it reaches the ledger

Run the match before calling [`POST /accounting/bills`](/apis/accounting/reference#operation/billsAdd). The draft bill in your product is scored against the ledger's existing bills for the same supplier, and the create call only fires if the score is below the block threshold. The draft and match objects below are your own data model, not an Apideck payload.

```json
{
  "draft": {
    "supplier_id": "sup_01H8X9Y2A3B4C5D6E7F8G9H0J1",
    "bill_number": "INV 2025 0417",
    "bill_date": "2025-02-16",
    "currency": "USD",
    "total": 1989.36
  },
  "match": {
    "candidate_bill_id": "bill_01HV9K2M3N4P5Q6R7S8T9U0V1W",
    "score": 95,
    "reasons": [
      "normalized_number_match",
      "amount_match",
      "date_within_window"
    ]
  },
  "decision": "block"
}
```

Send an [`Idempotency-Key` header](/guides/idempotency-keys) with every [Bills create](/apis/accounting/reference#operation/billsAdd) call, derived from the captured document (for example its content hash). If the call times out, resend it with the same key and body: the bill is created at most once. The key is remembered for 24 hours, or the connector's shorter window (6 minutes on Xero), and connectors that cannot honour it reject the request with 400. Outside that window, look the bill up with `filter[bill_number]` or `filter[reference]` before sending it again.

### 5. Expose it as an agent tool call

AP copilots and audit agents can call the same detector as a single tool. The tool resolves the supplier, pulls recent bills via `GET /accounting/bills`, filters them by `supplier.id`, scores, and returns a decision.

```json
{
  "name": "check_bill_for_duplicates",
  "description": "Score a draft bill against recent bills for the same supplier.",
  "input": {
    "service_id": "quickbooks",
    "consumer_id": "user_01HC2YK9X4L2R8S0W5U6Q7P9N3",
    "supplier_id": "sup_01H8X9Y2A3B4C5D6E7F8G9H0J1",
    "bill_number": "INV 2025 0417",
    "bill_date": "2025-02-16",
    "currency": "USD",
    "total": 1989.36
  },
  "output": {
    "decision": "block",
    "score": 95,
    "matched_bill": {
      "id": "bill_01HV9K2M3N4P5Q6R7S8T9U0V1W",
      "bill_number": "INV-2025-0417",
      "total": 1989.36
    }
  }
}
```

### 6. Sweep existing unpaid bills

Run the same scoring over the full unpaid-bill set on a schedule to catch duplicates that slipped in through direct ledger entry or through a connector that was down during the pre-create check. Page through [`GET /accounting/bills`](/apis/accounting/reference#operation/billsAll) with `filter[status]=unpaid` (and a second pass with `filter[status]=partially_paid`), group by `supplier.id`, and score each pair inside the group.

```http
GET /accounting/bills?filter[status]=unpaid&filter[updated_since]=2025-01-01T00:00:00Z&sort[by]=updated_at&sort[direction]=desc&limit=100&cursor=eyJvZmZzZXQiOjIwMH0= HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer {API_KEY}
x-apideck-app-id: {APP_ID}
x-apideck-consumer-id: {CONSUMER_ID}
x-apideck-service-id: quickbooks
```

Pass `meta.cursors.next` from each response as `cursor` on the next request until it is empty.

### 7. Resolve the duplicate

Once a duplicate is confirmed, hold it in your product and never push it, annotate it so the AP team sees the finding, or void or delete the ledger-side record. Never resolve a duplicate by issuing a payment through [`POST /accounting/bill-payments`](/apis/accounting/reference#operation/billPaymentsAdd).

For connectors that accept `DELETE` on bills, delete the duplicate with [`DELETE /accounting/bills/{id}`](/apis/accounting/reference#operation/billsDelete).

```http
DELETE /accounting/bills/bill_01HV9K2M3N4P5Q6R7S8T9U0V1W HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer {API_KEY}
x-apideck-app-id: {APP_ID}
x-apideck-consumer-id: {CONSUMER_ID}
x-apideck-service-id: xero
```

For connectors that don't accept a hard delete, set the bill `status` to `void` and record the finding in `notes` via [`PATCH /accounting/bills/{id}`](/apis/accounting/reference#operation/billsUpdate). Confirm the connector supports write operations on bills before relying on this path; some connectors in the catalog are read-only for the Bills resource.

```json
{
  "status": "void",
  "notes": "Duplicate of bill_01HV9K2M3N4P5Q6R7S8T9U0V1W. Flagged by AP automation on 2025-02-16. Score 95."
}
```

## Fitting into an AP approval flow

Insert the detector at two points in the approval pipeline:

1. At capture, before the bill is persisted in your product. A hard duplicate blocks the AP coder from saving. A soft duplicate routes to a reviewer with the matched bill rendered side by side.
2. At release, just before the create call to the ledger. Rerun the scan because the ledger's bill set may have changed since capture, especially when multiple AP users are entering bills concurrently.

Record the match score, the candidate bill id, and the resolution on your side so the auditor can trace every blocked and overridden duplicate. The ledger's own audit log will show the `DELETE` or void, but it won't show the bills your product never pushed.

## Connector-specific behavior

Confirm the Bills coverage for a specific connector in the [Accounting API reference](/apis/accounting/reference#tag/Bills) before enabling the resolve step for it. The notes below focus on what changes about match keys and resolution; where no non-obvious behavior is known, the row uses the standard mapping and the write path should be verified against the live coverage matrix.

| Connector | Notes |
| --- | --- |
| `access-financials` | Standard mapping. No known quirks beyond the unified model. Verify Bills write coverage before enabling delete or void. |
| `acumatica` | Reference number on bills is user-editable, so the normalized-number key catches most duplicates. Verify Bills write support before enabling delete or void. |
| `api-explorer` | Catalog entry for the in-browser request runner. Use it to prototype the detection calls, not as a production ledger. |
| `banqup` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `campfire` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `clearbooks-uk` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `coverage` | Catalog entry describing connector coverage, not a bookable ledger. Skip in detector runs. |
| `digits` | Limited or no write coverage for Bills in some tenants; verify in the coverage matrix and resolve duplicates in the source system when writes are not available. |
| `dualentry` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `exact-online` | Purchase invoices identify by the supplier's own reference, not the Exact internal id. Normalize both sides before comparing. Delete windows are restricted after the invoice is processed; verify in the reference. |
| `exact-online-nl` | Same purchase-invoice shape as the global Exact Online connector. |
| `exact-online-uk` | Same purchase-invoice shape as the global Exact Online connector. |
| `freeagent` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `freshbooks` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `intuit-enterprise-suite` | Verify Bills coverage in the coverage matrix. Where write support is available, expect QuickBooks-family semantics around payments blocking deletion. |
| `kashflow` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `microsoft-dynamics-365-business-central` | Posted purchase invoices are immutable in Business Central. Where a delete call is not accepted, cancel via a corrective credit memo and record the cancellation reference in your product. |
| `moneybird` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `mrisoftware` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `myob` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `myob-acumatica` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `netsuite` | Vendor bill numbers are often auto-generated when `Vendor Bill Numbering` is on. Prefer the fuzzy supplier plus amount plus date key for NetSuite tenants that let the ledger assign numbers. |
| `odoo` | Vendor bills post as `account.move` with `move_type` `in_invoice`. Posted bills with payments cannot be deleted in Odoo; reset to draft first where the connector permits it. |
| `overview` | Catalog navigation entry, not a connector. Skip in detector runs. |
| `pennylane` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `procountor-fi` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `quickbooks` | Delete via `DELETE /accounting/bills/{id}` is accepted when no payment is linked. Once a bill is paid, unlink the payment first or fall back to updating the bill's `notes`. |
| `rillet` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `sage-business-cloud-accounting` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `sage-intacct` | Posted AP bills typically require reversal rather than deletion in Intacct. Record the reversal id on your side for audit traceability. |
| `sage-intacct-rest` | Same AP bill semantics as the SOAP-based Sage Intacct connector. |
| `stripe` | Limited or no coverage for AP Bills; verify in the coverage matrix. Stripe is not an AP ledger of record and generally should not be included in bill duplicate sweeps. |
| `visma-netvisor` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `wave` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `workday` | Verify Bills coverage in the coverage matrix before relying on the resolve step. Where available, Workday Financial Management treats supplier invoices as the equivalent record. |
| `xero` | A hard delete is only accepted on bills still in draft. Authorised bills must be voided via `PATCH /accounting/bills/{id}` with `status: void`. The void is irreversible. |
| `yuki` | Standard mapping. Verify Bills write coverage before enabling delete or void. |
| `zoho-books` | Standard mapping. Verify Bills write coverage before enabling delete or void. |

### NetSuite

NetSuite accounts with auto-generated bill numbers defeat the exact-number key, because every entry gets a system-assigned number regardless of the supplier's own reference. Read `reference` or `custom_fields` where the supplier's document number is stored, and use that in place of `bill_number` when building keys.

### Xero

Xero's `ACCPAY` invoices support a hard delete only while still in draft. Any bill that has been authorised must be voided through `PATCH /accounting/bills/{id}` with `status: void`. The void is irreversible, so confirm the match score and operator intent before firing it.

### Microsoft Dynamics 365 Business Central

Once a purchase invoice is posted in Business Central, no field on it is editable. Cancel the duplicate by creating a matching corrective credit memo, for example with [Bill credit notes create](/apis/accounting/reference#operation/billCreditNotesAdd) where the connector supports it, and record a note on your side linking both documents to the detection run.

## Next steps

- [Handling bills and expenses for NetSuite, QuickBooks, Xero, and Exact Online](/guides/expenses-bills)
- [Mark invoices and bills as paid](/guides/mark-invoices-as-paid)
- [Accounting API reference](/apis/accounting/reference)
- [Bills resource](/apis/accounting/reference#tag/Bills) and [Bill payments resource](/apis/accounting/reference#tag/Bill-Payments)
