# Invoice OCR API: extract invoices to JSON
URL: https://docs.munito.ai/invoice-extraction

To extract an invoice to structured JSON with Munito AI, send the PDF to `POST /ocr` to get Markdown, then send the Markdown to a chat model with a JSON schema.
The answer is JSON that matches the schema: the number, the date, the seller, the buyer, the total and each line.

## How it works [#how-it-works]

| Step                  | Endpoint                                                        | Model              | Result                        |
| --------------------- | --------------------------------------------------------------- | ------------------ | ----------------------------- |
| 1. Read the document  | [`POST /ocr`](https://docs.munito.ai/api/createOcr)                         | `munito/ocr-auto`  | The Markdown of each page     |
| 2. Extract the fields | [`POST /chat/completions`](https://docs.munito.ai/api/createChatCompletion) | `qwen/qwen3.8-27b` | JSON that matches your schema |

Both steps use the same key and the base URL `https://api.munito.ai/inference/v1`.
To keep the processing in the EU, use `https://api.eu.munito.ai/inference/v1`. See [Regions](https://docs.munito.ai/regions).

## Complete script [#complete-script]

Install the clients and set your key:

```bash
pip install openai requests
export MUNITO_API_KEY="munitoai_..."
```

Save this file as `extract_invoice.py`:

```python
# pip install openai requests
import base64
import json
import os
import sys
import time

import requests
from openai import OpenAI

HOST = "https://api.munito.ai"
BASE_URL = f"{HOST}/inference/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['MUNITO_API_KEY']}"}

INVOICE_SCHEMA = {
    "type": "object",
    "properties": {
        "invoice_number": {"type": "string"},
        "invoice_date": {"type": "string", "description": "ISO 8601 date, YYYY-MM-DD"},
        "seller": {"type": "string"},
        "buyer": {"type": "string"},
        "currency": {"type": "string", "description": "ISO 4217 code, such as EUR"},
        "total": {"type": "number"},
        "lines": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "description": {"type": "string"},
                    "quantity": {"type": "number"},
                    "unit_price": {"type": "number"},
                    "amount": {"type": "number"},
                },
                "required": ["description", "quantity", "unit_price", "amount"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["invoice_number", "invoice_date", "seller", "buyer", "currency", "total", "lines"],
    "additionalProperties": False,
}


def ocr_markdown(path: str) -> str:
    """Read the PDF with Munito OCR and return the Markdown of all pages."""
    with open(path, "rb") as f:
        data = base64.b64encode(f.read()).decode()
    response = requests.post(
        f"{BASE_URL}/ocr",
        headers=HEADERS,
        json={
            "model": "munito/ocr-auto",
            "document": {"type": "base64", "data": data, "filename": os.path.basename(path)},
        },
        timeout=300,
    )
    response.raise_for_status()
    body = response.json()
    # A long document answers 202. Poll its location until the status is final.
    while response.status_code == 202 or body.get("status") == "running":
        time.sleep(2)
        response = requests.get(HOST + body["location"], headers=HEADERS, timeout=60)
        response.raise_for_status()
        body = {**response.json(), "location": body["location"]}
    if body.get("status", "completed") != "completed":
        sys.exit(f"OCR ended with status {body['status']}")
    return "\n\n".join(page["markdown"] for page in body["pages"])


def extract_fields(markdown: str) -> dict:
    """Ask a chat model for the invoice fields, as JSON that matches the schema."""
    client = OpenAI(base_url=BASE_URL, api_key=os.environ["MUNITO_API_KEY"])
    completion = client.chat.completions.create(
        model="qwen/qwen3.8-27b",
        messages=[
            {"role": "system", "content": "Extract the invoice fields. Use only values that the invoice shows."},
            {"role": "user", "content": markdown},
        ],
        response_format={
            "type": "json_schema",
            "json_schema": {"name": "invoice", "schema": INVOICE_SCHEMA, "strict": True},
        },
        reasoning_effort="low",
        max_tokens=4096,
    )
    return json.loads(completion.choices[0].message.content)


if __name__ == "__main__":
    path = sys.argv[1] if len(sys.argv) > 1 else "invoice.pdf"
    fields = extract_fields(ocr_markdown(path))
    print(json.dumps(fields, indent=2))
```

Run it on your invoice:

```bash
python extract_invoice.py invoice.pdf
```

The script prints the fields:

```json
{
  "invoice_number": "INV-2026-0417",
  "invoice_date": "2026-10-15",
  "seller": "Nordlicht Software GmbH",
  "buyer": "Atelier Vert SARL",
  "currency": "EUR",
  "total": 1499.4,
  "lines": [
    { "description": "Support plan, October", "quantity": 1, "unit_price": 900.0, "amount": 900.0 },
    { "description": "Onboarding workshop (hours)", "quantity": 3, "unit_price": 120.0, "amount": 360.0 }
  ]
}
```

## Choices in the script [#choices-in-the-script]

- **`munito/ocr-auto`** reads a page that is mostly text from its text layer, and sends a scanned page to an OCR model. For a scan or a photo, you can also name `lightonai/lightonocr-2-1b`. See [PDF to Markdown](https://docs.munito.ai/pdf-to-markdown).
- **`response_format`** with `json_schema` makes the answer match the schema. Add a field to the schema to extract it, such as `due_date` or `vat_id`.
- **`reasoning_effort: "low"`** and a large `max_tokens` keep the answer in `content`. `qwen/qwen3.8-27b` reasons before it answers, and a small `max_tokens` can end the request before the answer, with `content` set to `null`.
- **Base64** sends a local file. To send a file that is on the web, use `{"type": "url", "url": "https://..."}` in place of the base64 document.

## Cost [#cost]

One extraction costs the OCR pages plus the chat tokens: the input tokens of the Markdown and the schema, and the output tokens of the JSON.
The OCR answer shows the page cost in `usage.cost_usd`, and the chat answer shows its tokens in `usage`. A page that fails is not billed.
See [Models and pricing](https://docs.munito.ai/models#prices) for the price of each page and each token.

## Errors [#errors]

| Status                                                               | Cause                                 | Fix                                                           |
| -------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- |
| `413` `Document exceeds the configured size limit`                   | The file is larger than 50 MiB.       | Split the PDF.                                                |
| `422` `This document type is not supported. Send a PDF or an image.` | The file is not a PDF or an image.    | Convert the file to PDF.                                      |
| `422` `Invalid base64 data`                                          | The `data` field is not valid base64. | Encode the raw bytes of the file.                             |
| `503`                                                                | No capacity now.                      | Wait for the `Retry-After` time, then send the request again. |

See [Errors](https://docs.munito.ai/errors) for each status and its body.
