Invoice Automation, End to End
The founding IDP workload as a runnable sequence: a multi-document scan arrives, and validated, typed records leave, with humans seeing only the documents that earned their attention. Four calls per batch plus one per document, all conventions as stated in the cookbook hub.
1Upload the batch, split it into labeled documents#
Upload the scan (files/upload, hub convention), then split with physical output, so each
detected document comes back as its own file id, ready to chain. This step is job-friendly
on large batches.
curl -s "$LMKIT_URL/lmkit/v1/document-splitting" \
-H "Authorization: Bearer $LMKIT_API_KEY" -H "Content-Type: application/json" \
-d '{
"input": "'$BATCH_FILE_ID'",
"input_format": "FileIdentifier",
"split_documents": true,
"guidance": "Each invoice or credit note is one document; remittance advice belongs with its invoice."
}'
{
"contains_multiple_documents": true,
"document_count": 3,
"confidence": 0.94,
"segments": [
{ "start_page": 1, "end_page": 2, "page_count": 2, "label": "Invoice", "file_id": "8a1c..." },
{ "start_page": 3, "end_page": 3, "page_count": 1, "label": "Credit Note", "file_id": "77e2..." },
{ "start_page": 4, "end_page": 6, "page_count": 3, "label": "Contract", "file_id": "0f9d..." }
]
}
Branch here: low batch confidence routes the whole scan to a human sorter instead of
proceeding on a bad split (Intelligent Splitting).
2Classify each segment against YOUR taxonomy#
The segment label is a useful prior; the classification is the decision. Descriptions are
the accuracy lever, and unknown stays allowed so junk pages cannot force-file
(Document Classification).
curl -s "$LMKIT_URL/lmkit/v1/categorize" \
-H "Authorization: Bearer $LMKIT_API_KEY" -H "Content-Type: application/json" \
-d '{
"input": "'$SEGMENT_FILE_ID'",
"input_format": "FileIdentifier",
"categories": ["Invoice", "CreditNote", "Contract", "Correspondence"],
"category_descriptions": [
"A bill requesting payment: line items, totals, payment terms",
"A negative adjustment referencing a prior invoice",
"A signed agreement or its annexes",
"Letters, emails, and anything that is none of the above"
],
"allow_unknown_category": true
}'
{ "categories": [ { "value": "Invoice" } ], "confidence": 0.97 }
Your code switches on the value: each class selects its own extraction schema, and an empty result (unknown) routes to a triage queue.
3Extract with the class's schema#
The invoice schema uses the schema dialect's guarantees:
grammar-enforced enums and dates, entity validation triggered by field NAMES (iban,
vat_number, total_amount), and include_elements for the per-field evidence. Job-friendly.
curl -s "$LMKIT_URL/lmkit/v1/extract-structured-data" \
-H "Authorization: Bearer $LMKIT_API_KEY" -H "Content-Type: application/json" \
-d '{
"input": "'$SEGMENT_FILE_ID'",
"input_format": "FileIdentifier",
"include_elements": true,
"jsonSchema": {
"type": "object",
"properties": {
"invoice_number": { "type": "string", "trimStart": ["INV-", "INV"] },
"issue_date": { "type": "string", "format": "date" },
"due_date": { "type": "string", "format": "date" },
"currency": { "type": "string", "enum": ["EUR", "USD", "GBP", ""] },
"vendor": {
"type": "object",
"properties": {
"name": { "type": "string" },
"vat_number": { "type": "string" },
"iban": { "type": "string" }
},
"required": ["name"]
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": { "type": "string" },
"quantity": { "type": "number" },
"unit_price": { "type": "number" }
},
"required": ["description", "quantity", "unit_price"]
}
},
"total_amount": { "type": "number", "description": "Grand total including tax." }
},
"required": ["invoice_number", "issue_date", "vendor", "total_amount"]
}
}'
{
"json": "{ \"invoice_number\": \"2024-0117\", \"issue_date\": \"2024-03-02\", ... }",
"confidence": 0.91,
"human_verification_required": true,
"elements": [
{ "path": "invoice_number", "confidence": 0.98, "validation": "not_applicable", "page_index": 0,
"bounds": { "x0": 431.2, "y0": 88.1, "x1": 512.6, "y1": 88.1, "x2": 512.6, "y2": 101.4, "x3": 431.2, "y3": 101.4 } },
{ "path": "vendor.iban", "confidence": 0.99, "validation": "repaired",
"entity_kind": "Iban", "original_value": "FR76 3000 4000 0312 3456 7890 K43", "page_index": 0, "bounds": { "x0": 60.0, "y0": 640.2, "x1": 268.9, "y1": 640.2, "x2": 268.9, "y2": 652.0, "x3": 60.0, "y3": 652.0 } },
{ "path": "due_date", "confidence": 0.44, "validation": "not_applicable",
"human_verification_required": true, "page_index": 0, "bounds": null }
],
"pages": [ { "index": 0, "width": 595.3, "height": 841.9, "unit": "pt" } ]
}
4Route on the flag, review with coordinates#
The branch that makes this a pipeline instead of a demo (Structured Extraction):
human_verification_required: false: parsejson, post the record to your system. Straight-through, no human.true: queue for review, showing ONLY the flagged elements. Render the page (the thumbnail endpoint), draw each flagged element'sbounds(normalized by ITS page's width and height frompages), and showoriginal_valuenext to the extracted value forrepairedfields. The reviewer confirms in seconds because their eye is pointed at the exact spot.
Corrections you collect here are not waste: they become the labeled sample for measurement and, at volume, the training set for fine-tuning a compact model on your own invoices.
5Production notes#
- Force async for the batch job. In production, run steps 1 and 3 with
Prefer: respond-asyncand a polling worker; the jobs contract is the loop, andprogress_current/progress_totaldrive the dashboard. - Handle the deterministic refusals. A
password_requiredorunsupported_file_typefailure is a routing decision, not a retry: send those to the same triage queue as unknown classifications. - Throughput is a setting. Segments classify and extract independently, so parallelize across the slot pool rather than serializing the batch.