Invoices to Excel
Extract number, seller, buyer, lines and VAT from PDF or photographed invoices, validate totals and tax IDs and download Excel per invoice or batch.
Typing invoices into a spreadsheet by hand is slow and error-prone. With the invoice type Constaia extracts the
header, the lines and the VAT breakdown, checks that the totals add up and that Spanish tax IDs (NIF/CIF) have a
correct check character, and gives you an XLSX ready to open.
Options
{
"expect": "invoice",
"export": ["xlsx"],
"metadata": { "supplier_id": "88" }
}expect: "invoice": if another recognised type arrives (a payment receipt, for example), the verdict isinvalidwithtype_mismatch; if the document isn't recognised,reviewwithtype_unknown.export: ["xlsx"]: the response includes a signed URL inexports.xlsxto download the Excel file, valid for 24 hours. You can also ask forcsv,json,xmlorpdf. See Exports.checks.expected_amount(optional): compares with the invoicetotal. Useful if you already know the amount.
Fields and validations
| Field | Example (test file) |
|---|---|
number, issue_date | 20260042, 2026-09-01 |
seller { name, tax_id, address } | Add On Sport S.L., B12345674 |
buyer { name, tax_id, address } | Club Deportivo Arco Madrid, G12345674 |
lines[] { description, quantity, unit_price, discount_percent, amount, vat_rate } | Licencia anual, 2 × 50, VAT 21 |
tax_base | 100 |
vat[] { rate, base, amount } | 21 % on 100 = 21 |
withholding | withholding tax, if any |
total, currency | 121, EUR |
Deterministic validations reported in checks[]:
code | What it checks |
|---|---|
invoice_totals | That lines, tax base, VAT, withholding and total add up. If not, the message says which amount doesn't match. |
nif_check_digit | The check character of each Spanish NIF/CIF (seller and buyer). |
If one fails, the verdict becomes invalid with a reason of the same code and severity error. For invoices,
invalid because of invoice_totals is usually a misread or a badly made invoice: review it before booking it.
One invoice, one Excel
import { writeFile } from "node:fs/promises";
import { Constaia, ConstaiaError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
try {
const analysis = await constaia.analyze(await fromPath("./invoice.pdf"), {
expect: "invoice",
export: ["xlsx"],
language: "en",
metadata: { supplier_id: "88" },
});
console.log(analysis.verdict?.status, analysis.fields.total?.value, analysis.fields.currency?.value);
for (const check of analysis.checks) console.log(check.code, check.passed, check.message);
// Option A: the signed URL in the response (expires in 24 h, no key needed)
const signed = await fetch(analysis.exports.xlsx);
await writeFile("./invoice.xlsx", Buffer.from(await signed.arrayBuffer()));
// Option B: generate it whenever you want with the key (GET /v1/analyses/{id}/export?format=xlsx)
const res = await constaia.analyses.export(analysis.id, "xlsx");
await writeFile("./invoice-2.xlsx", Buffer.from(await res.arrayBuffer()));
} catch (err) {
if (err instanceof ConstaiaError) console.error(err.code, err.message, err.requestId);
else throw err;
}GET /v1/analyses/{id}/export generates the file on the fly, so it works even after the signed URL has expired. The
analysis must be finished (otherwise 409 analysis_not_completed) and its results must be kept: with
keep_results: false or after deleting it, it returns 404.
What the Excel looks like
One row per document. The first columns are common (id, file, type, label, confidence, verdict, reasons, warnings,
date; headers are currently in Spanish), then one column per extracted field and finally one per metadata key
(metadata.supplier_id). Compound fields such as seller, lines or vat are written as JSON in their cell.
If you need one row per invoice line, build your own sheet from fields.lines.value in your code.
Many invoices, one Excel
With a batch, options.export applies to the whole batch: when it finishes, the batch's
exports.xlsx is a combined Excel with one row per invoice. Batches don't generate per-document exports.
curl https://api.constaia.com/v1/batches \
-H "Authorization: Bearer $CONSTAIA_API_KEY" \
-F "files[]=@invoices/invoice_001.pdf" \
-F "files[]=@invoices/invoice_002.pdf" \
-F "files[]=@invoices/invoice_003.pdf" \
-F 'options={"expect":"invoice","export":["xlsx"],"metadata":{"month":"2026-09"}}'The response is 202 with the batch processing. When it finishes you get the batch.completed webhook with
exports.xlsx; you can also call GET /v1/batches/{id}. The full flow (webhook, more than 100 invoices, retries) is in
Bulk processing with batches.
Mixed inbox: classify first
If invoices arrive mixed with other documents, you can classify first (0.2 credits) and only analyze what is an invoice (1 credit per 2 pages):
import { Constaia } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia();
const file = await fromPath("./attachment.pdf");
const classification = await constaia.classify(file, { expect: "invoice" });
if (classification.document?.type === "invoice") {
const analysis = await constaia.analyze(file, { expect: "invoice", export: ["xlsx"] });
}If nearly everything is an invoice, classifying isn't worth it: analyze directly with expect: "invoice" and discard
the ones with type_mismatch or type_unknown. More in Which endpoint to use.
Your own fields: extract with JSON Schema
If you need data the invoice type doesn't extract (purchase order, due date, payment IBAN), pass your own JSON Schema
in extract. The response contains only the fields in your schema, so also include the standard ones you want to
keep.
{
"expect": "invoice",
"export": ["xlsx"],
"extract": {
"type": "object",
"properties": {
"number": { "type": "string" },
"issue_date": { "type": "string", "description": "Issue date, YYYY-MM-DD" },
"due_date": { "type": "string", "description": "Due date, YYYY-MM-DD" },
"purchase_order": { "type": "string", "description": "Customer purchase order number" },
"payment_iban": { "type": "string", "description": "IBAN to pay the invoice to" },
"tax_base": { "type": "number" },
"vat": { "type": "array", "items": { "type": "object", "properties": {
"rate": { "type": "number" }, "base": { "type": "number" }, "amount": { "type": "number" } } } },
"total": { "type": "number" },
"currency": { "type": "string" }
}
}
}Deterministic validations only run on fields that exist: if you drop tax_base, vat or total, invoice_totals
can't check the amounts. Use description to explain the format you expect.
Test it
With a ck_test_… key, a PDF named invoice.pdf (or containing invoice or factura) returns invoice 20260042
for 2 licences at 50 €, tax base 100, VAT 21 % and total 121 EUR, with invoice_totals and both nif_check_digit
passed. More in Test mode.
Next steps
Reconcile bank-transfer receipts
Check that each bank-transfer receipt has the amount, your account's IBAN and the registration reference, one by one or in batches.
Bulk processing with batches
Process hundreds or thousands of documents with batches of up to 100, signed webhooks, idempotency keys, retries, rate limits and a combined export.