Skip to content

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
Try
  1. 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
    88 ms
  2. Pick the right approverChoice
    • auto_payunder 0.01%
    • team_leadunder 0.01%
    • cfounder 0.01%
    • fraud_review99.99%
    144 ms
  3. 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

  1. Extract

    Pull out the invoice fields

    Extract docs

    Send the invoice text to /v1/extract with the fields you want. A field is a name plus a short definition, or a type:

    • total has type money. It comes back as {amount, currency}.
    • due_date has type date and a definition, so Sage2 reads the date payment is due, not the invoice date. It comes back as an ISO date.
    • items is 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 is null.

    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
      }
    }
  2. Choice

    Pick the right approver

    Choice docs

    Write your approval policy in the question's instructions and list the routes as options. Sage2 returns its pick in chosen, with a probability for 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
        }
      }
    }
  3. Your code

    Pass it to the application

    Map each option to an action in your system. Act on the pick only when its probability is at or above your bar, here 0.9.

    Below your bar, when chosen is null, 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

  1. Set your own bar. Test on a few hundred past invoices, then pay automatically only above the level you trust.
  2. Double-check all numerical values. Compare the extracted total with the purchase order before you pay.
  3. Fast and small. Each call answers in about 0.1 s and reads a few hundred tokens.
  4. PDFs and scans need OCR first. See Read any document.

Sage / Levanto

Start free.

Sign in or create an account.

Continue with Google
or with email

We’ll email you a 6-digit code.