Invoice OCR API: extract invoices to JSON

Extract invoice data to structured JSON. Read the invoice PDF with the Munito AI OCR API, then get its fields from a chat model with a JSON schema.

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

StepEndpointModelResult
1. Read the documentPOST /ocrmunito/ocr-autoThe Markdown of each page
2. Extract the fieldsPOST /chat/completionsqwen/qwen3.8-27bJSON 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.

Complete script

Install the clients and set your key:

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

Save this file as extract_invoice.py:

# 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:

python extract_invoice.py invoice.pdf

The script prints the fields:

{
  "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,
      "amount": 900
    },
    {
      "description": "Onboarding workshop (hours)",
      "quantity": 3,
      "unit_price": 120,
      "amount": 360
    }
  ]
}

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

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 for the price of each page and each token.

Errors

StatusCauseFix
413 Document exceeds the configured size limitThe 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 dataThe data field is not valid base64.Encode the raw bytes of the file.
503No capacity now.Wait for the Retry-After time, then send the request again.

See Errors for each status and its body.