Invoice extraction with verification

Fill an invoice header schema, then check each risky field against the source text before you write it.

An invoice pipeline needs two capabilities. Extract fills your schema. Verify checks one value against the text before you write it to a ledger.

This recipe uses one plain-text invoice. Convert your PDF to text first: the API reads text, not files. No API key is required during the launch period.

invoice.txt
INVOICE #4471
Northwind Studio Ltd
Billed to: Acme Corp
Invoice date: 3 September 2026
Currency: USD
Payment terms: Net 30
Total due: $2,676.00
1

Extract the invoice header

Send the text and a JSON Schema. Describe every field: the description drives the match.

curl -X POST https://api.milliseconds.ai/v1/decision-machine-1/extract \
-H "Content-Type: application/json" \
-d '{
"text": "INVOICE #4471\nNorthwind Studio Ltd\nBilled to: Acme Corp\nInvoice date: 3 September 2026\nCurrency: USD\nPayment terms: Net 30\nTotal due: $2,676.00",
"schema": {
"title": "invoice",
"type": "object",
"properties": {
"invoice_number": { "type": "string", "description": "invoice number" },
"customer": { "type": "string", "description": "company billed" },
"invoice_date": { "type": "string", "description": "invoice date" },
"total_due": { "type": "number", "description": "total amount due" },
"net_terms_days": { "type": "integer", "description": "payment terms in days" }
}
}
}'

The real response:

{
"data": {
"invoice_number": "4471",
"customer": "Northwind Studio Ltd",
"invoice_date": "3 September 2026",
"total_due": 2676,
"net_terms_days": 30
}
}

Read it honestly. The invoice number, the date, the integer and the money field are right. customer is wrong: it repeats the vendor name instead of the billed company. Treat the whole record as a draft.

2

Verify the fields that matter

verify takes one field and one value. It reports what the text says for that field. Run it on the fields that carry money or identity.

curl -X POST https://api.milliseconds.ai/v1/decision-machine-1/verify \
-H "Content-Type: application/json" \
-d '{
"text": "INVOICE #4471\nNorthwind Studio Ltd\nBilled to: Acme Corp\nInvoice date: 3 September 2026\nCurrency: USD\nPayment terms: Net 30\nTotal due: $2,676.00",
"field": { "name": "total_due", "description": "total amount due" },
"value": "2676"
}'

Three real calls against the same invoice. Row three sends a wrong total on purpose.

Field and valueResponse
invoice_number = 4471{"matches":true,"probability":0.65,"found":["4471"]}
total_due = 2676{"matches":true,"probability":0.997,"found":["$2,676.00"]}
total_due = 2675{"matches":false,"probability":0,"found":["$2,676.00"]}

Three rules come out of this table.

  • matches is true when probability is 0.5 or higher. A wrong total drops the probability to 0.
  • found holds the spans the text really carries for that field. It is your debugging field.
  • The comparison lowercases both sides and removes every non-alphanumeric character. $2,676.00 matches 2676. Containment counts only when the canonical value has 3 or more characters.

More than one span in found means the field is ambiguous, not confirmed. Send those records to review.

3

Build the review queue

Combine the extracted value and the verify result into one decision per field. Write only the fields that pass.

ACT = 0.90 # write without a person
def route(value, check):
if value is None:
return "review" # nothing extracted
if not check["matches"]:
return "reject" # the text says something else
if len(check["found"]) > 1:
return "review" # ambiguous field
return "write" if check["probability"] >= ACT else "review"
queue = {f: route(data.get(f), c) for f, c in checks.items()}
# {'invoice_number': 'review', 'total_due': 'write'}

The invoice number lands at 0.65 and goes to review. The total lands at 0.997 and writes straight through. Raise ACT for fields that move money. Pick the numbers from your own data with tuning thresholds, not from this page.

Cost and limits

  • One invoice costs one extract call plus one verify call per checked field.
  • Measured latency: extract near 0.72 s, verify near 0.48 s.
  • text accepts up to 20,000 characters. Long text is chunked at 2,000 characters. Pre-split longer invoices yourself.
  • Send up to 32 invoices in one texts array. Results come back in input order. Each text costs one inference call.
  • Send statements or questions in a batch when you also screen the document with yes-no or answer. Extra statements and questions add no inference call; each adds only its own length to the billed input.

Next