Terraced hillside of dry-stone walls in a risograph print of blue and ochre: upright wheat sheaves tied with blue cloth on the upper terraces, and below the lowest wall three sheaves lying scattered in the dirt with their bands loose
All posts

Extract structured data from a PDF with an LLM API: JSON from a schema, billed and metered per document

AIVAX's extract endpoint turns PDFs and images into JSON shaped by your schema. Billing and quota are per document, so send one document per request and retry only the one that failed.

Send documents to AIVAX's extract endpoint one per request, and a failure costs you one read: the error maps to a single input, and the retry repeats a single document. A batch request is all-or-nothing. If any document fails, it returns an error instead of partial results, and the documents that finished are billed and count toward the quota, as the extraction docs state.

The accuracy question comes first for most people who ask how to extract structured data from a PDF with an LLM. A Gemini forum thread from September lists the usual failures (missed line items, a price bound to the wrong product, decimals) and has no replies. An OpenAI forum thread on catalogs of a hundred pages ended with the advice to skip the PDF where the manufacturer publishes structured data. I have nothing to add on accuracy. What follows is the behavior around it, which decides whether a bulk job surprises you.

The endpoint takes a schema and returns one object per document

POST /api/v1/generations/extract takes a responseSchema, an optional instructions string and an array of documents, each an OpenAI-style content part: text, image_url or file. Every property in the schema is a field to find, and its description is what the model searches for, so "total amount due" works better than "total". The response is one JSON object per document, in input order. The extraction docs list the formats (JPEG, PNG, TIFF and PDF, multi-page included) and the request shape. If you want the whole text of a document rather than a few fields, fetching messy web content to text is the other route. The request shape and the schema-driven search are laid out in turning PDFs and images into JSON.

Billing is for input tokens only, at $0.04 per million. Output tokens are free, which suits a small JSON object read from a long document.

The code bills each document as it finishes

In DocumentProcessing.cs every document writes its usage row in its own database transaction, right after its result comes back, and the endpoint runs four documents at a time. A rejected document throws, the request returns that error, and the rows already written stay. To check the shape I ran a script with the same Parallel.ForAsync call: document 2 of 6 throws while the others complete, five charge flags end up set, and the exception that comes out is document 2's.

The sums are small. Take 5,000 input tokens per document, a figure I made up for the arithmetic: that is $0.0002 a document, so five wasted documents cost a tenth of a cent. The cost of a failed batch is the work. You paid to read documents whose JSON you never saw, and the retry reads them again.

The quota counts documents, and a rejected attempt stays counted

Before any processing, the endpoint adds the number of documents to a per-account window. The free plan allows 10 documents a minute and 300 a day, Pro 100 a minute, Max 1,000 and Ultimate 2,500; the limits page has the current numbers, and summarization shares the counter. The limiter adds the value before it compares it and leaves it in the window when it rejects the request, which means a batch of eleven can never pass on the free plan and a rejected batch still uses up its documents until the window moves. The 429 has no Retry-After header; the server sets that header in one place, for upstream failures, with a fixed five seconds. Splitting a batch into smaller requests spends the same quota, because the unit is the document.

Values come back typed only when they parse

The schema can declare number, integer or boolean. The server converts a string to the declared type when it can and leaves it as a string otherwise. A missing field is null, and I found nothing that enforces required. The conversion uses invariant-culture parsing. I ran the same parse options over a few strings: 1234.56, 20 and 1e3 become numbers, while 1,234.56, 1.234,56, $20.00, 20.00 USD and R$ 1.234,56 stay as strings.

If the model copies a value as the document writes it, a field you declared as a number arrives as text whenever the document uses thousands separators or a currency symbol. I would validate every type on my side and treat the schema as a request. The docs say the same about the values: they are model-generated, and amounts and identifiers deserve a check against the original.

Send one document per request

I would send one document per request and run my own concurrency. A failure then costs one document, the error maps to one input, and a retry repeats one read. Quota is the same either way, since it counts documents, though a rejected attempt also spends its documents, so keep the number in flight per minute under your plan's limit.

import os
import time

import requests

URL = "https://inference.aivax.net/api/v1/generations/extract"
HEADERS = {"Authorization": f"Bearer {os.environ['AIVAX_API_KEY']}"}


def extract(document, schema, instructions=None):
    body = {"responseSchema": schema, "documents": [document]}
    if instructions:
        body["instructions"] = instructions

    for _ in range(3):
        response = requests.post(URL, headers=HEADERS, json=body, timeout=120)
        if response.status_code == 429:
            time.sleep(61)
            continue
        response.raise_for_status()
        return response.json()["data"][0]

    raise RuntimeError("document rate limit still exceeded after three tries")

The 429 does not say whether the minute or the day window was hit, so the loop gives up after three tries and leaves the daily case to the caller. For large files, send a URL under 5 MB or split the file first. For other work that fails partway, expired batch jobs covers what you keep and what you retry. If you need the text of a PDF for retrieval instead of named fields, PDF to Markdown for RAG chunks is the matching post, and chat-completion JSON has different failure modes, described in structured output healing. When the next step needs a short description of the document rather than named fields, Summarization returns one summary per PDF or image.

Read on 2026-10-28: the AIVAX code for the extract endpoint, document resolution, usage recording and the rate limiter; the extraction and limits docs; the two forum threads. On November 10, 2026 I ran requests against the live AIVAX API: extract with a text document, a PDF URL and a base64 PDF; a schema with $ref (400); a schema with no properties (400); a URL that returned 404 (400 with the download status); a URL over 5 MB (400, "Content length exceeded 5,242,880 bytes"); and a number field, where 1234.56 came back as a number while R$ 1.234,56 and $20.00 stayed strings. The Python helper above ran as written: a text document returned {'total': '42.50 USD'} for a number field, and a URL that returned 404 raised the 400. A request with four text documents and one corrupt PDF returned 400, and the account balance fell by about four documents' worth of credits, 0.000033 against 0.0000083 for one document alone. The quota windows, including the 429 and its missing Retry-After, come from the code, and the loop check and number parsing from standalone scripts with the same calls. The $0.04 per million price is as listed on October 28. I did not measure extraction accuracy. I found no page limit and no size check for base64 data: URLs, only the 5 MB cap on downloaded URLs. The 400 for an unsupported type (I sent an audio part) names the allowed types and not the rejected document's index.