Export and import
Export invoices to an Excel/CSV spreadsheet (SUMMARY or ITEMS, capped at 5000) and import clients from a CSV with a dry-run preview, column mapping, a downloadable template and partial-success.
Two file-based operations move data in and out of Factuarea over the public API: export invoices to a spreadsheet, and import clients from a CSV. Both reuse the same engines the dashboard uses, and the import follows the partial-success contract — one bad row never sinks the whole file.
Export invoices to a spreadsheet
POST /v1/invoices/export/excel (scope invoices:read) builds an XLSX or CSV
spreadsheet of your invoices and streams the binary file back. It is a read
operation: nothing is created or modified.
Two orthogonal axes control the output:
| Parameter | Values | Meaning |
|---|---|---|
format | SUMMARY (default) · ITEMS | Content layout. SUMMARY is one row per invoice; ITEMS is one row per invoice line (header columns repeated on each line). |
file_format | xlsx (default) · csv | File container. |
Pick the invoices to export in either of two ways:
- By id — pass
invoice_idswith the UUID v7 ids of specific invoices. - By filter — omit
invoice_idsand narrow the set withstatus,date_from,date_to,client_id,series_idandsearch.date_fromanddate_tofilter the issue date (inclusive);client_idandseries_idtake the public UUID v7 of the client/series.
curl -s -X POST https://api.factuarea.com/v1/invoices/export/excel \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"format": "ITEMS",
"file_format": "xlsx",
"status": "paid",
"date_from": "2026-01-01",
"date_to": "2026-03-31"
}' \
-o invoices-q1.xlsxThe 5000-invoice limit
The selected set is capped at 5000 invoices. If your filters match more
than that, the API does not truncate silently — it returns 422 with the
export_limit_exceeded error code:
{
"error": {
"type": "invalid_request_error",
"code": "export_limit_exceeded",
"message": "La exportación supera el máximo de 5000 facturas."
}
}Narrow the date range, the status or the client, or split the export into several calls, so each request stays under the limit.
client_id, series_id and the invoice_ids entries are matched inside
your company. A UUID that does not exist or belongs to another company is
simply dropped from the selection — it never leaks cross-tenant data and never
returns a global 404.
Import clients from a CSV
POST /v1/clients/import (scope clients:write) reads a delimited file and
creates one client per valid row. The request is multipart/form-data — it
carries a file, not a JSON body — with three fields:
| Field | Type | Meaning |
|---|---|---|
file | file | The CSV/XLSX/XLS/ODS/TXT file, up to 10 MB. |
mapping | object | { "csv_header": "target_field" }. Must map at least name and tax_id. |
dry_run | boolean | When true, validate and preview without creating anything. Default false. |
The mapping tells the importer which spreadsheet column feeds which client
field. The target set must include name and tax_id — without them a
client cannot be created, and the request is rejected with 422 before any row
is processed.
Download the template
GET /v1/clients/import/template streams a ready-to-fill CSV (UTF-8 with BOM so
Excel opens it cleanly) whose header row lists every column the importer
understands: Nombre, NIF/CIF, Razón social, Email, Teléfono, address
fields, default VAT/retention, IBAN and more. Two example rows show the
expected format.
curl -s https://api.factuarea.com/v1/clients/import/template \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-o clients-template.csvDry run first, then import
Always validate with dry_run=true before you commit. The preview returns a
per-row report and writes nothing:
curl -s -X POST https://api.factuarea.com/v1/clients/import \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-F "file=@clients.csv" \
-F 'mapping={"Nombre":"name","NIF/CIF":"tax_id","Email":"email"};type=application/json' \
-F "dry_run=true"{
"data": {
"object": "client_import_preview",
"total_rows": 3,
"rows": [
{ "row": 2, "status": "valid", "errors": [], "warnings": [] },
{
"row": 3,
"status": "error",
"errors": [{ "param": "tax_id", "code": "invalid_tax_id", "message": "El NIF no es válido." }],
"warnings": []
},
{ "row": 4, "status": "valid", "errors": [], "warnings": [] }
]
}
}row is the 1-based line number in the file (the header is row 1, so the
first data row is row 2). status is valid or error; each errors[] item
carries the offending param, a stable code and a Spanish message.
When the preview is clean, resend the same file and mapping with
dry_run=false (or omit it). Only valid rows are created; rejected rows come
back in failures[], and the response follows the partial-success shape with a
per-row results[]:
{
"data": {
"total": 3,
"successful": 2,
"failed": 1,
"failures": [
{
"index": 1,
"error_code": "invalid_tax_id",
"error_message": "El NIF no es válido.",
"errors": [{ "param": "tax_id", "code": "invalid_tax_id", "message": "El NIF no es válido." }],
"warnings": []
}
],
"results": [
{ "row": 3, "status": "error", "errors": [{ "param": "tax_id", "code": "invalid_tax_id", "message": "El NIF no es válido." }], "warnings": [] }
]
}
}total === successful + failed always holds. A duplicate row (a client
already on file, by the dedup rule) is skipped, not failed — it counts
towards successful and is not created again, so re-running the same file is
safe.
Branch on error_code / code, never on the message — the message is
Spanish, human-facing text. The per-row codes come from the v1 error catalog.
File-size limit
The v1 import is synchronous so it can return the per-row result in the same
response. Files are capped at fewer than 200 rows; a larger file is rejected
with 422 and the client_import_too_large code. Split a big list into batches
under that limit and import them in sequence.
The file (10 MB) and dry_run rejections, and the 5000/200 caps, are
enforced before any row is written. The dry-run preview is the cheapest way to
catch malformed rows — use it before every real import.