ProductsDocsGuidesPricing
Sign inGet Started

Intelligence APIs for developers.

Product

  • All APIs
  • Support Triage
  • Pricing

Developers

  • Getting started
  • Guides
  • API reference
  • Errors
  • Dashboard

Company

  • About
  • Security
  • Contact

Legal

  • Privacy Policy
  • Terms of Service
© Kovca. kovca.io
GuidesFinance & Procurement

How to Validate Vendor Invoices Before Payment

Catch duplicate invoices, price and quantity discrepancies, and bad arithmetic before AP pays. A practical 3-way match pipeline with curl, TypeScript and Python examples.

Invoice Intelligence8 min readcURL · TypeScript · Python
matcheddiscrepanciesrequires_review

Accounts payable has a simple rule: pay what you ordered, at the price you agreed, for what actually arrived, exactly once. Enforcing it is harder. Invoice lines are worded differently from purchase-order lines. Vendors round differently. The same invoice gets emailed twice, once as a PDF and once as a "reminder". And the check usually happens in a person's head while they stare at two windows side by side.

This guide shows how to put a validation step in front of payment approval. It covers the classic three-way match (invoice ↔ purchase order ↔ receipt), duplicate detection, and how to route the results. The concepts are standard AP practice. For the matching step we use the Kovca Invoice Intelligence API, but you'll get value from the checklist even if you build it all yourself.

The checks that matter

Before writing code, here's what a pre-payment check should catch, roughly in order of how often each one costs money:

  1. Duplicates. Same invoice number from the same vendor, already received.
  2. Price drift. A unit price higher than the PO price ("we updated our pricing").
  3. Over-billing quantity. Billing for more than was ordered, or more than was received.
  4. Arithmetic errors. Line amounts that aren't quantity × price, subtotals that don't sum, totals that aren't subtotal + tax.
  5. Unordered lines. A "handling fee" or extra item with no PO line behind it.
  6. Currency and unit mismatches. An invoice in EUR against a USD PO, or "cases" billed against an order in "each".

Two engineering rules apply to all of them.

Do money math in decimals, never floats. 0.1 + 0.2 is not 0.3 in IEEE 754, and AP teams notice. Send and store amounts as decimal strings.

Separate "match" from "pay". Validation produces a recommendation. Payment authorization stays with your approval workflow and your people.

Start with duplicates. If you only ship one check this quarter, make it duplicate detection against the vendor's recent invoice numbers. It's the cheapest to build and the hardest for a person to catch by eye.

The hard part: line matching

Arithmetic and duplicate checks are easy to code. The hard part is knowing that invoice line "Hand sanitizer gel 500ml pump bottle" is PO line "Sanitizer, gel, 500 ml pump". When both sides carry the same SKU or line ID, it's a join. When they don't, you need description matching, and you need it to refuse when it's unsure. Two invoice lines claiming the same PO line is a red flag, not a tie to break arbitrarily.

  1. 1
    Invoice arrives
    email, EDI, or OCR output cleaned into decimals
  2. 2
    Gather context
    PO, receipts, vendor's recent invoice numbers
  3. 3
    Kovca Invoice Intelligence
    duplicates, line matching, exact arithmetic
    AP analystrequires_review or an unmatched line
    Vendor disputereject_for_correction, with the issues list
  4. 4
    Approval workflow
    matched invoices go to your normal approvers
  5. 5
    ERP
    store request_id with the invoice

Kovca's endpoint works this way. Lines with the same sku or line_id are paired in code. Remaining lines are matched by description. A description match has to be confident, and two invoice lines can't claim the same purchase-order line; otherwise the result is ambiguous_line_match and a person decides. A line that clearly matches nothing on the order is unmatched_line. All arithmetic is exact decimal arithmetic in code. Interpretation is used only for deciding which PO line a differently worded invoice line describes.

The request

POST https://kovca.io/api/v1/invoices/check takes the invoice, and optionally the PO, receipts, previously received invoices (for duplicate detection), and tolerances. Bodies are limited to 16 KB, with up to 50 lines per invoice or PO.

curl

cURL
curl https://kovca.io/api/v1/invoices/check \
  -H "Authorization: Bearer $KOVCA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "invoice": {
      "number": "INV-20931",
      "vendor": { "name": "Northwind Supply", "id": "V-1042" },
      "currency": "USD",
      "lines": [
        { "sku": "NW-4410", "description": "Nitrile gloves, large, box of 100",
          "quantity": 40, "unit_price": "12.50", "amount": "500.00" },
        { "description": "Hand sanitizer gel 500ml pump bottle",
          "quantity": 24, "unit_price": "4.10", "amount": "98.40" }
      ],
      "subtotal": "598.40", "tax": "47.87", "total": "646.27"
    },
    "purchase_order": {
      "number": "PO-7781",
      "currency": "USD",
      "lines": [
        { "line_id": "1", "sku": "NW-4410", "description": "Gloves, nitrile, L (100/box)",
          "quantity": 40, "unit_price": "12.50" },
        { "line_id": "2", "description": "Sanitizer, gel, 500 ml pump",
          "quantity": 24, "unit_price": "3.95" }
      ]
    }
  }'

The response for that request:

JSON
{
  "request_id": "req_2bWq9Lm4Ks7TzXcN",
  "status": "discrepancies_found",
  "recommended_action": "reject_for_correction",
  "issues": [
    {
      "type": "unit_price_mismatch",
      "invoice_line": 1,
      "po_line": 1,
      "expected": "3.95",
      "actual": "4.10",
      "explanation": "The unit price differs from the purchase order price.",
      "evidence": ["invoice.lines[1]", "purchase_order.lines[1]"]
    }
  ],
  "matched_lines": [
    { "invoice_line": 0, "po_line": 0, "method": "identifier" },
    { "invoice_line": 1, "po_line": 1, "method": "description" }
  ],
  "unmatched_lines": [],
  "missing_information": []
}

The gloves matched on SKU. The sanitizer matched on description, and its price is 15 cents over the PO. (Line indexes in the response are zero-based.) That's exactly the kind of drift that slips through when someone eyeballs a total.

TypeScript: a typed client

TypeScript
type Decimal = string; // always send money as strings, e.g. "12.50"

type InvoiceLine = {
  description: string;
  quantity: number | Decimal; // required
  unit_price: Decimal;        // required
  line_id?: string;
  sku?: string;
  amount?: Decimal;
  unit?: string;
};

type PurchaseOrderLine = {
  description: string;
  quantity: number | Decimal; // required
  unit_price: Decimal;        // required
  line_id?: string;
  sku?: string;
  unit?: string;
};

type InvoiceIssueType =
  | "quantity_mismatch"
  | "unit_price_mismatch"
  | "amount_mismatch"
  | "currency_mismatch"
  | "unmatched_line"
  | "ambiguous_line_match"
  | "missing_purchase_order"
  | "possible_duplicate";

type InvoiceCheckRequest = {
  invoice: {
    number: string;
    vendor: { name: string; id?: string };
    currency: string; // ISO 4217
    date?: string;    // YYYY-MM-DD
    lines: InvoiceLine[];
    subtotal?: Decimal; tax?: Decimal; total?: Decimal;
  };
  purchase_order?: {
    number: string;   // required when a purchase order is sent
    currency: string;
    lines: PurchaseOrderLine[];
  };
  receipts?: { po_line_id: string; quantity_received: number | Decimal }[];
  known_invoices?: { number: string; vendor_id?: string; vendor_name?: string }[];
  tolerances?: {
    unit_price_percent?: number | Decimal;
    unit_price_absolute?: Decimal;
    quantity_percent?: number | Decimal;
    amount_absolute?: Decimal;
  };
};

type InvoiceCheckResult = {
  request_id: string;
  status: "matched" | "discrepancies_found" | "requires_review" | "insufficient_information";
  recommended_action: "approve_for_processing" | "request_information" | "manual_review" | "reject_for_correction";
  issues: { type: InvoiceIssueType; invoice_line?: number; po_line?: number; expected?: string;
            actual?: string; explanation: string; evidence: string[] }[];
  matched_lines: { invoice_line: number; po_line: number; method: "identifier" | "description" }[];
  unmatched_lines: number[];
  missing_information: ("purchase_order" | "exchange_rate")[];
};

export async function checkInvoice(req: InvoiceCheckRequest): Promise<InvoiceCheckResult> {
  const res = await fetch("https://kovca.io/api/v1/invoices/check", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.KOVCA_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(req),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`${res.status} ${err?.error?.code}: ${err?.error?.message}`);
  }
  return res.json();
}

quantity and unit_price are required on every invoice line and every purchase-order line. line_id, sku, amount, and unit are optional. A purchase order also requires number and currency. The full contract is in the Invoice Intelligence docs.

Python: receipts, duplicates and tolerances

Real AP runs need the other two legs of the match. Pass what the warehouse received, and the last few invoices from this vendor:

Python
import os, requests

payload = {
    "invoice": invoice,                 # built from your OCR/EDI output
    "purchase_order": po,               # from your ERP; include line_id on each line
    "receipts": [                       # from goods-received notes
        {"po_line_id": "1", "quantity_received": 40},
        {"po_line_id": "2", "quantity_received": 20},
    ],
    "known_invoices": [                 # recent invoices from this vendor, max 50
        {"number": n, "vendor_id": "V-1042"} for n in recent_invoice_numbers("V-1042")
    ],
    "tolerances": {"unit_price_percent": 1, "amount_absolute": "0.05"},
}

r = requests.post(
    "https://kovca.io/api/v1/invoices/check",
    headers={"Authorization": f"Bearer {os.environ['KOVCA_API_KEY']}"},
    json=payload, timeout=30,
)
r.raise_for_status()
result = r.json()

Defaults are strict: exact prices and quantities, and 0.01 on amounts for rounding. Loosen them only where your vendor contracts allow it. Note that billing less than ordered is treated as a partial invoice, not a discrepancy.

Routing the result

The statuses are evaluated in a fixed order (first match wins), so you can map them directly onto your workflow:

statusrecommended_actionWhat your workflow should do
requires_reviewmanual_reviewPossible duplicate, ambiguous line, or currency mismatch. Hold and assign to an AP analyst.
discrepancies_foundreject_for_correction or manual_reviewPrice/quantity/amount issues: send back to the vendor with the issues list. An unmatched line goes to manual review.
insufficient_informationrequest_informationNo purchase order was supplied, and nothing else is wrong. Find the PO or get a budget-owner sign-off.
matchedapprove_for_processingMove into your normal approval flow. This is not a payment instruction.
Python
def next_step(result):
    action = result["recommended_action"]
    if action == "approve_for_processing":
        return enqueue_for_approval(result)          # still goes through your approvers
    if action == "reject_for_correction":
        return draft_vendor_dispute(result["issues"])  # human sends it
    return assign_to_ap_analyst(result)

The issues[].expected / actual / evidence fields are written to be shown to people. Put them straight into the dispute email or the analyst's review screen.

Design notes and limits

  • One PO per request. If an invoice spans several POs, split it first.
  • No currency conversion. Differing currencies produce a currency_mismatch issue and missing_information: ["exchange_rate"]. Amounts are never converted.
  • Duplicates are only as good as known_invoices. Send the vendor's recent invoice numbers from your ERP. Comparison trims, ignores case, and ignores punctuation, so INV-20931 and inv 20931 from the same vendor match.
  • Amounts must be plain decimals. "$12" or 1e3 returns 400 INVALID_REQUEST. Clean OCR output first.
  • Request data isn't stored by Kovca, so keep your own audit trail with the request_id.

Cost

One request per invoice. Free covers 1,000 successful requests a month; Pay As You Go is $0.005 per successful request with a spending cap you control (pricing). Rejected or invalid requests aren't billed.

Try it with a real invoice

Take a recent invoice your team disputed, along with its PO, and run it through. Create a free account (no card required), generate a key, and try it in the playground. The full contract is in the Invoice Intelligence docs.

On this page

  • The checks that matter
  • The hard part: line matching
  • The request
  • TypeScript: a typed client
  • Python: receipts, duplicates and tolerances
  • Routing the result
  • Design notes and limits
  • Cost
  • Try it with a real invoice

Invoice Intelligence

Reconcile invoices against purchase orders and receipts, and find discrepancies before payment approval.

Try it in the playground

Keep going

Invoice IntelligenceAPI reference
billingtechnicalneeds_review
Support Triage7 min read

Automating Support Ticket Triage in Your Helpdesk Pipeline

Build a support ticket triage pipeline: webhook intake, category and priority classification, human-review routing, and safe fallbacks. Code in curl, TypeScript and Python.

Read guide
monitorcontact_carrierinvestigate
Shipment Intelligence6 min read

Catching Delivery Exceptions Before Your Customers Do

Turn raw carrier tracking events into routed delivery exceptions: late and stalled parcels, address problems, losses and disputes. A scheduled pipeline with curl, TypeScript and Python.

Read guide
same_variantdifferent_variantuncertain
Catalog Matching9 min read

How to Match Supplier Product Catalogs to Your Own SKUs

A practical guide to matching supplier feeds to your SKUs: blocking candidates, using GTINs safely, handling variants, and routing uncertain matches. Code in curl, TypeScript and Python.

Read guide