Ironclad-OCR is an asynchronous invoice ingestion and extraction service built for vendor PDFs that do not share a single stable layout. It combines:
FastAPIfor ingestion and status APIsRedis Streamsfor durable background job dispatchPostgres / Supabasefor schema registry and job stateLangGraphfor orchestrationOpenRoutervia theopenaiPython SDK for vendor identification, schema discovery, and extraction- deterministic ERP reconciliation for supply-chain workflows
The system is designed around a human-in-the-loop schema onboarding model:
- A PDF is uploaded.
- The worker identifies the vendor and tries to match an existing layout.
- If the layout is known, extraction runs immediately.
- If the layout is unknown, the system proposes a schema and pauses for approval.
- After approval, the job is requeued and processed with the stored schema.
- Accepts PDF uploads through an API.
- Splits merged invoice packets into per-invoice jobs.
- Stores processing state in Postgres.
- Uses fuzzy header matching to reuse the best known vendor schema.
- Falls back to schema discovery for unseen layouts.
- Extracts structured JSON from PDFs.
- Optionally runs 3-way matching against ERP purchase order and goods receipt data.
- Delivers final results to an outbound webhook.
- Recovers Redis pending jobs after worker crashes using
XAUTOCLAIM.
POST /ingest
- Accepts one PDF upload.
- Saves the file to
data/uploads/. - Runs PDF splitting to detect separate invoices inside a packet.
- Creates one
processing_jobsrow per split PDF. - Enqueues one Redis Stream message per job.
src/worker/worker.py
- Reads new jobs from Redis Streams with consumer groups.
- Periodically claims stuck pending messages older than 10 minutes using
XAUTOCLAIM. - Runs the LangGraph workflow.
- Acknowledges successful and fatal jobs.
- Leaves transient failures unacked so they can be reclaimed later.
src/core/graph.py
fingerprint_and_lookupidentifies vendor and header text.- A normalized fuzzy match compares the current header to known layouts stored in
document_registry. - If a layout match is strong enough, extraction proceeds.
- Otherwise,
discovery_agentproposes a new schema and the job moves toWAITING_HUMAN.
POST /approve
- Stores the approved schema in the registry.
- Marks the original job as
PENDING. - Requeues the job for extraction.
- Extraction runs against the approved vendor schema.
- The worker attempts ERP reconciliation using
po_numberororder_reference. - Final payload is persisted and sent to the configured webhook.
- Webhook failures are recorded as
DELIVERY_FAILEDin the job table.
src/api/app.py: FastAPI app entrypointsrc/api/routes.py: ingest, status, approval, health endpoints
src/core/graph.py: LangGraph orchestrationsrc/core/nodes.py: LLM-backed vendor ID, schema discovery, extraction helperssrc/core/pdf_splitter.py: packet splitting logicsrc/core/state.py: graph state contract
src/infrastructure/redis_queue.py: Redis Streams wrapper with pending recoverysrc/infrastructure/supabase_repos.py: asyncpg-backed repositories with connection poolingsrc/infrastructure/webhook_client.py: outbound webhook delivery and error persistence
src/plugins/supply_chain.py: deterministic 3-way match logic
sql/001_registry_jobs.sql: base tablessql/002_add_multi_layout_support.sql: multi-layout migrationsql/003_erp_tables.sql: ERP reconciliation tables and seed data
The processing_jobs.status field currently uses these values in practice:
PENDING: queued for worker processingPROCESSING: actively being processed by the workerWAITING_HUMAN: awaiting schema approvalCOMPLETED: extraction finished successfullyFAILED: fatal processing failureDELIVERY_FAILED: extraction completed, but webhook delivery failed
- Python
3.11+ - Redis
6.2+ - PostgreSQL-compatible database or Supabase Postgres
- OpenRouter API key
Python dependencies are declared in requirements.txt:
fastapiuvicornredisasyncpglanggraphopenaipydanticpypdfaiofilespython-multipartpython-dotenvhttpxrich
Environment is loaded from .env by src/config.py.
| Variable | Required | Default | Purpose |
|---|---|---|---|
OPENROUTER_API_KEY |
Yes | "" |
API key used for LLM calls |
MODEL_NAME |
No | google/gemini-2.0-flash |
OpenRouter model name |
TEMPERATURE |
No | 0.1 |
Model temperature |
DRIFT_THRESHOLD |
No | 0.8 |
Reserved threshold config |
DATABASE_URL |
Yes for ingest/worker | none | Postgres connection string |
REDIS_URL |
No | redis://localhost:6379/0 |
Redis connection string |
WEBHOOK_URL |
No | none | Final result delivery target |
DATA_DIR |
No | ./data |
Base directory for uploads and output |
LOG_LEVEL |
No | INFO |
Logging verbosity |
OPENROUTER_API_KEY=your_openrouter_key
MODEL_NAME=google/gemini-2.0-flash
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ironclad
REDIS_URL=redis://localhost:6379/0
WEBHOOK_URL=http://localhost:9000/webhook
DATA_DIR=./data
LOG_LEVEL=INFORun the SQL files in this order:
sql/001_registry_jobs.sqlsql/002_add_multi_layout_support.sqlsql/003_erp_tables.sql
document_registry
- stores vendor layout schemas
- supports multiple layouts per vendor through
(vendor_name, fingerprint_hash)uniqueness
processing_jobs
- stores file path, vendor, status, extracted payload, and errors
erp_purchase_orders, erp_po_lines, erp_goods_receipts
- support deterministic reconciliation
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txtOptional dev dependencies:
pip install -r requirements-dev.txtIf Redis is already installed locally:
redis-serverOr via Docker Compose:
docker compose up -d redis- create a Postgres database
- run the SQL files under
sql/ - set
DATABASE_URL
uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reloadpython -m src.worker.workerThe API and worker are separate processes and both must be running for end-to-end processing.
The repository includes docker-compose.yml with:
redisapiworker
Bring everything up:
docker compose up --buildNotes:
DATABASE_URLmust still point to a reachable Postgres database.- The compose file still includes a legacy
GOOGLE_API_KEYenvironment variable, but the current codepath usesOPENROUTER_API_KEY.
Uploads a PDF and returns one job ID per split invoice.
Request:
- multipart form-data
- field name:
file
Example:
curl -X POST "http://localhost:8000/ingest" \
-F "file=@invoice.pdf"Response:
{
"job_ids": [
"2f26c8f2-5f7d-4134-94c7-dedbde1b5c8e"
]
}Fetches the current database record for a job.
Example:
curl "http://localhost:8000/jobs/2f26c8f2-5f7d-4134-94c7-dedbde1b5c8e"Approves a proposed schema and requeues the job.
Example:
curl -X POST "http://localhost:8000/approve" \
-H "Content-Type: application/json" \
-d '{
"job_id": "2f26c8f2-5f7d-4134-94c7-dedbde1b5c8e",
"vendor_name": "LABEL_TECH",
"schema_definition": {
"vendor_name": "LABEL_TECH",
"version": 1,
"fields": [
{
"key": "invoice_number",
"type": "str",
"description": "Invoice identifier"
},
{
"key": "invoice_date",
"type": "date",
"description": "Invoice date"
},
{
"key": "line_items",
"type": "list",
"description": "Extracted line items"
}
]
}
}'Returns a Redis and database health report.
Example response:
{
"status": "ok",
"redis": { "ok": true },
"supabase": { "ok": true }
}If the database is unavailable or missing tables, the endpoint returns degraded details.
src/core/pdf_splitter.py uses heuristics to detect new invoice boundaries in merged PDFs by scanning page text for invoice-like headers. If a PDF appears to contain multiple invoices, each detected chunk becomes its own job.
This allows a single uploaded packet to fan out into multiple independently tracked jobs.
Schemas stored in document_registry.schema_definition follow the RegistrySchema contract from src/schemas.py:
{
"vendor_name": "LABEL_TECH",
"version": 1,
"fields": [
{
"key": "invoice_number",
"type": "str",
"description": "Invoice identifier"
}
]
}Supported field types currently are:
strfloatdatelist
list fields are mapped to line-item extraction using the LineItem schema.
src/plugins/supply_chain.py performs deterministic invoice-to-ERP checks:
- invoice vs purchase order price
- invoice vs goods receipt quantity
- unauthorized item detection
- missing receipt detection
The audit result is added to audit_report in the final payload.
If no PO can be derived, reconciliation is skipped and the payload still proceeds to webhook delivery.
The worker uses Redis consumer groups and periodically reclaims pending messages older than 10 minutes with XAUTOCLAIM.
This protects the pipeline from jobs being stuck forever when a worker crashes after reading a message but before acknowledging it.
Repositories use a shared asyncpg connection pool instead of creating a new connection on each call.
If the webhook responds with a non-2xx status or the request fails at the transport layer:
- the error is logged with
job_id - the job status becomes
DELIVERY_FAILED - the payload remains stored in
processing_jobs.extracted_data
Run tests with:
pytestFocused examples:
pytest tests/test_fingerprint.py -q
pytest tests/test_schema_json_schema.py -q- The current API has no authentication or authorization layer.
- Place the service behind a trusted network boundary or gateway before exposing it externally.
- Uploaded files are stored on the local filesystem under
data/uploads/. WEBHOOK_URLis optional. If unset, the pipeline will skip outbound delivery.output_diris created automatically by configuration, though current processing primarily usesuploads_dir.
POST /approvecurrently acceptsschema_definitionas a genericdictinstead of validating it as a strictRegistrySchemaat the API boundary.- The health endpoint still opens a direct
asyncpg.connect(...)instead of reusing the shared pool. - The compose file includes legacy environment wiring that should be cleaned up.
src/
api/
core/
infrastructure/
plugins/
worker/
sql/
tests/
data/
No license file is currently included in the repository.