# 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 `.xlsx`
  exported from Holded. Rehearse with [`…/customers/imports/preview`](/customers/previewCompanyCustomerImport),
  then run the import with [`…/customers/imports`](/customers/createCompanyCustomerImport).
- **From JSON**, as an integration would: [`…/customers/bulk`](/customers/createCompanyCustomersBulk)
  and [`…/products/bulk`](/products/createCompanyProductsBulk).

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

<Steps>

<Step>

### Download the template

```bash
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:

```csv
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`](/errors/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`.

</Step>

<Step>

### 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`](/errors/MISSING_PARAMETER).

```bash
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"
```

```json
{
  "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.

</Step>

<Step>

### 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`](/errors/IDEMPOTENCY_KEY_REQUIRED).

```bash
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`.

<Callout type="info">
  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.
</Callout>

</Step>

</Steps>

## 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](/guides/limits-and-pagination#file-uploads).

## Bulk creation from JSON

When the data is already in your system, skip the file and send JSON to
[`POST …/customers/bulk`](/customers/createCompanyCustomersBulk) or
[`POST …/products/bulk`](/products/createCompanyProductsBulk). 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](/guides/bulk-operations-and-exports#customers-in-bulk).

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

## Related

<Related>

- [Download the customer import template](/customers/downloadCustomerImportTemplate)
- [Preview a customer import](/customers/previewCompanyCustomerImport) ·
  [Import customers](/customers/createCompanyCustomerImport)
- [Create customers in bulk](/customers/createCompanyCustomersBulk) ·
  [Create products in bulk](/products/createCompanyProductsBulk)
- [Handling errors](/guides/handling-errors) · [Idempotency](/guides/idempotency)

</Related>

---

Full OpenAPI spec: https://docs.beel.es/api/openapi