---
title: "Import customers into a company"
method: POST
path: "/v1/companies/{company_id}/customers/imports"
tags: ["CompanyCustomerImports"]
---

# Import customers into a company

`POST /v1/companies/{company_id}/customers/imports`

## Request

- Base URL: `https://app.beel.es/api`
- URL: `https://app.beel.es/api/v1/companies/{company_id}/customers/imports`
- Auth: HTTP bearer

Imports customers into this company from an uploaded file.

- **`source`:** the origin travels in the body. `csv` is a file following the import
  template, limited to 5 MB and 1,000 records; `holded` is an Excel (`.xlsx`) exported from
  Holded contacts, limited to 10 MB and 5,000 records. A file over either limit of its
  `source` is rejected as a whole with `400` (`CSV_FILE_TOO_LARGE` or `TOO_MANY_RECORDS`)
  and nothing is imported. A file over 12 MB answers `413`
  (`FILE_TOO_LARGE`) before it is read. A `holded` file that does not open as a workbook
  answers `422 INVALID_EXCEL_FORMAT`, and one whose columns are not those of a Holded
  contacts export `422 INVALID_EXCEL_STRUCTURE`.
- **This operation writes:** the customers it accepts are created, and a record whose tax
  identifier already exists in this company is reported as a duplicate rather than created
  again, so re-importing the same file duplicates nothing.
- **`Idempotency-Key`:** required on this operation.
- **Rehearsal:** to see what would happen without writing anything, use
  `POST .../customers/imports/preview`, a separate operation with no effects at all — the
  import is never governed by a boolean flag.

## Headers

- `Idempotency-Key` string, required

## Response `201`

Import completed. Returns the outcome per record, in the same format as the preview.
`statistics.imported` is how many customers were actually created; every other counter
classifies the submitted rows.

- object: The envelope every successful JSON response of the BeeL. API is wrapped in. There are no bare resources in v1 and none are planned: the payload always hangs off `data`. - A **single resource** is an object in `data`. - A **collection** hangs off a named key inside `data`, together with its `pagination`, also inside `data` — `data: {invoices: [...], pagination: {...}}`. - A collection carries `pagination` unless its operation declares itself a **closed catalogue**: a fixed, bounded list with nothing to page through. The declaration is explicit in the operation; a missing `pagination` is never something to infer. - `GET /v1/accounts` pages by cursor (`data: {accounts: [...], next_cursor}`). It is a documented variant of pagination, not another envelope. Putting the array straight into `data` with `pagination` as its sibling is the shape a v2 would adopt; v1 is not being flipped to it.
  - `success` boolean, required
  - `data` object, required: Unified format for customer validation results. Used for bulk validation (JSON), CSV import and Holded Excel import. One shape for every bulk and import outcome, so a caller reads a batch creation, a CSV import and a Holded import the same way.
    - `metadata` CustomerValidationMetadata, required: Metadata about the validation operation. Compatible with bulk JSON, CSV import and Holded Excel import.
      - `total_customers` integer, required: Total number of customers submitted
      - `is_dry_run` boolean, required: Whether it was validation (true) or import (false)
      - `processing_time_ms` integer, required: Processing time in milliseconds
      - `source_type` 'BULK_JSON' | 'CSV_IMPORT' | 'HOLDED_EXCEL', required: Data source type: - BULK_JSON: Data sent as JSON to bulk endpoint - CSV_IMPORT: Data parsed from CSV file - HOLDED_EXCEL: Data parsed from Holded Excel file
      - `filename` string, nullable: File name (CSV or Excel depending on source_type)
      - `file_size_bytes` integer, nullable: File size in bytes
      - `total_rows` integer, nullable: Total file rows excluding headers
    - `customers_validation` CustomerValidationItem[], required: List of processed customers with their validation status
      - `index` integer, required: Customer index in the submitted list (0-based for bulk, 1-based for files). For CSV/Excel, represents the row number in the file.
      - `customer` CustomerEcho: A customer as it was submitted, once parsed and validated. Same fields as `Customer` minus the ones only a stored customer has (`id`, `created_at`, `updated_at`): a rehearsal writes nothing, so it has none of them to report. The identifier of a customer that WAS created is reported next to this object, in `customer_id`. `Customer` itself is untouched — many endpoints return it and it does require an `id`.
        - `legal_name` string, required
        - `trade_name` string
        - `nif` string: Spanish Tax ID (9 characters, uppercase only). Structural validation: - DNI: 8 digits + 1 letter (e.g., 12345678A) - NIE: X/Y/Z + 7 digits + 1 letter (e.g., X1234567A) - CIF: Organization letter + 7 digits + 1 control digit/letter (e.g., B12345674)
        - `alternative_id` AlternativeIdentifier, nullable: Alternative identifier for customers without Spanish Tax ID. ### VeriFactu rules (checked when you send it, `422` on violation) - `country_code` is required, except for `PASSPORT` (03) and `NOT_REGISTERED` (07), the two types AEAT accepts with `ES`: omitted, `ES` applies. Missing for any other type, the identifier is rejected with `ALTERNATIVE_ID_COUNTRY_REQUIRED`, and `error.details` names the field where it was sent: `alternative_id.country_code` on a customer, `recipient.alternative_id.country_code` on an invoice recipient. Same code in both. - If `country_code = ES`, then `type` **must** be `PASSPORT` (03) or `NOT_REGISTERED` (07) (`ALTERNATIVE_ID_SPAIN_INVALID_TYPE`). - If `type = NOT_REGISTERED` (07), then `country_code` **must** be `ES` (`ALTERNATIVE_ID_REQUIRES_SPAIN`), and `number` **must** be a Spanish DNI or NIE: a Spanish company is always registered (`RECIPIENT_UNREGISTERED_ID_MUST_BE_DNI_OR_NIE`). That DNI or NIE must not be one the AEAT census identifies: if it is, the identifier is rejected with `ALTERNATIVE_ID_ACTUALLY_IN_CENSUS` and has to be sent as `nif`. A customer is checked when it is saved; an invoice recipient, when the invoice is issued with VeriFactu. If the census does not answer, the identifier is not rejected. - If `type = NIF_IVA` (02), `country_code` **must** be an EU member state other than Spain (`ALTERNATIVE_ID_VAT_REQUIRES_EU_COUNTRY`), and `number` **must** have that country's EU VAT number structure as AEAT defines it: the country prefix (`EL` for Greece) followed by the national number, e.g. `FR40303265045`, `DE123456789`, `EL094014201` (`ALTERNATIVE_ID_VAT_INVALID_FORMAT`). Lowercase letters are accepted and stored in uppercase. A customer from outside the EU is identified with another type, such as `OTHER_DOCUMENT` or `COUNTRY_ID`. A `number` that is blank once trimmed is rejected with `ALTERNATIVE_ID_INVALID`. Well-formed is not the same as registered: an EU VAT number that is not in the VIES census is still rejected by VeriFactu after the invoice is issued. An identifier returned in a response is the one stored. A customer saved before a rule existed keeps its identifier and can still be read; issuing an invoice to it with an identifier that breaks these rules is rejected with the same code, before a number is used. ### Matrix of allowed combinations | `type` | `country_code = ES` | `country_code ≠ ES` | |------------------------|:-------------------:|:-------------------:| | `NIF_IVA` (02) | ✗ | EU member states only | | `PASSPORT` (03) | ✓ | ✓ | | `COUNTRY_ID` (04) | ✗ | ✓ | | `RESIDENCE_CERTIFICATE` (05) | ✗ | ✓ | | `OTHER_DOCUMENT` (06) | ✗ | ✓ | | `NOT_REGISTERED` (07) | ✓ | ✗ |
          - `type` 'NIF_IVA' | 'PASSPORT' | 'COUNTRY_ID' | 'RESIDENCE_CERTIFICATE' | 'OTHER_DOCUMENT' | 'NOT_REGISTERED' | '02' | '03' | '04' | '05' | '06' | '07', required: Identifier type. Use descriptive names: - **NIF_IVA**: EU VAT number (intra-community) — *only for an EU member state other than Spain, with that country's VAT number structure* - **PASSPORT**: Passport — *allowed for any country* - **COUNTRY_ID**: Country of residence ID — *not allowed when `country_code = ES`* - **RESIDENCE_CERTIFICATE**: Residence certificate — *not allowed when `country_code = ES`* - **OTHER_DOCUMENT**: Other supporting document — *not allowed when `country_code = ES`* - **NOT_REGISTERED**: Not registered in AEAT — *requires `country_code = ES`* **⚠️ DEPRECATED numeric codes** (will be removed in v2): 02, 03, 04, 05, 06, 07 — use the descriptive names above instead.
          - `number` string, required: Identifier number. For `NIF_IVA`, the full EU VAT number with its country prefix (e.g. `FR40303265045`); see the VeriFactu rules on the parent schema.
          - `country_code` string: ISO 3166-1 alpha-2 code of the country that issued the document. Required except for `PASSPORT` and `NOT_REGISTERED`, where omitting it means `ES`. Constrains the allowed `type` values; see the VeriFactu rules on the parent schema.
        - `address` Address: Address you send when you create or update a company, a customer or an onboarding. A required field made only of whitespace passes the length checks below but leaves the address without that part, so it is rejected with `422`: `STREET_EMPTY`, `POSTAL_CODE_EMPTY`, `CITY_EMPTY` or `PROVINCE_EMPTY`. Addresses you read back are described by their own schema.
          - `street` string, required: Full address (street, number, floor, etc.) - Latin characters only
          - `number` string: Street number. Optional: omit it when the address has none, or when `street` already carries the address in full.
          - `floor` string: Floor or level
          - `door` string: Door or apartment
          - `postal_code` string, required: Postal code (5 digits for Spain, free format for other countries)
          - `city` string, required: City or town - Latin characters only
          - `province` string: Province or state - Latin characters only. Required for an address in Spain (`country_code` `ES`, or no country at all); optional elsewhere, where many addresses have none. A Spanish address without it is rejected with a `422`.
          - `country` string: Country of the address, as its ISO 3166-1 alpha-2 code (`GB`) or its official name in Spanish, English or Catalan (`Reino Unido`, `United Kingdom`, `Regne Unit`; case and accents are ignored). Anything else, such as `UK`, is rejected with `422 COUNTRY_CODE_REQUIRED`: send `country_code` instead. If it names a different country than `country_code`, `422 COUNTRY_CODE_MISMATCH` (`España` alone yields to a foreign `country_code`: it was the old default). What is stored and returned is always the Spanish name derived from the resulting code, never the text sent. With neither field present, the address is Spanish (`España`).
          - `country_code` string: ISO 3166-1 alpha-2 country code: the canonical field that decides the country of the address. It must be a real country code (`GB`, not `UK`); otherwise `422 COUNTRY_CODE_REQUIRED`. When it is omitted, the code comes from `country` (see there). With neither field present, the address is stored as `ES`.
        - `phone` string: A phone number, as the record holds it: digits, spaces, dashes, parentheses and an optional leading `+`, up to 20 characters. This is the schema a **response** carries, and the length above is the only rule it states. It deliberately does not repeat the character rule, because a number can reach a record through a path that predates that rule or never passed through this API at all — a payment provider's customer data, a bulk import. Read the field defensively and do not assume it parses. What a **request** has to satisfy is `PhoneInput`, which adds the rules this API enforces on the way in.
        - `email` string, email, nullable: Main contact address (minimum valid email is 5 chars, e.g. a@b.co). Invoice emails go here only when the customer has no `billing_emails`.
        - `website` string, nullable: Website URL
        - `billing_emails` Email[]: Addresses that receive the customer's invoice emails. When the send request names no `recipients` and the invoice has no `email_config` recipients, invoices go to these addresses, and not to `email`; `email` is used only when `billing_emails` is empty.
        - `contact_person` string
        - `notes` string
        - `preferred_payment_method` PaymentInfo
          - `method` 'NONE' | 'BANK_TRANSFER' | 'CARD' | 'CASH' | 'CHECK' | 'DIRECT_DEBIT' | 'BIZUM' | 'OTHER': Payment method shown on invoices and recurring invoices. - `NONE`: no payment information is shown. - `BANK_TRANSFER`: bank transfer to the IBAN of the payment details; the only method that requires an IBAN. - `CARD`: card payment. - `CASH`: cash payment. - `CHECK`: payment by cheque. - `DIRECT_DEBIT`: direct debit from the customer's bank account. - `BIZUM`: payment through Bizum. - `OTHER`: any other method.
          - `iban` string: IBAN (International Bank Account Number). Required when payment method is BANK_TRANSFER.
          - `swift` string: SWIFT/BIC code
          - `payment_term_days` integer, nullable: Payment term in days. When marking an invoice as paid, every field of this object that travels replaces the stored one and every omitted field keeps its current value.
        - `general_discount` number
        - `active` boolean
      - `customer_id` string, uuid: Universally Unique Identifier (UUID v4)
      - `status` 'VALID' | 'WARNING' | 'ERROR' | 'DUPLICATE' | 'NIF_INVALID', required: Customer validation status: - VALID: Completely valid customer - WARNING: Valid customer but with non-critical warnings - ERROR: Invalid customer, cannot be processed - DUPLICATE: Duplicate customer (in batch/CSV or database) - NIF_INVALID: the identifier does not match the AEAT census: a NIF the census does not identify, or a type 07 (not registered) whose DNI or NIE the census does identify
      - `errors` CustomerValidationError[], required: List of errors found (block import)
        - `field` string, required: Field that failed validation
        - `value` string, required: Value that caused the error
        - `message` string, required: Human-readable text, already resolved to the language of the request. It is never a raw i18n key — a key neither reads nor is stable against a rename.
        - `code` string, required: Stable, uppercase, never translated — this is what an integration compares. Present on every row error. When the same failure exists on the single `POST /v1/companies/{company_id}/customers`, it is the SAME code, so the single and the bulk route never disagree about the same customer (`CLIENT_DUPLICATE`, `NIF_NOT_IN_CENSUS`, `NIF_REVOCADO`, `CUSTOMER_IDENTIFIER_REQUIRED`, `NIF_INVALID_FORMAT`, …). Failures that only an import has carry their own: `CUSTOMER_DUPLICATED_IN_BATCH`, `CUSTOMER_DUPLICATED_IN_FILE`, `CUSTOMER_FIELD_REQUIRED`, `CUSTOMER_FIELD_INVALID_FORMAT`, `CUSTOMER_FIELD_INVALID_CHARACTERS`, `CUSTOMER_ADDRESS_INVALID`, `CUSTOMER_NOT_SAVED`, `CUSTOMER_ROW_NOT_PROCESSED`. An address field of a file row that the JSON contract would have capped carries the address code, blank after trimming or over its length: `STREET_EMPTY`, `POSTAL_CODE_EMPTY`, `CITY_EMPTY`, `PROVINCE_EMPTY`, `STREET_MAX_LENGTH` (255), `NUMBER_MAX_LENGTH` (20), `CITY_MAX_LENGTH` (100), `PROVINCE_MAX_LENGTH` (100). A reason with no specific code yet answers `VALIDATION_ERROR`.
      - `warnings` CustomerValidationWarning[], required: List of warnings (do not block import)
        - `field` string, required: Field with warning
        - `message` string, required: Warning message
      - `row_number` integer, nullable: Row number in the file (CSV or Excel depending on source_type)
    - `statistics` CustomerValidationStatistics, required: Aggregated validation statistics. Compatible with all source types (bulk JSON, CSV and Holded Excel). Every field except `imported` counts rows of the submitted file/payload, classified by outcome: each row falls into exactly one of `valid`, `with_warnings`, `with_errors`, `duplicates` and `invalid_nifs`, so those five add up to `total_processed`. `imported` is the only field that counts writes to the database, so it is the field to read to know whether an import worked.
      - `total_processed` integer, required: Total customers processed
      - `valid` integer, required: Completely valid customers (no warnings or errors)
      - `with_warnings` integer, required: Valid customers but with warnings
      - `with_errors` integer, required: Customers with errors (not importable)
      - `duplicates` integer, required: Customers with duplicate NIFs
      - `invalid_nifs` integer, required: Customers with NIFs not valid in AEAT
      - `imported` integer, required: Customers actually created in the database by this request. Always `0` when `is_dry_run=true` (a preview writes nothing). On a real import it is the number of rows that reached the database — normally `importable`, and lower when a row was rejected on write (it is then reported under `duplicates` or `with_errors`).
      - `success_rate` number, float, required: Success rate (valid + with_warnings) / total_processed
      - `importable` integer: Total customers that can be imported (valid + with_warnings)
      - `not_importable` integer: Total customers that CANNOT be imported (with_errors + duplicates + invalid_nifs)
  - `meta` ResponseMeta
    - `timestamp` string, date-time
    - `request_id` string

## Other responses

- `400`: The file was rejected as a whole, before any record was looked at: it is not valid CSV, its encoding is not UTF-8, required columns are missing, it is over the size limit, or it carries more records than the operation accepts. Which one it was travels in `error.code`, and the values behind it in `error.details`: `missing_headers` and `found_headers` for `MISSING_HEADERS`, `record_count` and `max_records` for `TOO_MANY_RECORDS`, `file_size_mb` and `max_file_size_mb` for `CSV_FILE_TOO_LARGE`. A rejection with nothing to quantify carries no `details`.
- `401`: Missing or invalid authentication. Like every other error, `message`/`detail` is localized according to `Accept-Language` (`es`, `en`, `ca`); defaults to English when the header is missing or asks for none of those.
- `403`: Your account does not own or manage this company, or does not hold the required access over it. A NIF that does not exist answers the same way. That case answers `error.code` `ACTIVE_COMPANY_NOT_ACCESSIBLE`.
- `413`: The uploaded file is over 12 MB (`FILE_TOO_LARGE`) and is rejected before it is read; a file over the limit of its `source` answers `400` instead.
- `422`: `VALIDATION_ERROR` — the body parses but a value is not acceptable. `details` is a flat map from the offending property's name (in the contract's `snake_case`, nested paths joined with a dot, e.g. `recipient.address.postal_code`) to a message describing what is wrong with it, one entry per property. When the rejected value is one outside an enum's vocabulary, `details` instead follows `FieldDeserializationError` (`field`, `invalid_value`, `allowed_values`). Two more codes share this status for a value that passes the contract but does not fit where it is stored: `VALUE_TOO_LONG` (a text longer than its column holds) and `VALUE_OUT_OF_RANGE` (a number outside what its column holds). Nothing was written.
- `429`: Rate limit exceeded. `RATE_LIMIT_EXCEEDED` when the request rate of your credential is over its limit; `TOO_MANY_REQUESTS` when an email the operation sends is held back because too many went out to that recipient in a short time. Either way, retry after the `Retry-After` seconds. `QUOTA_EXCEEDED` is the other `429`: the operation would go over a quota of your plan for the current period (issued invoices, for instance). Retrying does not help until the quota resets or the plan changes. `error.details` carries `feature_code`, `current_usage`, `quota_limit` and `reset_date`, and `RateLimit-Reset` the seconds until the reset when it is known.
- `500`: Internal server error
- `default`: Any status code the operation does not list above. Every operation declares it, so a generated client always has a branch to fall into and never loses the cause of a failure it did not anticipate. This is where the transport-level answers land — `405`, `406`, `415` and `429` — together with any status a future version of the API starts returning. All of them carry the same error envelope as the codes listed explicitly, so `error.code` is what tells them apart: switching on the status code alone is not enough. See «Transport-level errors» in the API description for when each one is produced. A `402` carrying `SUBSCRIPTION_REQUIRED` lands here when the account has no active subscription. `error.details` says why and what to do: `current_status` (absent if it never had one), `required_permission` and `action`. A `503` carrying `SERVICE_UNAVAILABLE` lands here too, with a `Retry-After` header: a dependency of BeeL itself (the database, the email provider) was momentarily unavailable and nothing was done. Retry after those seconds. A `502` carrying `EXTERNAL_SERVICE_ERROR` also lands here: an outbound integration the operation depends on failed or did not answer in time. It is a transient condition — retry with the same `Idempotency-Key` where the operation accepts one. One exception to the envelope: a failure of the network edge that never reaches the application (`502`, `503`, `504`, `524`) is generated by Cloudflare and its body is not BeeL's — it may not even be JSON. Treat those as "no answer", and retry.

---

[API](https://skmtc.dev/beel/apis/beel-public-api.md) · [All operations](https://skmtc.dev/beel/apis/beel-public-api/llms.txt) · [OpenAPI document](https://skmtc.dev/beel/apis/beel-public-api/revisions/ffbc0ae31f79?raw)
