Importing data
Bring an existing customer base into a company from a CSV or a Holded export, rehearse it first, and create customers or products in bulk from JSON.
There are two ways to load customers into a company:
- From a file, as a person would: a CSV built on BeeL.'s template, or the contacts
.xlsxexported from Holded. Rehearse with…/customers/imports/preview, then run the import with…/customers/imports. - From JSON, as an integration would:
…/customers/bulkand…/products/bulk.
Each file row gets its own result. A bad row is reported and skipped. It doesn't stop the rest of the file.
Import from a file
Download the template
curl https://app.beel.es/api/v1/templates/customer-import \
-H "Authorization: Bearer $BEEL_API_KEY" \
-o template_clientes_beel.csvThe template is a UTF-8 CSV with a byte order mark, so Excel opens it with the accents intact. Columns are separated by semicolons. It has these columns and four example rows:
nombre_fiscal;nif;email;telefono;direccion_calle;direccion_numero;direccion_codigo_postal;direccion_poblacion;direccion_provincia
García, López y Asociados SL;B12345674;contacto@garcialopez.es;915551234;Calle Gran Vía;45;28013;Madrid;MadridThe example rows show the format; they are not all importable. Previewing the unchanged
template is a quick first test, but expect one of its rows, whose tax ID is not in the AEAT
census, to come back as NIF_INVALID.
- All nine columns must be in the header row. If any are missing, the whole file is
rejected with
400MISSING_HEADERS.error.details.missing_headerslists every missing column andfound_headerslists what the file had. - English headers work too. Each column is also accepted under an English name:
legal_name,phone,address_street,address_number,address_postal_code,address_cityandaddress_province.nifandemailare the same in both languages. The template itself uses the Spanish names. - Separator: semicolon, comma or tab. It is detected from the header row.
The template is the same for every account and every company, so it takes no company_id.
Preview before importing
The preview parses and validates the file and writes nothing. It returns exactly what the
real import would do, row by row. Both operations take the same multipart/form-data body:
the file in file, and its format in source (csv or holded). source is required;
without it the request answers 400 MISSING_PARAMETER.
curl -X POST https://app.beel.es/api/v1/companies/{company_id}/customers/imports/preview \
-H "Authorization: Bearer $BEEL_API_KEY" \
-F "file=@clientes.csv" \
-F "source=csv"{
"success": true,
"data": {
"metadata": {
"total_customers": 3,
"is_dry_run": true,
"processing_time_ms": 412,
"source_type": "CSV_IMPORT",
"filename": "clientes.csv",
"file_size_bytes": 412,
"total_rows": 3
},
"customers_validation": [
{ "index": 0, "status": "VALID", "errors": [], "warnings": [], "customer": { "legal_name": "Construcciones Año Nuevo SL" } },
{ "index": 1, "status": "DUPLICATE", "errors": [ { "field": "nif", "value": "B12345674", "message": "…" } ], "warnings": [], "customer": { "legal_name": "García, López y Asociados SL" } },
{ "index": 2, "status": "ERROR", "errors": [ { "field": "direccion.codigo_postal", "value": "4100", "message": "…" }, { "field": "direccion", "value": "", "message": "…" } ], "warnings": [], "customer": { "legal_name": "Distribuidora Levante SA" } }
],
"statistics": {
"total_processed": 3, "valid": 1, "with_warnings": 0, "with_errors": 1,
"duplicates": 1, "invalid_nifs": 0, "imported": 0, "success_rate": 0.33,
"importable": 1, "not_importable": 2
}
},
"meta": { "timestamp": "2026-09-24T10:00:00Z", "request_id": "4bf92f3577b34da6a3ce929d0e0e4736" }
}(Rows shortened for readability.)
How to read it:
indexis the position of the row among the data rows, counting from 0 and not counting the header: the first customer of the file isindex: 0.status:VALIDandWARNINGrows will be created.ERROR,DUPLICATEandNIF_INVALIDrows won't be.NIF_INVALIDmeans the tax ID is not in the AEAT census.DUPLICATE: a customer with that NIF already exists in this company. A NIF repeated inside the file is different: the first row goes through and each repetition is anERROR, with an error onfield: "general".WARNINGrows are importable; the warning says what is missing, for example a row without email.statistics.importableis how many customers the real import would create.importedis always0in a preview, because a preview writes nothing.
Rows without a complete address
A customer's address is all or nothing. Each row needs street, postal code (5 digits),
town and province. The street number is optional. If any of the four is missing or
malformed, the row is reported as ERROR and no customer is created for it. The row
carries one error per field that is missing or malformed (direccion.poblacion,
direccion.codigo_postal, …) plus a general direccion error saying the address is
required.
Nothing is filled in for you: no placeholder address is invented to let the row through. The rest of the file imports normally. Complete the address columns of the reported rows and import them again, or leave them out.
Import
Same body, different operation. The import requires an Idempotency-Key, so a retry of
the same request can't create the customers twice. Without it the request answers
400 IDEMPOTENCY_KEY_REQUIRED.
curl -X POST https://app.beel.es/api/v1/companies/{company_id}/customers/imports \
-H "Authorization: Bearer $BEEL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@clientes.csv" \
-F "source=csv"It answers 201 with the same report as the preview. Now metadata.is_dry_run is false,
statistics.imported is the number of customers actually created, and each row that
created a customer carries its customer_id.
Re-importing the same file creates no duplicates: a row whose NIF already belongs to a
customer of this company is reported as DUPLICATE instead of being created again. That
makes it safe to fix the rejected rows and upload the whole file again.
CSV vs. Holded
To import from Holded, export the contacts from Holded as .xlsx and send it with
source=holded. It goes through the same row validation as the CSV, including the address
rule above.
The two sources differ in how many rows and how large a file they take; above either limit the file is rejected as a whole and nothing is imported. Every number, error code and the 8 MB ceiling that applies to any upload are in Limits and pagination.
Bulk creation from JSON
When the data is already in your system, skip the file and send JSON to
POST …/customers/bulk or
POST …/products/bulk. The two behave differently
on purpose — customers are all or nothing, with a dry run; products are created row by row —
and both are covered, with requests and reports, in
Bulk operations and exports.
Gotchas
- Preview first. It costs nothing and returns exactly what the import will do.
statistics.importedis the field that says whether the import worked. The other counters classify the rows of the file.- Incomplete addresses are rejected, not filled in. Expect fewer customers than rows if your source has gaps.
- A Holded export over 8 MB never reaches the import, and one over 5,000 contacts is rejected whole. Split it.
- Customers bulk is atomic, products bulk is not. A
201from products bulk doesn't mean every product was created.
Related
Bulk operations and exports
Issue, mark, download, email and export many invoices at once, and create or delete customers and products in batches — and how to read what came back.
Fiscal summary
Read the VAT and IRPF totals of a company for any period of up to 365 days, plus an annual IRPF projection — a starting point for preparing tax returns.