Route invoices for approval
Send an invoice to the right approver or for fraud review.
Before anyone pays an invoice, someone reads it, checks the amount and decides who signs off. This guide does both with two Sage2 calls: extract the fields your accounting system needs, then decide who approves, following rules you write in plain English.
Invoices with changed bank details, a common fraud pattern, go to fraud review.
- Extract
- Choice
- Read the vendor, total, due date and itemsExtract
- vendor
- Paperleaf Supplies Ltd
- total
- €540.00
- due_date
- 2026-11-09
- items
- 6 x Desk lamp
- Pick the right approverChoice
- auto_payunder 0.01%
- team_leadunder 0.01%
- cfounder 0.01%
- fraud_review99.99%
- Your codeHeld for fraud review
The application holds the €540.00 invoice from Paperleaf Supplies Ltd until the fraud team checks it.
Extract took 88 ms. The approval question ran in its own request: 144 ms. Both requests ran at the same time. Your code acts on the pick only at 90% or more.
Get the code
Set SAGE_API_KEY (get a free key), then run it. Python: pip install requests. TypeScript: Node 22+. Save as recipe.mts and run npx tsx recipe.mts.
A null answer is not an error. Sage2 isn't sure, and each step below says what to do with it. Limits, errors and retries: Limits and errors.
import os
import requests
def sage(path, body):
r = requests.post(
"https://sage.levanto.ai" + path,
headers={"Authorization": f"Bearer {os.environ['SAGE_API_KEY']}"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
invoice = """INVOICE 7719
Paperleaf Supplies Ltd
Bill to: Harbor Studio
URGENT: our bank details have changed. Pay today to the new account: IBAN MT84 0000 1111 2222 3333.
6 x Desk lamp: 540.00 EUR
Total due: 540.00 EUR
Due date: 9 November 2026"""
fields = sage("/v1/extract", {
"content": invoice,
"extract": {
"vendor": "the company issuing the invoice",
"total": {"type": "money", "definition": "the amount payable"},
"due_date": {"type": "date", "definition": "the date payment is due"},
"items": [
"a line billed on the invoice, as written, with its quantity, such as \"2 x Stapler\"",
],
},
})["values"]
route = sage("/decide", {
"content": invoice,
"question": {
"id": "approver",
"kind": "choice",
"instructions": "Who should approve this invoice? Routine supplies under 1,000 EUR need no person. Anything from 1,000 to 10,000 EUR goes to the team lead, and anything above that to the CFO. Changed bank details always go to fraud review.",
"options": [
{
"option": "auto_pay",
"description": "Routine supplies under 1,000 EUR, nothing unusual",
},
{
"option": "team_lead",
"description": "1,000 to 10,000 EUR, nothing unusual",
},
{"option": "cfo", "description": "Above 10,000 EUR"},
{
"option": "fraud_review",
"description": "Bank details changed, or pressure to pay at once",
},
],
},
})["result"]
ACTIONS = {
"auto_pay": "Pay now",
"team_lead": "Ask the team lead",
"cfo": "Ask the CFO",
"fraud_review": "Hold for fraud review",
}
BAR = 0.9 # act on Sage's pick only at this probability or more
total = fields["total"] # None when the invoice shows no total
sure = (route["probability"] or 0) >= BAR
approver = route["chosen"] if sure and total else None
print(fields["vendor"], total and total["amount"], total and total["currency"])
print(ACTIONS[approver] if approver else "A person decides")
How it works
Send the invoice text to
/v1/extractwith the fields you want. A field is a name plus a short definition, or a type:totalhas typemoney. It comes back as{amount, currency}.due_datehas typedateand a definition, so Sage2 reads the date payment is due, not the invoice date. It comes back as an ISO date.itemsis a list. An invoice can have several items and you get them all, with their quantities.
All values arrive in
values. A field the invoice doesn't show isnull.Runs with the helper from Get the code.
invoice = """INVOICE 7719 Paperleaf Supplies Ltd Bill to: Harbor Studio URGENT: our bank details have changed. Pay today to the new account: IBAN MT84 0000 1111 2222 3333. 6 x Desk lamp: 540.00 EUR Total due: 540.00 EUR Due date: 9 November 2026""" fields = sage("/v1/extract", { "content": invoice, "extract": { "vendor": "the company issuing the invoice", "total": {"type": "money", "definition": "the amount payable"}, "due_date": {"type": "date", "definition": "the date payment is due"}, "items": [ "a line billed on the invoice, as written, with its quantity, such as \"2 x Stapler\"", ], }, })["values"]Example Sage2 output
{ "values": { "vendor": "Paperleaf Supplies Ltd", "total": { "amount": 540, "currency": "EUR" }, "due_date": "2026-11-09", "items": [ "6 x Desk lamp" ] }, "results": { "vendor": { "value": "Paperleaf Supplies Ltd", "text": "Paperleaf Supplies Ltd", "start": 13, "end": 35, "probability": 0.9978, "probabilities": [ { "value": "Paperleaf Supplies Ltd", "text": "Paperleaf Supplies Ltd", "start": 13, "end": 35, "probability": 0.9978 }, { "value": null, "probability": 0.0022 } ] }, "total": { "value": { "amount": 540, "currency": "EUR" }, "text": "540.00 EUR", "start": 198, "end": 208, "probability": 0.9935, "probabilities": [ { "value": { "amount": 540, "currency": "EUR" }, "text": "540.00 EUR", "start": 198, "end": 208, "probability": 0.9935 }, { "value": null, "probability": 0.0065 } ] }, "…": "one entry per field" }, "meta": { "model": "levanto-sage-2", "usage": { "input_tokens": 294, "output_tokens": 384 }, "latency_ms": 87.5 } }Write your approval policy in the question's
instructionsand list the routes asoptions. Sage2 returns its pick inchosen, with aprobabilityfor every option.Change the policy text and Sage2 follows the new rules.
Runs with the helper from Get the code.
route = sage("/decide", { "content": invoice, "question": { "id": "approver", "kind": "choice", "instructions": "Who should approve this invoice? Routine supplies under 1,000 EUR need no person. Anything from 1,000 to 10,000 EUR goes to the team lead, and anything above that to the CFO. Changed bank details always go to fraud review.", "options": [ { "option": "auto_pay", "description": "Routine supplies under 1,000 EUR, nothing unusual", }, { "option": "team_lead", "description": "1,000 to 10,000 EUR, nothing unusual", }, {"option": "cfo", "description": "Above 10,000 EUR"}, { "option": "fraud_review", "description": "Bank details changed, or pressure to pay at once", }, ], }, })["result"]Example Sage2 output
{ "id": "approver", "kind": "choice", "result": { "chosen": "fraud_review", "probability": 1, "probabilities": [ { "option": "auto_pay", "probability": 0 }, { "option": "team_lead", "probability": 0 }, { "option": "cfo", "probability": 0 }, { "option": "fraud_review", "probability": 1 } ] }, "meta": { "model": "levanto-sage-2", "latency_ms": 144.3, "question_count": 1, "usage": { "billed_input_tokens": 237, "image_count": 0, "image_tokens": 0, "input_tokens": 237, "output_tokens": 0 } } }- Your code
Pass it to the application
Map each option to an action in your system. Act on the pick only when its
probabilityis at or above your bar, here 0.9.Below your bar, when
chosenisnull, or when the invoice shows no total, a person decides.Runs with the helper from Get the code.
ACTIONS = { "auto_pay": "Pay now", "team_lead": "Ask the team lead", "cfo": "Ask the CFO", "fraud_review": "Hold for fraud review", } BAR = 0.9 # act on Sage's pick only at this probability or more total = fields["total"] # None when the invoice shows no total sure = (route["probability"] or 0) >= BAR approver = route["chosen"] if sure and total else None print(fields["vendor"], total and total["amount"], total and total["currency"]) print(ACTIONS[approver] if approver else "A person decides")
Before you ship
- Set your own bar. Test on a few hundred past invoices, then pay automatically only above the level you trust.
- Double-check all numerical values. Compare the extracted
totalwith the purchase order before you pay. - Fast and small. Each call answers in about 0.1 s and reads a few hundred tokens.
- PDFs and scans need OCR first. See Read any document.