# Three-way matching with the Accounting API

Three-way matching compares a supplier bill against the purchase order that authorised the spend and the goods receipt that confirms delivery, so accounts payable only pays for what was ordered and received. The flow below pulls purchase orders, goods receipts and bills from Apideck's [Accounting API](/apis/accounting/reference), links them by purchase order and line, flags variances, and writes the approval, evidence and payment back.

## One integration for every ledger

Purchase orders and goods receipts live in different objects, with different line-item shapes, in each accounting and ERP system. Apideck maps them to one data model, so the matching code reads the same fields whichever system a supplier is on.

- One auth flow, one cursor-based pagination model and one error format across connectors.
- Purchase order and bill lines share `item`, `quantity`, `unit_price` and `total_amount`. Goods receipt lines carry `quantity_received`.
- Bill lines and goods receipt lines point back to the purchase order line through `line_items[].purchase_order.id` and `line_items[].purchase_order.line_id`.
- Where goods receipts are not available, the two-way fallback is a branch in your code rather than a separate integration.

## Resource mapping

Goods receipts are currently available on four connectors. Everywhere else the best you can do is a two-way match (purchase order to bill) or bill-only coding. Check the live coverage matrix for `purchase-orders`, `goods-receipts` and `bills` before relying on any row.

| Connector | Purchase order | Goods receipt | Bill |
| --- | --- | --- | --- |
| NetSuite | `PurchaseOrder` | `ItemReceipt` | `VendorBill` |
| Dynamics 365 Business Central | `Purchase Order` | `Posted Purchase Receipt` | `Purchase Invoice` |
| Odoo | `purchase.order` | `stock.picking` (incoming) | `account.move` (vendor bill) |
| SAP S/4HANA Cloud | Purchase order | Goods receipt against a purchase order | Supplier invoice |
| Acumatica | `Purchase Order` | Not available. Falls back to two-way | `AP Bill` |
| QuickBooks | `PurchaseOrder` | Not available. Falls back to two-way | `Bill` |
| Xero | `PurchaseOrder` | Not available. Falls back to two-way | `Bill` (`ACCPAY` invoice) |
| Exact Online | Not available | Not available | `PurchaseInvoice` |

## Walkthrough

Pull the three resources for a given supplier, build a line-level comparison, classify exceptions, then write the outcome back to the ledger.

### 1. Fetch open purchase orders for the supplier

Pull purchase orders for the supplier. These are the authorised commitments the bill will be matched against. `PurchaseOrdersFilter` accepts `supplier_id`, `updated_since` and `subsidiary_id` but has no status filter, so keep the orders with `status: open` client-side.

Send this to [`GET /accounting/purchase-orders`](/apis/accounting/reference#operation/purchaseOrdersAll).

```http
GET /accounting/purchase-orders?filter[supplier_id]=sup_01HT9F7Q2N4K8W 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: netsuite
```

A typical purchase order in the response looks like this. The purchase order `id` and each line `id` are the join keys for everything that follows.

```json
{
  "id": "po_01HT9F8C4R5N6S",
  "po_number": "PO-2041",
  "supplier": { "id": "sup_01HT9F7Q2N4K8W", "display_name": "Blue Harbor Logistics" },
  "status": "open",
  "currency": "USD",
  "issued_date": "2025-02-14",
  "expected_arrival_date": "2025-02-28",
  "sub_total": 4800.00,
  "total_tax": 384.00,
  "total": 5184.00,
  "line_items": [
    {
      "id": "pol_01HT9F8D1A",
      "line_number": 1,
      "item": { "id": "item_sku_9281", "code": "SKU-9281", "name": "Carbon fiber rail, 2m" },
      "quantity": 40,
      "unit_price": 95.00,
      "total_amount": 3800.00
    },
    {
      "id": "pol_01HT9F8D2B",
      "line_number": 2,
      "item": { "id": "item_sku_4410", "code": "SKU-4410", "name": "Mounting bracket, steel" },
      "quantity": 100,
      "unit_price": 10.00,
      "total_amount": 1000.00
    }
  ]
}
```

### 2. Fetch goods receipts against those POs

For each open purchase order, pull the matching receipts. The goods receipt carries the received quantity per line in `quantity_received`, and that is the number the billed quantity gets compared against, rather than the ordered quantity.

Send this to [`GET /accounting/goods-receipts`](/apis/accounting/reference#operation/goodsReceiptsAll). `GoodsReceiptsFilter` defines `purchase_order_id`, `supplier_id` and `updated_since`, but connector support varies: Odoo accepts all three, Business Central accepts only `updated_since`, and NetSuite and SAP S/4HANA Cloud reject every filter. On those connectors, page through the list and keep the receipts for your purchase order client-side.

```json
{
  "id": "gr_01HT9FBX7Y8Z",
  "receipt_number": "REC-5588",
  "purchase_order_id": "po_01HT9F8C4R5N6S",
  "po_number": "PO-2041",
  "supplier": { "id": "sup_01HT9F7Q2N4K8W", "display_name": "Blue Harbor Logistics" },
  "status": "received",
  "received_date": "2025-02-27",
  "line_items": [
    {
      "line_number": 1,
      "purchase_order": { "id": "po_01HT9F8C4R5N6S", "line_id": "pol_01HT9F8D1A" },
      "item": { "id": "item_sku_9281", "code": "SKU-9281" },
      "quantity_received": 38,
      "unit_price": 95.00
    },
    {
      "line_number": 2,
      "purchase_order": { "id": "po_01HT9F8C4R5N6S", "line_id": "pol_01HT9F8D2B" },
      "item": { "id": "item_sku_4410", "code": "SKU-4410" },
      "quantity_received": 100,
      "unit_price": 10.00
    }
  ]
}
```

### 3. Fetch bills awaiting approval

Pull the supplier's unpaid bills. `BillsFilter` has no supplier filter, and its `status` filter only takes `paid`, `unpaid` or `partially_paid`. Request `filter[status]=unpaid`, then keep the bills whose `supplier.id` matches and whose document `status` is `draft` or `submitted` client-side. Connectors that leave draft bills out of the unpaid set need an unfiltered pull by `filter[updated_since]` instead. Carry the header `po_number` and each line's `purchase_order` reference through to the matcher.

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

```json
{
  "id": "bill_01HT9FD9K2M3N",
  "bill_number": "BH-INV-77214",
  "supplier": { "id": "sup_01HT9F7Q2N4K8W", "display_name": "Blue Harbor Logistics" },
  "bill_date": "2025-03-01",
  "due_date": "2025-03-31",
  "currency": "USD",
  "status": "draft",
  "po_number": "PO-2041",
  "line_items": [
    {
      "line_number": 1,
      "purchase_order": { "id": "po_01HT9F8C4R5N6S", "line_id": "pol_01HT9F8D1A" },
      "item": { "id": "item_sku_9281", "code": "SKU-9281" },
      "quantity": 38,
      "unit_price": 97.50,
      "total_amount": 3705.00
    },
    {
      "line_number": 2,
      "purchase_order": { "id": "po_01HT9F8C4R5N6S", "line_id": "pol_01HT9F8D2B" },
      "item": { "id": "item_sku_4410", "code": "SKU-4410" },
      "quantity": 100,
      "unit_price": 10.00,
      "total_amount": 1000.00
    }
  ],
  "sub_total": 4705.00,
  "total_tax": 376.40,
  "total": 5081.40
}
```

### 4. Link lines and compute variances

Group the three resources by purchase order (`purchase_order.id` on lines, or `po_number` on the header), then join line by line on `purchase_order.line_id` when both the receipt and the bill carry it. When they do not, fall back to `item.code` plus `line_number`. A purchase order line can appear on several receipts after split deliveries, so sum `quantity_received` across receipts that share a `line_id` before comparing.

For each matched line, compute three variances against configurable tolerances. A reasonable default is 2% on unit price and 0 units on quantity for inventory items, with a looser quantity tolerance for weight-based or bulk SKUs.

```json
{
  "po_line_id": "pol_01HT9F8D1A",
  "item_code": "SKU-9281",
  "ordered_quantity": 40,
  "received_quantity": 38,
  "billed_quantity": 38,
  "po_unit_price": 95.00,
  "billed_unit_price": 97.50,
  "quantity_variance": 0,
  "price_variance_pct": 2.63,
  "line_total_variance": 95.00,
  "within_tolerance": false,
  "exception": "price_variance"
}
```

### 5. Classify exceptions

Every line falls into one of five buckets. The matcher labels them; a human or an agent decides what to do with each.

| Exception | How to detect | Typical resolution |
| --- | --- | --- |
| `clean_match` | All three resources agree within tolerance. | Approve the bill automatically. |
| `price_variance` | `billed_unit_price` differs from `po_unit_price` beyond tolerance. | Route to buyer. Approve, request credit note, or amend PO. |
| `quantity_variance` | `billed_quantity` differs from `received_quantity`. | Hold line. Confirm short-ship or over-ship with the warehouse. |
| `missing_receipt` | PO and bill exist, no goods receipt (and the connector supports receipts). | Chase receiving. Approve on attestation. |
| `bill_without_po` | Bill has no `po_number`, no line carries `purchase_order.id`, and none can be inferred by supplier plus amount. | Route to AP for coding. Create a backdated PO or approve as non-PO spend. |

### 6. Approve the bill

For a clean match or a resolved exception, set the bill to `authorised`. Only the fields you want to change need to go in the body. Bills have no `memo` field; record the match outcome in `notes`.

Send this to [`PATCH /accounting/bills/{id}`](/apis/accounting/reference#operation/billsUpdate).

```json
{
  "status": "authorised",
  "notes": "3-way matched against PO-2041 and REC-5588. 2.63% price variance on SKU-9281 approved by j.okafor."
}
```

### 7. Attach the matching evidence

Store the match report (the per-line comparison and the approver's note) on the bill so audit can replay the decision. The upload body is the raw file, and the file name goes in the `x-apideck-metadata` header.

Send this to [`POST /accounting/attachments/{reference_type}/{reference_id}`](/apis/accounting/reference#operation/attachmentsUpload) with `reference_type=bill` and `reference_id=bill_01HT9FD9K2M3N`.

```bash
curl -X POST "https://unify.apideck.com/accounting/attachments/bill/bill_01HT9FD9K2M3N" \
  -H "Authorization: Bearer {API_KEY}" \
  -H "x-apideck-app-id: {APP_ID}" \
  -H "x-apideck-consumer-id: {CONSUMER_ID}" \
  -H "x-apideck-service-id: netsuite" \
  -H "x-apideck-metadata: {\"name\":\"match-report-BH-INV-77214.pdf\",\"description\":\"3-way match report\"}" \
  -H "Content-Type: application/pdf" \
  --data-binary "@match-report-BH-INV-77214.pdf"
```

### 8. Record the bill payment

Once the bill is approved and scheduled, record the payment and allocate it to the bill. The ledger updates the bill's `balance` and status from the allocation.

Send this to [`POST /accounting/bill-payments`](/apis/accounting/reference#operation/billPaymentsAdd).

```json
{
  "transaction_date": "2025-03-15T10:00:00Z",
  "currency": "USD",
  "total_amount": 5081.40,
  "reference": "ACH-2025-03-15-0042",
  "supplier": { "id": "sup_01HT9F7Q2N4K8W", "display_name": "Blue Harbor Logistics" },
  "account": { "id": "acct_01HT9FGH5Q", "nominal_code": "1100", "name": "Operating Checking" },
  "type": "accounts_payable",
  "status": "authorised",
  "reconciled": false,
  "allocations": [
    {
      "id": "bill_01HT9FD9K2M3N",
      "type": "bill",
      "amount": 5081.40
    }
  ],
  "note": "3-way match PO-2041 / REC-5588 / BH-INV-77214"
}
```

For background on the payment-and-allocation pattern, see [Mark Invoices and Bills as Paid](/guides/mark-invoices-as-paid).

## Connector-specific behavior

Three-way matching needs purchase orders, goods receipts and a link from a bill line back to its purchase order line. The table reflects the coverage matrix for `purchase-orders`, `goods-receipts`, `bills`, `attachments` and `bill-payments` at the time of writing. Where a resource is missing, fall back to the flow in the notes column, and check the live matrix before enabling a connector.

| Connector | Notes |
| --- | --- |
| `access-financials` | No `bills` coverage. Not usable for this workflow. |
| `acumatica` | Purchase orders and bills are readable and writable. No goods receipts, so run a two-way match. No `attachments` or `bill-payments` coverage; store evidence and record payment outside the ledger. |
| `banqup` | No `bills` coverage. Not usable for this workflow. |
| `campfire` | Bills only. No purchase orders or goods receipts, so every bill is `bill_without_po`. No `attachments` or `bill-payments` coverage. |
| `clearbooks-uk` | Bills are read-only, with no purchase orders or goods receipts. Match results cannot be written back. |
| `digits` | No `bills` coverage. Not usable for this workflow. |
| `dualentry` | Purchase orders, bills and bill payments are available; no goods receipts. Run a two-way match. No `attachments` coverage. |
| `exact-online` | No purchase orders or goods receipts. Bills, attachment upload and bill payments are available, so route every bill to coding as `bill_without_po`. |
| `exact-online-nl` | Same capability profile as `exact-online`. |
| `exact-online-uk` | Same as `exact-online`, except bills are read-only, so the approval in step 6 cannot be written. |
| `freeagent` | Bills are read-only, with no purchase orders or goods receipts. Bill payments and attachment upload are available. |
| `freshbooks` | No purchase orders. Bills can be read and deleted but not updated. Treat every bill as `bill_without_po`. |
| `intuit-enterprise-suite` | Purchase orders are available; goods receipts are not. Fall back to two-way, the same pattern as QuickBooks. |
| `kashflow` | No `bills` coverage. Not usable for this workflow. |
| `microsoft-dynamics-365-business-central` | Full three-way coverage, with read-only goods receipts. Receipt lines carry no purchase order line reference, so join on the header `po_number` and `item`. See the section below. |
| `moneybird` | Bills only, with no purchase orders. Bill payments can be created but not listed. |
| `mrisoftware` | Purchase orders are read-only and bills cannot be updated. No goods receipts, `attachments` or `bill-payments` coverage. Use for two-way checks only, without write-back. |
| `myob` | Bills only. No purchase orders or goods receipts. |
| `myob-acumatica` | Same capability profile as `acumatica`. |
| `netsuite` | Reference three-way flow. Purchase orders, item receipts and vendor bills are all available. See the section below for receipt filtering limits. |
| `odoo` | Purchase orders and goods receipts are read-only; bills are writable. Attachments can be listed but not uploaded, so keep the match report outside Odoo. See the section below. |
| `pennylane` | Purchase orders map to purchase requests. No goods receipts or `bill-payments` coverage. Two-way at best. |
| `procountor-fi` | Purchase orders and bills are available; no goods receipts, `attachments` or `bill-payments` coverage. Two-way at best. |
| `quickbooks` | Purchase orders are available; goods receipts are not. Fall back to two-way. `POST /accounting/bill-payments` requires `payment_method` set to `Check` or `CreditCard`. |
| `rillet` | No purchase orders. Bills are writable; bill payments can be created but not read back. Bill-only coding. |
| `sage-business-cloud-accounting` | No purchase orders or goods receipts. Bill-only coding. |
| `sage-intacct` | No purchase orders or goods receipts in the unified model. Bill-only coding. |
| `sage-intacct-rest` | Same capability profile as `sage-intacct`. `po_number` is not supported on bills. |
| `sap-s4hana-cloud` | Purchase orders and goods receipts are read-only, and bills cannot be updated. Receipt lines carry `purchase_order.line_number` instead of `line_id`, and no filters are supported on goods receipts. |
| `stripe` | No `bills` coverage. Skip for AP matching. |
| `visma-netvisor` | Purchase orders are available; no goods receipts. Bills cannot be updated, and there is no `attachments` or `bill-payments` coverage. |
| `wave` | No `bills` coverage. Not usable for this workflow. |
| `workday` | Purchase orders and supplier invoices are available; no goods receipts. Bill payments are read-only. Two-way at best. See the [Workday financials guide](/guides/journal-entries-expenses-ledger-accounts-workday) for related mappings. |
| `xero` | Purchase orders are available; goods receipts are not. Fall back to two-way. Xero allows 10 attachments per bill, each up to 3.5MB. |
| `yuki` | Bills can be listed only. No purchase orders or goods receipts. |
| `zoho-books` | Purchase orders are available; goods receipts are not. Fall back to two-way. |

### NetSuite

NetSuite returns only item receipts created from a purchase order. The goods receipts endpoint rejects every `filter[...]` and `sort[by]` parameter with a 400, so page through the list (with `limit` of at least 5) and keep the receipts whose `purchase_order_id` matches. Receipt lines carry `unit_price` but `total_amount` is null, and `po_number` is not available on the receipt. A voided receipt still reports `status: received`, so re-check the originating purchase order when that matters. If a bill arrives before the item receipt is posted, treat the line as `missing_receipt` rather than `clean_match`, even if PO and bill agree.

### Microsoft Dynamics 365 Business Central

Business Central receipts set `purchase_order_id` and `line_items[].purchase_order` to null. The header `po_number` is the only link to the purchase order, so join receipt lines to purchase order lines on `item`. `supplier.id` is also null; use `supplier.display_name` instead. Receipts posted from a purchase invoice without a purchase order have no `po_number` and should be skipped. Only `filter[updated_since]` is accepted.

### QuickBooks and Xero

Both expose purchase orders but no goods receipts. The two-way fallback replaces the received quantity input with the ordered quantity, and the exception taxonomy drops `missing_receipt` in favour of a separate attestation step where an operator confirms receipt before approval. Record in the bill's `notes` which kind of match was performed, so audit can tell them apart.

### Odoo

Odoo creates one receipt per delivery, so a single purchase order line can appear on several receipts. Sum `quantity_received` across receipts that share `line_items[].purchase_order.line_id`. A purchase order with only service products produces no receipt, so classify those lines with the two-way rules. Receipt lines come from a field introduced in Odoo 17; on Odoo 16 and earlier `line_items` is empty. Draft receipts report the quantity recorded so far.

## Next steps

- [Handling Bills and Expenses](/guides/expenses-bills) for the write side of AP across the same connectors.
- [Mark Invoices and Bills as Paid](/guides/mark-invoices-as-paid) for the payment-and-allocation pattern used in step 8.
- [Accounting API reference](/apis/accounting/reference) for the full schema of purchase orders, goods receipts, bills, attachments and bill payments.
