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.
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:
- Duplicates. Same invoice number from the same vendor, already received.
- Price drift. A unit price higher than the PO price ("we updated our pricing").
- Over-billing quantity. Billing for more than was ordered, or more than was received.
- Arithmetic errors. Line amounts that aren't quantity × price, subtotals that don't sum, totals that aren't subtotal + tax.
- Unordered lines. A "handling fee" or extra item with no PO line behind it.
- 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.
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.
- 1Invoice arrivesemail, EDI, or OCR output cleaned into decimals
- 2Gather contextPO, receipts, vendor's recent invoice numbers
- 3Kovca Invoice Intelligenceduplicates, line matching, exact arithmeticAP analystrequires_review or an unmatched lineVendor disputereject_for_correction, with the issues list
- 4Approval workflowmatched invoices go to your normal approvers
- 5ERPstore 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 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:
{
"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
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:
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:
| status | recommended_action | What your workflow should do |
|---|---|---|
requires_review | manual_review | Possible duplicate, ambiguous line, or currency mismatch. Hold and assign to an AP analyst. |
discrepancies_found | reject_for_correction or manual_review | Price/quantity/amount issues: send back to the vendor with the issues list. An unmatched line goes to manual review. |
insufficient_information | request_information | No purchase order was supplied, and nothing else is wrong. Find the PO or get a budget-owner sign-off. |
matched | approve_for_processing | Move into your normal approval flow. This is not a payment instruction. |
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_mismatchissue andmissing_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, soINV-20931andinv 20931from the same vendor match. - Amounts must be plain decimals.
"$12"or1e3returns400 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.