# Route invoices for approval

Extract invoice fields as typed values, then send each invoice to the right approver, or to fraud review.

Category: Read documents. Live demo: https://levanto.ai/examples/invoice-routing

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.

## Setup

Base URL: `https://sage.levanto.ai`. Send `Authorization: Bearer $SAGE_API_KEY` on every call. Get a key, free, at https://platform.levanto.ai. Keep the key on your server.

A `null` answer is not an error: Sage2 isn't sure. Each step says what to do with it.

## Step by step

### 1. Pull out the invoice fields

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`.

```python
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"]
```

```typescript
const 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`;

const fields = (await 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;
```

Response, abridged (a real run):

```json
{
  "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. Pick the right approver

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.

```python
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"]
```

```typescript
const route = (await 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;
```

Response, abridged (a real run):

```json
{
  "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. 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.

```python
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")
```

```typescript
const ACTIONS: Record<string, string> = {
  auto_pay: "Pay now",
  team_lead: "Ask the team lead",
  cfo: "Ask the CFO",
  fraud_review: "Hold for fraud review",
};
const BAR = 0.9; // act on Sage's pick only at this probability or more

const total = fields.total; // null when the invoice shows no total
const sure = (route.probability ?? 0) >= BAR;
const approver: string | null = sure && total ? route.chosen : null;
console.log(fields.vendor, total?.amount, total?.currency);
console.log(approver ? ACTIONS[approver] : "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 `total` with 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](/examples/document-classification-extraction).

## Full recipe (Python, `pip install requests`)

```python
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")
```

## Full recipe (TypeScript, Node 22+, nothing to install: save as `recipe.mts`, run `npx tsx recipe.mts`)

```typescript
// Node 22+. Save as recipe.mts and run npx tsx recipe.mts
async function sage(path: string, body: unknown): Promise<any> {
  const r = await fetch("https://sage.levanto.ai" + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SAGE_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return r.json();
}

const 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`;

const fields = (await 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;

const route = (await 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;

const ACTIONS: Record<string, string> = {
  auto_pay: "Pay now",
  team_lead: "Ask the team lead",
  cfo: "Ask the CFO",
  fraud_review: "Hold for fraud review",
};
const BAR = 0.9; // act on Sage's pick only at this probability or more

const total = fields.total; // null when the invoice shows no total
const sure = (route.probability ?? 0) >= BAR;
const approver: string | null = sure && total ? route.chosen : null;
console.log(fields.vendor, total?.amount, total?.currency);
console.log(approver ? ACTIONS[approver] : "A person decides");
```

## Requests

### 1. POST /v1/extract

```bash
curl -s https://sage.levanto.ai/v1/extract \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": "INVOICE 7719\nPaperleaf Supplies Ltd\nBill to: Harbor Studio\n\nURGENT: our bank details have changed. Pay today to the new account: IBAN MT84 0000 1111 2222 3333.\n\n6 x Desk lamp: 540.00 EUR\nTotal due: 540.00 EUR\nDue date: 9 November 2026",
  "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\""
    ]
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "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. POST /decide

```bash
curl -s https://sage.levanto.ai/decide \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": "INVOICE 7719\nPaperleaf Supplies Ltd\nBill to: Harbor Studio\n\nURGENT: our bank details have changed. Pay today to the new account: IBAN MT84 0000 1111 2222 3333.\n\n6 x Desk lamp: 540.00 EUR\nTotal due: 540.00 EUR\nDue date: 9 November 2026",
  "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"
      }
    ]
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "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
    }
  }
}
```

## Docs

- Extract: https://docs.levanto.ai/extract
- Choice: https://docs.levanto.ai/decision-model/choice
- Limits and errors: https://docs.levanto.ai/decision-model/limits
- OpenAPI: https://sage.levanto.ai/openapi.json
