Reading bills with a model
Every document that lands in the inbox, by upload or by email, can be read by a model: vendor, invoice number, dates, line items, tax and total. Payvy checks what the model says, matches it to a vendor on file and keeps it as a draft. Nothing becomes a bill until someone calls bill.create with it.
What happens
Section titled “What happens”- The model reads the page. It fills in one fixed schema and copies amounts exactly as printed (“1,234.56”), with no arithmetic. The same schema goes to every provider.
- Payvy checks it. Amounts become cents, or the field fails if they can’t be read exactly. The lines must add up to the total, with or without the tax. Subtotal plus tax must equal the total. The due date can’t be before the invoice date. The document must be an invoice, not a statement, credit memo, receipt or quote, and in US dollars.
- Payvy matches the vendor. An account number on file is the strongest match, then the email domain (free mail like gmail.com doesn’t count), then the name, ignoring case, punctuation and suffixes like LLC. If two vendors fit equally well, a person chooses. If the vendor already has a bill with this invoice number, the draft is flagged as a duplicate.
- The result is kept on the document (
document.get→extractions). It has every check with its reason,ready(true when every check passed) andbill_input: the exactbill.createinput it suggests. If no vendor matched,new_vendorsuggests one to create. - Someone enters it. A person or an agent calls
bill.createwithbill_input, as it is or corrected. Thebill.createdevent records the extraction and which fields were corrected, so over time you can see how often each model is right.
With PAYVY_EXTRACT_AUTO on (the default), new inbox documents are read in the background by the agent extractor within about 30 seconds. A failed read is recorded on the document and tried again after 15 minutes. After three failures, it waits for a person. Agents can also call document.extract themselves; it refuses a document that was already read unless again is true.
Choosing a model
Section titled “Choosing a model”Set PAYVY_EXTRACT_PROVIDER and its key. Two backends cover nearly everything:
| Provider | Settings |
|---|---|
Claude (default model claude-opus-5-5) |
PAYVY_EXTRACT_PROVIDER=anthropic, PAYVY_EXTRACT_API_KEY |
| OpenAI | openai-compatible, key, PAYVY_EXTRACT_MODEL |
| Gemini | openai-compatible, PAYVY_EXTRACT_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai, key, model |
| Grok | openai-compatible, PAYVY_EXTRACT_BASE_URL=https://api.x.ai/v1, key, model |
| OpenRouter (most models) | openai-compatible, PAYVY_EXTRACT_BASE_URL=https://openrouter.ai/api/v1, key, model |
| A model on your own server | openai-compatible, PAYVY_EXTRACT_BASE_URL=http://127.0.0.1:11434/v1 (Ollama) or your vLLM URL, model, no key |
A local model keeps every invoice on your own hardware. It has to accept images or PDFs. PDFs are sent as a file part, which not every OpenAI-compatible server supports yet. TIFF scans aren’t sent to any provider; convert them to PDF.
Measuring a model
Section titled “Measuring a model”fixtures/invoices in the source repo holds sample documents with known answers: an invoice with tax, one without, one with a discount line, a statement and a credit memo. Score the configured model against them with payvy extract-eval fixtures/invoices before trusting it, and again when the prompt, schema or model changes.
Extraction quality depends on the document and the model. See Invoice intake for where documents come from, and Configuration for all PAYVY_EXTRACT_* variables.