Document scanner
Upload supplier invoices and receipts, review extracted data and create linked expense drafts.
The scanner keeps the original, extraction evidence, review history and expense link together. It creates expense drafts backed by supplier invoices or qualifying simplified invoices; it does not issue invoices or register payment. Review fiscal data before conversion. An unknown tax rate is not a verified zero.
Access and upload
The company must have scanner/OCR access. Use purchase_invoices:read to consult, purchase_invoices:write to upload, review and convert, and purchase_invoices:delete to archive. The linked company remains the authorization boundary for API keys, OAuth and MCP.
Upload up to 20 PDF, JPEG or PNG files: 20 MiB per file, 100 MiB per request, 25 pages per document (POST /v1/purchase_scans). A request body over the edge limit is rejected before it reaches the app with 413 payload_too_large. Within an accepted body, an oversized or excess file returns an app-level 413: scan_file_too_large, scan_batch_too_large or scan_page_limit_exceeded. Files undergo safety validation and encrypted storage. A 202 response contains data.accepted and data.rejected; it means admission, not completed OCR. If every file is rejected, the 422 response retains the batch result. Check each item. Retry the same batch with the same file order, payload and Idempotency-Key.
curl https://api.factuarea.com/v1/purchase_scans \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Idempotency-Key: scanner-upload-batch-001" \
-F "files[]=@invoice.pdf" \
-F "files[]=@receipt.jpg"Always start with a fact_test_ API key: the sandbox runs the same contract with a deterministic simulated extraction, no OCR provider and no quota consumption. Switch to fact_live_ only once the flow is validated.
Processing and review
Read GET /v1/purchase_scans/{id}. received waits for processing; queued and processing are in progress. When automatic scanning is off, explicitly start a received document with POST /v1/purchase_scans/{id}/retry. needs_review exposes extracted fields, confidence, page/bounding-box evidence, issues and review_fields (the catalog of field names still open for review). failed may be retried only when recoverable. duplicate requires a decision. Follow available_actions and capability flags rather than guessing from status alone. Archive a scan that is not being processed with DELETE /v1/purchase_scans/{id}; it is reversible with POST /v1/purchase_scans/{id}/restore — check available_actions again afterwards, since restoring does not recover a purged original.
Use PUT /v1/purchase_scans/{id}/review with the observed expected_version. Send only reviewed corrections: omitted fields and lines remain unchanged; { "value": null } clears a field. Public identifiers are UUID v7 in id, supplier_id and line_id. Decimal values are strings. A line patch uses update, add without line_id (the server assigns it), or remove with an existing line_id.
Purchase evidence and catalog selection
Each line can keep up to five purchase fields exactly as printed on the original: supplier_sku, unit_code, price_unit_code, package_quantity and measured_base_quantity. They are optional and never inferred — a missing value is null without an issue and never blocks conversion, and package_quantity/measured_base_quantity never turn into billed quantity or a unit conversion on their own.
lines[].catalog_selection is a separate, human-only decision: the OCR never writes it. In the review patch, omitting catalog_selection keeps the saved selection, null unlinks the line (its purchase evidence is untouched), and an object replaces it. Only new or changed references are checked against the requesting company's catalog when saving; a product, variant, presentation or supplier offer from another company — or one that does not exist — responds 422 without saving anything or changing the version:
// PUT /v1/purchase_scans/{id}/review with a product from another company
{"expected_version": 7, "lines": [{"line_id": "019c...", "fields": {}, "catalog_selection": {"product_id": "<product from another company>"}}]}
// → 422, error.code = "validation_failed", error.param = "lines.0.catalog_selection.product_id"A deactivated reference can still be linked when saving. Conversion re-resolves the selection against the draft's company; if it is no longer valid it rejects with 422 and does not create the invoice — the scan stays reviewable to fix or unlink the line.
Conversion and conflicts
After saving, use the new version for POST /v1/purchase_scans/{id}/convert. Conversion atomically creates one expense draft and links its original — it is irreversible, and repeating it on an already-converted scan returns 409 purchase_scan_already_converted. Read purchase_invoice_id to continue in Expenses. EUR and resolved fiscal data are required; simplified expenses and full supplier invoices follow their respective validation rules. The public API and MCP require an existing supplier where one is mandatory. Supplier creation and duplicate override require an authorized interactive user in the app. external_id is yours to set on other resources, except the purchase-scan: prefix, which is reserved for the internal bridge and rejected with 422 parameter_invalid_value if you send it.
A stale version returns 409 stale_scan_version: reload and reconcile changes before trying again. A 422 conversion error exposes the fields to correct in error.details.field_errors. Reuse the idempotency key only for the same mutation; after changing the review, use a new key. The scanner tells an exact duplicate (same file) apart from a fiscal duplicate (same supplier and document number); public duplicate resolution (POST /v1/purchase_scans/{id}/duplicate_resolution, irreversible) supports link_existing with purchase_invoice_id or archive — the v1 API does not authorize overriding an exact duplicate; that override needs an authorized interactive user in the app. Archiving is reversible; restoring does not recover an original already purged under retention.
Originals, email and clients
Download retained originals using GET /v1/purchase_scans/{id}/source as binary data; it requires the export capability on the scan, or 403 forbidden_action. An API key whose creator is no longer a member of the company is rejected the same way, on this download and on every write.
GET /v1/purchase_scan_emails lists sender, subject and accepted/rejected attachments; accepted entries link to scan identifiers. The app configures the scanner mailbox: an allowlist of sender addresses or domains, and SPF/DKIM/DMARC verification — only DMARC pass admits ingestion, everything else is parked or rejected with a reason, and the raw verdict is published as sender_authentication. Disabled or unconfigured receiving is displayed explicitly. Every admitted email stays in needs_review: the mailbox never auto-creates a supplier or a draft, even for an allowlisted sender.
The TypeScript SDK exposes purchaseScans and purchaseScanEmails; binary responses provide toBuffer(). The CLI accepts repeatable --file-files flags for a batch. Both preserve per-file errors and support explicit idempotency keys. Consult the SDK, CLI and MCP catalog.
MCP uses the same review, version and conversion contract. Upload and binary download remain REST operations. In the web assistant, scanner tools read data and navigate to human review. Linked WhatsApp photos/PDFs enter this same persistent scanner; the channel can propose a versioned draft conversion for explicit confirmation, or return a review link when processing or corrections remain pending.
Read GET /v1/purchase_invoices/expense_categories (or MCP list_purchase_invoice_expense_categories) and use a returned id for expense_category. Categories belong to the authenticated company; a name or another company's ID is rejected.
Monthly and daily quota
Empresario includes 100 scans per month and 10 per day per company; Enterprise has no plan scan limit. Both quotas are shared by all company users. A saved scan with deferred_reason=ocr_daily_quota_reached and deferred_until is waiting for the daily quota reset. Processing resumes automatically: keep its ID and do not upload it again or repeatedly call retry.
Detail responses cap attempts and audit at the latest 50 entries; use attempts_total and audit_total for the full counts.
OCR usage is metered monthly, per company, shared by every channel of the scanner (app, v1, MCP, mailbox) and the WhatsApp assistant. Once exhausted, starting a new extraction (upload or retry) responds 429 ocr_company_quota_exceeded with Retry-After counting the seconds until the quota resets on the 1st of the following month. A file already admitted and queued is not rejected retroactively.
Filters and stats
GET /v1/purchase_scans filters combine with AND across filters and OR within a filter's comma-separated values: filter[document_kind] (invoice, simplified_qualified, ticket, delivery_note, other, undetermined), filter[file_kind] (pdf/image), filter[supplier_link_state], filter[has_issues] (over issue_count > 0), filter[sender], filter[source] and filter[supplier_id] (up to 20 values each). Every reference is checked against your company before the query runs; one that does not belong to it responds 422 without revealing whether it exists elsewhere:
GET /v1/purchase_scans?filter[document_kind]=ticket,invoice&filter[has_issues]=true&filter[sender]=@supplier.exampleissue_count on each row is the count of fields still pending review — the same number the review screen would show on open, without edits — not the raw count of issue codes on the scan. uploaded_by is SPA-only and excluded from v1 and MCP, which only publish uploaded_by.name.
GET /v1/purchase_scans/stats takes scope=inbox (default) or scope=history and returns counts by status, source, document kind, file kind, supplier link state and issues under facets.