Invoices and receipts
Header fields, line items, and the path from PDF to a database row.
Accounts payable systems usually need invoice header fields and a list of line items. Define that contract as a Pydantic schema before writing the result downstream.
output_schema requests structured output and Agno attempts to parse it. A failed or unparseable run can leave content as text. Check RunStatus.completed and the expected Pydantic type before reading fields, indexing, or writing downstream. These examples stop on failure; an application can instead send the original input to a review queue. Schema validation checks the shape and constraints, so verify extracted facts against the source separately.
Before running these examples, create and activate a Python environment, then install the provider and set its key:
uv venv .venv --python 3.12
source .venv/bin/activate
uv pip install "agno[openai]"
export OPENAI_API_KEY="..."Replace every https://example.com/... PDF URL with a real PDF URL accessible to the model provider, or use File(filepath="...") for a local PDF. Create the referenced local files before running the example. Extraction quality depends on the document and model; the output comments are illustrative.
from agno.run.base import RunStatus
from typing import List, Optional
from agno.agent import Agent
from agno.media import File
from agno.models.openai import OpenAIResponses
from pydantic import BaseModel, Field
class LineItem(BaseModel):
description: str = Field(..., description="Line description as printed")
quantity: Optional[float] = None
unit_price: Optional[float] = None
amount: Optional[float] = Field(None, description="Line total in invoice currency")
class Invoice(BaseModel):
invoice_number: Optional[str] = None
vendor: Optional[str] = None
vendor_tax_id: Optional[str] = None
bill_to: Optional[str] = None
invoice_date: Optional[str] = Field(None, description="ISO 8601 if possible")
due_date: Optional[str] = None
currency: Optional[str] = Field(None, description="ISO 4217, e.g. USD, EUR")
subtotal: Optional[float] = None
tax: Optional[float] = None
total: Optional[float] = None
lines: List[LineItem] = Field(default_factory=list)
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
instructions=(
"Extract every field and every line item from the attached invoice. "
"Numbers stay as numbers. Use ISO 8601 for dates when the format is "
"unambiguous. Null for missing fields. Do not invent line items."
),
output_schema=Invoice,
)
invoice_run = agent.run(
"Extract this invoice.",
files=[File(url="https://example.com/invoice-1042.pdf")],
)
if invoice_run.status != RunStatus.completed or not isinstance(invoice_run.content, Invoice):
raise RuntimeError("No validated Invoice; send the input to review before continuing")
invoice = invoice_run.content
# Invoice(invoice_number='1042', vendor='Acme Corp', invoice_date='2026-04-12',
# total=1296.0, currency='USD', lines=[LineItem(description='Pro plan',
# quantity=12, unit_price=99.0, amount=1188.0), LineItem(...)])Handle missing fields explicitly. The instructions tell the agent to return null for absent values and prohibit invented line items. Check the resulting None values before writing the invoice to the AP ledger.
Persist the row
After checking the run, validate the extracted values and required business fields before persistence. Pydantic .model_dump() produces the dictionary for the driver.
Install uv pip install "agno[postgres]". This fragment requires an existing ap PostgreSQL database at the URL below, an invoices table with the listed columns and a generated id, and an invoice_lines table with the listed columns and an invoice_id foreign key. Create the schema and grant the application role appropriate insert privileges before running it; this example does not provision tables.
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg://ai:ai@localhost:5532/ap")
with engine.begin() as conn:
invoice_id = conn.execute(
text(
"INSERT INTO invoices (invoice_number, vendor, vendor_tax_id, "
"bill_to, invoice_date, due_date, currency, subtotal, tax, total) "
"VALUES (:invoice_number, :vendor, :vendor_tax_id, :bill_to, "
":invoice_date, :due_date, :currency, :subtotal, :tax, :total) "
"RETURNING id"
),
invoice.model_dump(exclude={"lines"}),
).scalar_one()
for line in invoice.lines:
conn.execute(
text(
"INSERT INTO invoice_lines (invoice_id, description, "
"quantity, unit_price, amount) "
"VALUES (:invoice_id, :description, :quantity, "
":unit_price, :amount)"
),
{"invoice_id": invoice_id, **line.model_dump()},
)Insert the header once, then insert each line item with the returned invoice ID. The schema maps cleanly to separate header and line tables.
Receipts
For receipts, use a smaller header with the merchant, purchase date, currency, and total. Keep the same line-item shape and change output_schema to Receipt.
class Receipt(BaseModel):
merchant: Optional[str] = None
purchase_date: Optional[str] = None
currency: Optional[str] = None
total: Optional[float] = None
lines: List[LineItem] = Field(default_factory=list)For phone-camera receipts (skewed, low light), the input becomes an image rather than a PDF. See multimodal inputs for the input argument.
Confidence on noisy scans
Production AP sees faxed copies, partial scans, and mixed-language invoices. When you need a flag for "send this to a human", wrap each value in a confidence carrier. The pattern is identical to the data labeling pattern and feeds the routing logic in human routing and eval.
Next steps
| Task | Guide |
|---|---|
| Process a folder of invoices | Batch and durability |
| Route low-confidence invoices to AP review | Human routing and eval |
| Extract contract clauses with the same primitive | Contracts |