How to extract invoice data from PDFs into structured JSON
DEV Community

How to extract invoice data from PDFs into structured JSON

Invoice PDFs are structurally unpredictable. Vendor A ships a five-column line-item table, vendor B embeds the same information in a paragraph block, and half the scanned copies in a legacy archive have no text layer at all. Generic text extraction reads characters in the order they were written to the file rather than the reading order a human sees, so field values drift with every layout variation. The Foxit PDF Structural Extraction API addresses this directly. You submit a PDF invoice and receive a typed, hierarchical JSON document with named element types, bounding regions, and an addressable table cell grid. This guide covers the four REST calls that turn a raw PDF into a StructureInfo.json file, the Python post-processing that maps that output onto a clean invoice schema, and the edge cases your pipeline will hit on real vendor documents. Every response shape and field name below comes from a live run against the API, not from the reference docs alone. Why invoice PDFs break generic parsers Three structural problems cause most invoice parsing failures, and OCR accuracy is only one of them. The first is layout variance across vendors. A PDF's text layer records characters in the order they were drawn, which often follows vector rendering order rather than left-to-right, top-to-bottom reading order. Extract raw text from a five-column line-item table and you frequently get interleaved fragments, where description text from one column mixes with unit prices from another because the writer rendered all rows of one column before moving to the next. No string-parsing logic reliably recovers column boundaries from that flattened sequence. The second is merged and multi-row cells. Line-item tables routinely span cells across rows for items with multi-line descriptions. Text extraction collapses those cell boundaries into a flat string and drops the row-to-total relationship an accounting system needs. The third is rasterized scans with no text layer. A PDF created by scanning a paper invoice contains only an embedded image, so anything that reads the text layer alone comes back empty. Adobe's own Acrobat documentation puts it plainly, noting that a scanned file "contains only image data, not searchable text." Tools built for scanned input bundle an OCR step rather than skipping it, and any pipeline you build has to do the same before extraction can happen. Structure-aware extraction addresses all three by classifying document regions into typed elements before exposing their content. Invoice fields to target before touching the API Define the target schema before writing code. A concrete target tells you which elements to read from StructureInfo.json and which to skip, which saves iteration time on every invoice you process. A workable invoice schema covers three groups: - Header fields, including vendor name, invoice number, invoice date, due date, and payment terms - Line items, a repeating array of description, quantity, unit price, and line total - Footer totals, including subtotal, tax amount, and total amount due { "vendor_name": "", "invoice_number": "", "invoice_date": "", "due_date": "", "payment_terms": "", "line_items": [ { "description": "", "quantity": "", "unit_price": "", "line_total": "" } ], "subtotal": "", "tax": "", "total_due": "" } Keep every value a string at extraction time. Type conversion, currency parsing, and date normalization belong downstream, after validation, where a bad value can be rejected with context instead of raising inside the parser. The sample invoice used throughout this guide is invoice_full_test.pdf , so you can run every call below against the same document. The input document. Notice that the Subtotal, Tax Rate, Tax Amount, and Total Due labels sit in the second-to-last column rather than the first. That detail determines how the post-processing code has to find them. Prerequisites You need the following before the first API call: - Python 3.9 or newer and pip - A virtual environment via venv, so the dependency below stays isolated - The requests library for HTTP calls - A code editor such as VS Code with the Python extension - A free Foxit developer account Scaffold the workspace in one shot: mkdir invoice-extraction && cd invoice-extraction && python3 -m venv .venv && source .venv/bin/activate && pip install requests Then download the sample invoice into that folder: curl -L -o invoice_full_test.pdf https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_full_test.pdf Foxit API authentication and setup Signing up activates a free Developer plan that includes 500 credits per year with no credit card required. A structural extraction call costs one credit, while the upload, polling, and download calls are not billed, so a full run of the workflow below costs a single credit. The account creation screen. The free Developer plan is enough to work through this entire guide. Foxit authenticates PDF Services requests with a client ID and client secret passed as HTTP headers, so there is no OAuth token exchange to implement. Both values come from the default application created in your Developer Portal dashboard, alongside the base URL your calls need. The credentials panel. Copy the Client ID and Client Secret into environment variables rather than pasting them into source files. Export them into your shell so no credential is ever committed: export FOXIT_CLIENT_ID="your_client_id" export FOXIT_CLIENT_SECRET="your_client_secret" The structural extraction reference page carries a Test Request button that fires live calls straight from the browser, which is the quickest way to confirm your credentials work before writing any Python. The endpoint is currently labelled Trial in the reference, so expect its surface to evolve. Pin your parser to the version field inside the analyzeResult response. The current schema ships as 1.0.7 , and pinning prevents silent breakage if that changes. The four-step PDF to JSON invoice extraction workflow The API is asynchronous. You upload a document, start a task, poll until the task completes, then download the result. All four paths sit under https://na1.fusion.foxit.com/pdf-services . Calling them without that prefix returns 404. The path prefix matters more than it looks. The four endpoints live under /pdf-services/api/... , and requesting a bare /documents/{id}/download returns 404 rather than a helpful error. import io import json import os import time import zipfile import requests BASE_URL = "https://na1.fusion.foxit.com/pdf-services" HEADERS = { "client_id": os.environ["FOXIT_CLIENT_ID"], "client_secret": os.environ["FOXIT_CLIENT_SECRET"], } POLL_SECONDS = 2 POLL_TIMEOUT = 120 def extract_structure(pdf_path: str) -> dict: # Step 1: upload the PDF (multipart/form-data, 100 MB maximum) with open(pdf_path, "rb") as handle: upload = requests.post( f"{BASE_URL}/api/documents/upload", headers=HEADERS, files={"file": (os.path.basename(pdf_path), handle, "application/pdf")}, ) upload.raise_for_status() document_id = upload.json()["documentId"] # Step 2: start the structural extraction task started = requests.post( f"{BASE_URL}/api/documents/pdf-structural-extract", headers=HEADERS, json={"documentId": document_id}, ) started.raise_for_status() task_id = started.json()["taskId"] # Step 3: poll until COMPLETED, bounded, and handle FAILED deadline = time.monotonic() + POLL_TIMEOUT while True: response = requests.get(f"{BASE_URL}/api/tasks/{task_id}", headers=HEADERS) response.raise_for_status() task = response.json() if task["status"] == "COMPLETED": break if task["status"] == "FAILED": raise RuntimeError(f"extraction task {task_id} FAILED: {task}") if time.monotonic() > deadline: raise TimeoutError(f"task {task_id} stuck at {task['status']}") time.sleep(POLL_SECONDS) # Step 4: download the result ZIP and read StructureInfo.json result = requests.get( f"{BASE_URL}/api/documents/{task['resultDocumentId']}/download", headers=HEADERS, ) result.raise_for_status() with zipfile.ZipFile(io.BytesIO(result.content)) as archive: return json.loads(archive.read("StructureInfo.json")) In this code, you read both credentials from the environment, upload the PDF as multipart form data and capture the returned documentId , hand that id to the extraction endpoint to receive a taskId , then poll the task endpoint every two seconds. The loop is bounded by a deadline and checks explicitly for FAILED , so a rejected document raises instead of spinning forever. Once the status reads COMPLETED , the task payload carries a resultDocumentId , which you exchange for a ZIP archive and read StructureInfo.json out of in memory. Status values are uppercase (PENDING , IN_PROGRESS , COMPLETED , FAILED ), and there is no synchronous variant of this endpoint. The download returns application/zip containing StructureInfo.json plus one PNG for each detected table region. A real run against the sample invoice. The task reports IN_PROGRESS at 20 percent before reaching COMPLETED , and the final object carries every field from the schema defined earlier. How to map raw output to a clean invoice schema Choosing an invoice data extraction API is only half the work. The other half is mapping whatever it returns onto a schema your backend already understands, and that mapping is where the shape of the response starts to matter. StructureInfo.json wraps everything in an analyzeResult object with four top-level keys, version , pages , info , and elements . The elements array is where the work happens. Each element carries a type drawn from twelve values, including paragraph , table , title , image , form , and formula , along with its bounding region and content. Two details in that structure cause most of the bugs in a first implementation, and neither is obvious from the field names. The actual response shape from a live extraction. A table's cells are nested at content.body.cells , cell text sits at paragraph.content.text , and region.boundingBox is an eight-number po

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.