Why raw JSON from a PDF is not the finish line
When you convert PDF to JSON with a generic parser, you usually get a layout tree. Pages contain blocks, blocks contain text and coordinates, and tables arrive as rows and columns. That output is faithful to the document, which is exactly what you want for search, display and retrieval.
It is not what your accounting code wants. Your code wants an object with an invoice number, an issue date, a due date, a currency, a total and a list of line items. It wants those fields to have the same names on every document, the same types on every run and a clear signal when something is missing.
The gap between the layout tree and the business record is where most PDF pipelines break. Somebody writes a regular expression for "Total due", a vendor changes their template, and the regular expression starts returning the subtotal. Nothing fails loudly. The wrong number flows downstream.
A schema closes that gap. You declare the fields you need, their types and a short description of each. The parser reads the document, finds the values and returns them in your shape. Your code validates the result against the same schema and decides what to do with anything that does not fit.
In PdfParse the layout output and the schema output come back in the same response. The pages array keeps every block with its bounding box, and the fields object holds your typed values. You get both views of one document in one call, so you never have to choose between auditability and convenience.
Designing the schema before you write any parsing code
Start from the consumer, not from the document. List what the downstream system actually reads. If your payables job needs six fields, the schema has six fields plus line items, not thirty fields because the invoice happens to print thirty things.
Name fields the way your code names them. Use snake case, keep names stable and avoid names that depend on one vendor's wording. total_amount survives a template change. amount_due_incl_tax_eur does not.
Write a one sentence description for every field. Descriptions are not decoration. They tell the extractor which of several similar numbers you mean, for example "the final amount payable after tax and discounts, not the subtotal".
Keep the schema flat where you can and use arrays only for repeating groups such as line items or account transactions. Deep nesting makes validation messages harder to read and rarely matches how the data is stored afterwards.
Version the schema. When you add a field, create a new schema id and keep the old one running until every consumer reads the new shape. Treat a schema like a database migration, because that is what it is.
- One schema per document family, for example supplier invoices, bank statements, purchase orders
- Field names that match your database columns
- A description that disambiguates every field that has a lookalike on the page
- Arrays only for genuinely repeating rows
- A version suffix in the schema name, for example invoice_v3
Registering a schema
You register a schema once with POST /v1/schemas and then reference it by id on every parse. Supported types are string, number, date, money, boolean and array.
{
"name": "invoice_v3",
"fields": [
{"name": "invoice_number", "type": "string", "description": "Identifier printed by the supplier, usually labelled Invoice No."},
{"name": "issue_date", "type": "date", "description": "Date the invoice was issued"},
{"name": "due_date", "type": "date", "description": "Date payment is due, null if the invoice states no due date"},
{"name": "supplier_name", "type": "string", "description": "Legal or trading name of the company that issued the invoice"},
{"name": "total_amount", "type": "money", "description": "Final amount payable after tax and discounts, not the subtotal"},
{"name": "tax_amount", "type": "money", "description": "Total tax charged on the invoice"},
{"name": "is_credit_note", "type": "boolean", "description": "True when the document is a credit note rather than an invoice"},
{"name": "line_items", "type": "array", "description": "One entry per billed line", "items": [
{"name": "description", "type": "string", "description": "Text of the line"},
{"name": "quantity", "type": "number", "description": "Billed quantity"},
{"name": "unit_price", "type": "money", "description": "Price per unit before tax"},
{"name": "line_total", "type": "money", "description": "Amount for the line before tax"}
]}
]
}
Custom schemas are part of the Growth plan (up to 10 schemas) and unlimited on Scale and Enterprise. The full field reference lives in the API documentation.
Money, dates and numbers deserve their own types
Most broken PDF to JSON pipelines fail on three kinds of values. Money, dates and numbers look simple on the page and turn out to be locale dependent strings.
Money should never travel as a float. A float cannot represent 0.10 exactly, and summing line items in floating point produces totals that differ from the printed total by a fraction of a cent. Treat money as a decimal string plus a currency code, and convert to integer minor units or a decimal type in your own code.
Dates on documents come in every order. "03/04/2026" is March 4 in one country and April 3 in another. A date field should come back as ISO 8601 (YYYY-MM-DD), and your validator should reject anything else instead of guessing.
Numbers carry thousands separators that change meaning by region. "1.234" is one thousand two hundred thirty four in some locales and a little over one in others. The schema type tells the extractor to normalise the value, and your validator confirms the result is a real number.
| Type | What comes back | What your code should do |
|---|---|---|
| string | Trimmed text | Check length and allowed characters |
| number | JSON number | Check range, for example quantity greater than zero |
| date | ISO 8601 string | Parse strictly, reject anything else |
| money | Amount as a decimal string and an ISO 4217 currency code | Load into a decimal type, never a float |
| boolean | true or false | Treat null as unknown, not as false |
| array | List of objects with their own typed fields | Validate every item, check that line totals add up |
Nullable fields are a feature
A field that is not on the document should come back as null, not as an empty string, not as zero and not as a guess. Zero is a valid amount. An empty string is a valid name. Null is the only value that means "the document does not say".
Decide per field whether null is acceptable. A missing due date may be fine because your system applies default payment terms. A missing total is never fine and should send the document to review.
Write that decision into the validator, not into scattered if statements across your codebase. One place, one rule per field.
Confidence and source page on every field
Every value in fields comes back with a confidence score and the page it was read from. The confidence is a signal for routing, not a promise. Use it to decide which documents go straight through and which ones a person looks at.
{
"fields": {
"invoice_number": {"value": "INV-20418", "confidence": 0.98, "page": 1},
"due_date": {"value": "2026-11-19", "confidence": 0.91, "page": 1},
"total_amount": {"value": {"amount": "1240.50", "currency": "USD"}, "confidence": 0.96, "page": 2},
"tax_amount": {"value": null, "confidence": 0.0, "page": null}
}
}
A practical pattern is a threshold per field rather than one global threshold. Set a high bar for fields that move money and a lower bar for descriptive fields. Pick the thresholds from your own review data after a few hundred documents, not from intuition.
The source page matters when someone has to check a value. A reviewer who sees "page 2" can open the right page instead of scrolling a 40 page contract. Combined with the block bounding boxes in pages, your review screen can highlight the exact region the value came from.
Parsing and validating in Python with pydantic
The Python SDK returns the same JSON as the REST API. Validate it with pydantic so that every record entering your system has passed one strict gate.
import os
from datetime import date
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from pdfparse_ai import PdfParse
client = PdfParse(api_key=os.environ["PDFPARSE_API_KEY"])
class Money(BaseModel):
amount: Decimal
currency: str = Field(min_length=3, max_length=3)
class LineItem(BaseModel):
description: str
quantity: Decimal = Field(gt=0)
unit_price: Money
line_total: Money
class Invoice(BaseModel):
invoice_number: str = Field(min_length=1)
issue_date: date
due_date: Optional[date] = None
supplier_name: str
total_amount: Money
tax_amount: Optional[Money] = None
is_credit_note: bool = False
line_items: list[LineItem]
@field_validator("invoice_number")
@classmethod
def strip_number(cls, v: str) -> str:
return v.strip()
@model_validator(mode="after")
def totals_match(self):
lines = sum((li.line_total.amount for li in self.line_items), Decimal("0"))
tax = self.tax_amount.amount if self.tax_amount else Decimal("0")
if abs(lines + tax - self.total_amount.amount) > Decimal("0.02"):
raise ValueError("line items plus tax do not add up to the total")
return self
THRESHOLDS = {"total_amount": 0.9, "invoice_number": 0.9, "due_date": 0.8}
def extract_invoice(path: str) -> tuple[Invoice | None, list[str]]:
result = client.parse(path, outputs=["json"], schema_id="invoice_v3")
raw = {name: f["value"] for name, f in result["fields"].items()}
problems = [
f"{name} below threshold on page {result['fields'][name]['page']}"
for name, bar in THRESHOLDS.items()
if result["fields"].get(name, {}).get("confidence", 0) < bar
]
try:
return Invoice.model_validate(raw), problems
except Exception as exc:
return None, problems + [str(exc)]
The cross field check in totals_match is the most valuable line in the file. A total that does not equal the sum of its lines plus tax is either a misread value or a document that genuinely does not add up. Both cases deserve a human.
The same gate in Node with zod
In TypeScript, zod gives you a runtime check and a static type from one definition.
import { PdfParse } from "pdfparse-ai";
import { z } from "zod";
const client = new PdfParse({ apiKey: process.env.PDFPARSE_API_KEY });
const Money = z.object({
amount: z.string().regex(/^-?\d+(\.\d+)?$/),
currency: z.string().length(3),
});
const Invoice = z.object({
invoice_number: z.string().min(1),
issue_date: z.string().date(),
due_date: z.string().date().nullable(),
supplier_name: z.string().min(1),
total_amount: Money,
tax_amount: Money.nullable(),
is_credit_note: z.boolean().default(false),
line_items: z.array(z.object({
description: z.string(),
quantity: z.number().positive(),
unit_price: Money,
line_total: Money,
})),
});
type Invoice = z.infer<typeof Invoice>;
export async function extractInvoice(file: string): Promise<Invoice | null> {
const result = await client.parse({ file, outputs: ["json"], schema_id: "invoice_v3" });
const raw = Object.fromEntries(
Object.entries(result.fields).map(([name, f]: [string, any]) => [name, f.value])
);
const parsed = Invoice.safeParse(raw);
if (parsed.success) return parsed.data;
console.warn("send to review", parsed.error.issues);
return null;
}
Keep money as strings in TypeScript and convert with a decimal library at the point where you do arithmetic. JavaScript numbers are floats, with the same rounding issue as Python floats.
Validation failures, retries and review
Not every failure is the same, and your retry logic should tell them apart.
- Transport errors and HTTP 5xx responses are worth retrying with exponential backoff and jitter.
- HTTP 4xx responses such as an unsupported file or an oversized upload are not worth retrying. Log them and move on.
- A successful parse that fails your validator is not a transport problem. Retrying the same file with the same schema gives the same answer. Route it to review.
- A field below its confidence threshold goes to review with the source page attached.
If you change something before retrying, such as forcing OCR with ocr: "always" on a document whose text layer is broken, that is a legitimate second attempt. Count it as a separate parse, because each parse uses pages from your plan.
Pages count as usage in the billing period, so a careless retry loop costs real pages. Cap retries per document and alert when a document hits the cap. When your plan reaches its monthly page limit, parsing pauses with an Upgrade button and nothing is charged automatically.
Idempotency so a file is processed once
Duplicate processing is the quiet failure mode of document pipelines. The same invoice arrives twice by email, a worker crashes after the parse but before the database write, a queue redelivers a message.
Use three layers of protection.
- Hash the file bytes (SHA 256) before you call the API and store the hash with the result. If the hash is known, reuse the stored result instead of parsing again.
- When you submit a batch with POST /v1/batches, send an Idempotency-Key header derived from your own batch id. A retried request with the same key does not create a second job.
- Make your database write idempotent too, with a unique constraint on supplier plus invoice number, so a duplicate record is rejected even if everything upstream misbehaves.
import hashlib
def file_key(path: str) -> str:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
Webhooks for large files and batches
Small files return synchronously. Larger files and batches return a job id, and you can poll GET /v1/jobs/{id}. Polling works, but for production pipelines webhooks are cleaner: PdfParse sends parse.completed or parse.failed to your endpoint and retries with backoff if your endpoint is down.
Every webhook carries a PdfParse-Signature header, an HMAC SHA256 of the raw request body computed with your webhook secret. Verify it before you trust the payload, and compare in constant time.
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["PDFPARSE_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/pdfparse")
def pdfparse_webhook():
body = request.get_data()
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
received = request.headers.get("PdfParse-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
if event["type"] == "parse.completed":
enqueue_validation(event["data"]["id"])
elif event["type"] == "parse.failed":
record_failure(event["data"]["id"])
return "", 204
Return 2xx quickly and do the validation work in a background job. Because webhooks are retried, the handler must be idempotent as well. Store the job id and ignore events you have already handled.
Webhooks and batch jobs of up to 500 files per job are part of the Growth plan and above. See the plan table on the pricing page.
A short checklist before you ship
- The schema is versioned and every field has a description
- Money is a decimal plus a currency code, never a float
- Dates are ISO 8601 and parsed strictly
- Null means "not on the document" and each field has a rule for null
- Confidence thresholds are set per field from your own review data
- Cross field checks catch totals that do not add up
- Retries only cover transport errors, validation failures go to review
- File hashes and idempotency keys prevent double processing
- Webhook signatures are verified before the payload is used
- Retention matches your policy, see the security overview
If you also need tables as CSV or the full document as Markdown, request them in the same call. The pdf to json page shows the complete response, and the pdf data extraction page covers schema extraction for operations teams who work in the web app instead of code. Tables with merged cells and tables that span pages are covered on the pdf table extraction page.