NewProvince is only required for addresses in Spain
BeeL
Get startedMulti-NIFVeriFactuRulesStripeAPI referenceChangelog

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:

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.csv

The 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;Madrid

The 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 400 MISSING_HEADERS. error.details.missing_headers lists every missing column and found_headers lists 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_city and address_province. nif and email are 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:

  • index is the position of the row among the data rows, counting from 0 and not counting the header: the first customer of the file is index: 0.
  • status: VALID and WARNING rows will be created. ERROR, DUPLICATE and NIF_INVALID rows won't be. NIF_INVALID means 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 an ERROR, with an error on field: "general".
  • WARNING rows are importable; the warning says what is missing, for example a row without email.
  • statistics.importable is how many customers the real import would create. imported is always 0 in 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.imported is 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 201 from products bulk doesn't mean every product was created.